` since the whole frame is `aria-hidden` decoration.
+ */
+function CalendarPanePill({ pill, animate }: CalendarPanePillProps) {
+ return (
+
+ {pill.time}
+ {pill.title}
+
+ )
+}
+
+interface ScheduledTasksCalendarPaneProps {
+ /** How many Weekly KPI pills the stamp-in series has landed (0..5). */
+ stampedCount: number
+}
+
+/**
+ * The static Scheduled Tasks page in the real workspace's exact vocabulary -
+ * the resource header (Calendar icon + title + primary "New scheduled task"
+ * chip), the calendar toolbar (Today jump, period label, prev/next chevrons,
+ * scope chip), the sticky weekday header, and the five-week month grid of day
+ * cells with the today square and stacked task pills - rendered from the
+ * parent clock's `stampedCount` beat.
+ */
+function ScheduledTasksCalendarPane({ stampedCount }: ScheduledTasksCalendarPaneProps) {
+ return (
+
+
+
+
+ Scheduled Tasks
+
+
+ New scheduled task
+
+
+
+
+
+ Today
+ {MONTH_LABEL}
+
+
+
+
+ Month
+
+
+
+
+ {WEEKDAY_LABELS.map((label, index) => (
+
+ {label}
+
+ ))}
+
+
+
+ {CALENDAR_CELLS.map((cell, index) => (
+
+
+ {cell.day}
+
+
+ {cell.pills.map((pill) =>
+ pill.stampIndex !== undefined && pill.stampIndex >= stampedCount ? null : (
+
+ )
+ )}
+
+
+ ))}
+
+
+ )
+}
+
+/**
+ * The scheduled-tasks hero's calendar loop - the workspace's real Scheduled
+ * Tasks surface as the workspace pane. Same architecture as the sibling hero
+ * loops (the {@link HeroLoopShell}'s fixed 1280x735 design-space layer scaled
+ * to the window, a parent-owned clock driving a presentational pane,
+ * reduced-motion showing the finished frame) and the same live sidebar with
+ * its Scheduled tasks nav row highlighted, but the pane is the month calendar
+ * itself: the settled recurring schedules hold, then a new Weekly KPI report
+ * schedule lands pill by pill across the month's Mondays, the fully
+ * scheduled month holds, and the scene fades before the cycle restarts.
+ *
+ * Everything is `pointer-events-none` decorative, matching the hero's
+ * `aria-hidden` frame. Under `prefers-reduced-motion` the loop never starts:
+ * the fully scheduled calendar renders statically.
+ */
+export function ScheduledTasksCalendarLoop() {
+ const [stampedCount, setStampedCount] = useState(0)
+ const [fading, setFading] = useState(false)
+ const [cycleId, setCycleId] = useState(0)
+
+ useMotionSafeCycle({
+ scheduleCycle: () => {
+ setFading(false)
+ setStampedCount(0)
+ setCycleId((c) => c + 1)
+ const totalMs = IDLE_HOLD_MS + (TOTAL_STAMPED_PILLS - 1) * PILL_STEP_MS + SCHEDULED_HOLD_MS
+ return {
+ timers: [
+ ...Array.from({ length: TOTAL_STAMPED_PILLS }, (_, i) =>
+ setTimeout(() => setStampedCount(i + 1), IDLE_HOLD_MS + i * PILL_STEP_MS)
+ ),
+ setTimeout(() => setFading(true), totalMs - RESET_FADE_MS),
+ ],
+ totalMs,
+ }
+ },
+ showFinished: () => {
+ setFading(false)
+ setStampedCount(TOTAL_STAMPED_PILLS)
+ },
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/scheduled-tasks/page.tsx b/apps/sim/app/(landing)/scheduled-tasks/page.tsx
new file mode 100644
index 00000000000..8ab38fc0d80
--- /dev/null
+++ b/apps/sim/app/(landing)/scheduled-tasks/page.tsx
@@ -0,0 +1,20 @@
+import { buildLandingMetadata } from '@/lib/landing/seo'
+import ScheduledTasks from '@/app/(landing)/scheduled-tasks/scheduled-tasks'
+
+export const revalidate = 3600
+
+const TITLE = 'Scheduled Tasks | Run Agents on a Cadence in Sim, the AI Workspace'
+const DESCRIPTION =
+ 'Scheduled Tasks runs your AI agents on a cadence in Sim, the open-source AI workspace. Schedule any workflow from 15-minute intervals to monthly or custom cron, timezone-aware, with every run traced.'
+
+export const metadata = buildLandingMetadata({
+ title: TITLE,
+ description: DESCRIPTION,
+ path: '/scheduled-tasks',
+ keywords:
+ 'AI workspace, scheduled AI agents, cron AI workflows, recurring agent runs, AI task scheduler, schedule workflow automation, open-source AI agent platform, agentic workflows',
+})
+
+export default function Page() {
+ return
+}
diff --git a/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx
new file mode 100644
index 00000000000..8a8113f25f3
--- /dev/null
+++ b/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx
@@ -0,0 +1,194 @@
+import {
+ PlatformHeroVisual,
+ SolutionsPage,
+ type SolutionsPageConfig,
+} from '@/app/(landing)/components'
+import {
+ AuditTrailGraphic,
+ DeployGraphic,
+ ItPlatformTeamsGraphic,
+ OperationsTeamsGraphic,
+ RunMonitoringGraphic,
+} from '@/app/(landing)/enterprise/components/feature-graphics'
+import { ScheduledTasksCalendarLoop } from '@/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop'
+import { DocumentDraftGraphic } from '@/app/(landing)/solutions/components/feature-graphics'
+
+/**
+ * Scheduled Tasks platform page - a consumer of {@link SolutionsPage}
+ * rendered with the enterprise page's feature-tile treatment.
+ *
+ * The page tells the product's real scheduling story: workflows carry
+ * schedule triggers (from every-15-minutes intervals through daily, weekly,
+ * and monthly cadences to custom cron, timezone-aware), and the workspace's
+ * Scheduled Tasks calendar shows every upcoming and finished run with
+ * pause/resume and full run history. The hero replays that calendar itself -
+ * the month grid filling with recurring task pills as a new schedule lands
+ * on its cadence. Every card visual slot reuses a
+ * parametrized enterprise feature graphic retold for recurring runs - the
+ * policy checklist as a schedule card, the routing fan as the delivery
+ * fan-out, the drafted document as the digest that arrives on time, the
+ * monitoring panel, deploy window, and audit ledger for the operate story.
+ *
+ * The JSON-LD emitted by {@link SolutionsPage} is structurally identical to
+ * the workflows page's (`WebPage` + `BreadcrumbList` + `WebApplication`).
+ */
+const SCHEDULED_TASKS_CONFIG: SolutionsPageConfig = {
+ module: 'Scheduled Tasks',
+ path: '/scheduled-tasks',
+ hero: {
+ eyebrow: 'Scheduled Tasks',
+ heading: 'Set agents to run on a schedule, and let the work happen on its own.',
+ description:
+ 'Scheduled Tasks runs your agents on a cadence in Sim, the open-source AI workspace. Pick a time from every 15 minutes to monthly or write a cron, timezone-aware, with every run traced.',
+ summary:
+ 'Scheduled Tasks is the scheduler in Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. Put any workflow on a cadence, from 15-minute intervals to daily, weekly, and monthly runs or custom cron, timezone-aware, then watch every scheduled run land on the workspace calendar with full run history.',
+ visual: (
+
+
+
+ ),
+ },
+ rows: [
+ {
+ id: 'schedule',
+ title: 'Put any agent on a cadence.',
+ subtitle:
+ 'Sim turns a workflow into a scheduled task with one trigger, from 15-minute intervals to monthly closes, in your timezone.',
+ cta: { label: 'Schedule your first agent', href: '/signup' },
+ cards: [
+ {
+ title: 'Pick the cadence',
+ description:
+ 'Choose minutes, hourly, daily, weekly, or monthly, or write a cron expression for exact control.',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Fan out on every run',
+ description:
+ 'Each run pulls from your systems and delivers wherever the work lives, without anyone pressing go.',
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Wake up to the output',
+ description:
+ 'The digest, report, or sync is done before the day starts. Sim delivers it where your team looks first.',
+ visual: (
+
+ ),
+ },
+ ],
+ },
+ {
+ id: 'operate',
+ title: 'Stay on top of every run.',
+ subtitle:
+ 'Scheduled runs land on the workspace calendar, and Sim keeps the full history so nothing happens silently.',
+ cta: { label: 'See runs in action', href: '/signup' },
+ cards: [
+ {
+ title: 'Trace each scheduled run',
+ description:
+ 'Every run is logged block by block, with the trigger, duration, and output a click away.',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Keep it running',
+ description:
+ 'Schedules stay live in production. Pause, resume, or retune the cadence without redeploying.',
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Keep the history',
+ description:
+ 'Every run and every schedule change lands in one record, so teams see what ran and when.',
+ visual: (
+
+ ),
+ },
+ ],
+ },
+ ],
+}
+
+export default function ScheduledTasks() {
+ return
+}
diff --git a/apps/sim/app/(landing)/solutions/compliance/compliance.tsx b/apps/sim/app/(landing)/solutions/compliance/compliance.tsx
index b7abc6b3b4d..96720d1dc34 100644
--- a/apps/sim/app/(landing)/solutions/compliance/compliance.tsx
+++ b/apps/sim/app/(landing)/solutions/compliance/compliance.tsx
@@ -1,23 +1,45 @@
-import { SolutionsPage, type SolutionsPageConfig } from '@/app/(landing)/components'
+import {
+ PlatformHeroVisual,
+ SolutionsPage,
+ type SolutionsPageConfig,
+} from '@/app/(landing)/components'
+import {
+ AccessControlGraphic,
+ AuditTrailGraphic,
+ ItPlatformTeamsGraphic,
+ StandardsGraphic,
+ TechnicalTeamsGraphic,
+} from '@/app/(landing)/enterprise/components/feature-graphics'
+import { ComplianceHeroLoop } from '@/app/(landing)/solutions/compliance/components/compliance-hero-loop'
+import { DocumentDraftGraphic } from '@/app/(landing)/solutions/components/feature-graphics'
/**
- * Compliance solution page - a reference consumer of {@link SolutionsPage}.
+ * Compliance solution page - a consumer of {@link SolutionsPage} rendered
+ * with the enterprise page's feature-tile treatment.
*
- * The whole page is one typed {@link SolutionsPageConfig} rendered inside the
- * shared route-group layout's chrome. Visual slots are `null`, so each renders the
- * layout's reserved placeholder panel; a real page swaps in its own client
- * island without touching the layout.
+ * The whole page is one typed {@link SolutionsPageConfig} rendered inside
+ * the shared route-group layout's chrome. Every visual slot carries an
+ * enterprise feature graphic - reused directly where its story fits the
+ * card (the audit ledger, the access role graph) or retold through the
+ * graphics' content props for compliance's use cases (evidence schedules,
+ * framework monitoring, policy review) - so the page shares the enterprise
+ * design language without any new visual vocabulary.
*/
const COMPLIANCE_CONFIG: SolutionsPageConfig = {
module: 'Compliance',
path: '/solutions/compliance',
hero: {
+ eyebrow: 'Compliance',
heading: 'Automate evidence, control checks, and audit reports with Sim agents.',
description:
'Compliance teams build AI agents in Sim, the open-source AI workspace, that monitor controls and assemble a clean, defensible record, keeping the organization continuously audit-ready instead of scrambling once a year.',
summary:
'Sim is the open-source AI workspace where compliance teams build, deploy, and manage AI agents that automate evidence collection, control monitoring, and reporting, keeping the organization continuously audit-ready across 1,000+ integrations.',
- visual: null,
+ visual: (
+
+
+
+ ),
},
rows: [
{
@@ -30,18 +52,50 @@ const COMPLIANCE_CONFIG: SolutionsPageConfig = {
title: 'Collect evidence',
description:
'Sim gathers screenshots, logs, and records from your systems on a schedule.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Monitor controls',
description: 'Sim continuously checks controls and flags drift the moment it appears.',
- visual: null,
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual:
,
},
{
title: 'Check policies',
description:
'Sim reviews changes against your policies and raises anything out of bounds.',
- visual: null,
+ visual: (
+
+ ),
},
],
},
@@ -54,18 +108,25 @@ const COMPLIANCE_CONFIG: SolutionsPageConfig = {
{
title: 'Review access',
description: 'Sim runs periodic access reviews and routes exceptions for sign-off.',
- visual: null,
+ visual:
,
},
{
title: 'Generate reports',
description:
'Sim assembles audit-ready reports from live evidence, not stale spreadsheets.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Trace every action',
description: 'Sim logs every run block by block, so auditors see exactly what happened.',
- visual: null,
+ visual:
,
},
],
},
diff --git a/apps/sim/app/(landing)/solutions/compliance/components/compliance-hero-loop.tsx b/apps/sim/app/(landing)/solutions/compliance/components/compliance-hero-loop.tsx
new file mode 100644
index 00000000000..9a842fcd87c
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/compliance/components/compliance-hero-loop.tsx
@@ -0,0 +1,117 @@
+'use client'
+
+import { AgentIcon, ConditionalIcon, ScheduleIcon, SlackIcon, TableIcon } from '@/components/icons'
+import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop'
+import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
+
+/**
+ * Compliance content for the shared platform loop - an evidence-collection
+ * agent: the prompt asks for weekly SOC 2 evidence sweeps with drift flags,
+ * the sidebar reads like the GRC team's Brightwave workspace, and the staged
+ * workflow builds the evidence flow on the enterprise stage geometry. The
+ * trigger is a Schedule block (the prompt's "every Monday" cadence); the only
+ * brand tile is Slack's, where drift gets flagged.
+ */
+const COMPLIANCE_LOOP_CONTENT: EnterpriseLoopContent = {
+ workspaceName: 'Brightwave GRC',
+ greeting: 'What should we get done, Dana?',
+ placeholder: 'Ask Sim to automate a process.',
+ prompt:
+ 'Every Monday, pull access logs and config changes from our core systems, check them against SOC 2 controls, and flag any drift for review.',
+ reply:
+ "On it. I'll collect the evidence every Monday, map it to your SOC 2 controls, and flag any drift for the team to review.",
+ sidebarChats: [
+ 'SOC 2 evidence gaps',
+ 'Vendor DPA renewals',
+ 'Q3 access review',
+ 'Policy update rollout',
+ ],
+ sidebarWorkflows: [
+ 'Evidence collection',
+ 'Access review',
+ 'Vendor risk scoring',
+ 'Policy attestation',
+ 'Audit trail export',
+ ],
+ suggestedActions: [
+ 'Collect this week\u2019s SOC 2 evidence',
+ 'Review stale access grants',
+ 'Summarize open audit findings',
+ 'Draft the vendor risk report',
+ ],
+ stageBlocks: [
+ {
+ id: 'schedule',
+ name: 'Schedule',
+ icon: ScheduleIcon,
+ bgColor: 'var(--text-muted)',
+ isTrigger: true,
+ rows: [{ title: 'Cadence', value: '-' }],
+ x: 155,
+ y: 12,
+ },
+ {
+ id: 'collect',
+ name: 'Collect evidence',
+ icon: AgentIcon,
+ bgColor: 'var(--text-primary)',
+ rows: [
+ { title: 'Messages', value: '-' },
+ { title: 'Model', value: '-' },
+ ],
+ x: 155,
+ y: 172,
+ },
+ {
+ id: 'controls',
+ name: 'Check controls',
+ icon: ConditionalIcon,
+ bgColor: 'var(--text-secondary)',
+ rows: [{ title: 'Conditions', value: '-' }],
+ x: 155,
+ y: 372,
+ },
+ {
+ id: 'drift',
+ name: 'Flag drift',
+ icon: SlackIcon,
+ bgColor: '#611F69',
+ isTerminal: true,
+ rows: [
+ { title: 'Channel', value: '-' },
+ { title: 'Message', value: '-' },
+ ],
+ x: 0,
+ y: 560,
+ },
+ {
+ id: 'ledger',
+ name: 'Evidence log',
+ icon: TableIcon,
+ bgColor: 'var(--text-body)',
+ isTerminal: true,
+ rows: [
+ { title: 'Table', value: '-' },
+ { title: 'Operation', value: '-' },
+ ],
+ x: 310,
+ y: 560,
+ },
+ ],
+ stageEdges: [
+ ['schedule', 'collect'],
+ ['collect', 'controls'],
+ ['controls', 'drift'],
+ ['controls', 'ledger'],
+ ],
+ stageCanvas: { width: 560, height: 680 },
+}
+
+/**
+ * The compliance hero's platform loop - the shared enterprise loop replayed
+ * with the evidence-collection story above. Client wrapper so the icon-bearing
+ * content object never crosses a server/client boundary.
+ */
+export function ComplianceHeroLoop() {
+ return
+}
diff --git a/apps/sim/app/(landing)/solutions/components/feature-graphics/document-draft-graphic.module.css b/apps/sim/app/(landing)/solutions/components/feature-graphics/document-draft-graphic.module.css
new file mode 100644
index 00000000000..b7684272ebd
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/components/feature-graphics/document-draft-graphic.module.css
@@ -0,0 +1,74 @@
+/**
+ * The DocumentDraftGraphic's motion: the greeked prose bars draw in
+ * left-to-right one after another — a one-shot write, never re-typed,
+ * like the audit tile's stamp-in — and the header's status tag then
+ * carries the family's shared quiet 6s ring-pulse beat. Under
+ * prefers-reduced-motion everything renders fully drawn and static.
+ */
+
+.bar0,
+.bar1,
+.bar2,
+.bar3,
+.bar4 {
+ animation: document-draft-bar 0.6s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+.bar0 {
+ animation-delay: 0.35s;
+}
+
+.bar1 {
+ animation-delay: 0.55s;
+}
+
+.bar2 {
+ animation-delay: 0.75s;
+}
+
+.bar3 {
+ animation-delay: 0.95s;
+}
+
+.bar4 {
+ animation-delay: 1.15s;
+}
+
+@keyframes document-draft-bar {
+ from {
+ opacity: 0;
+ transform: scaleX(0);
+ }
+ to {
+ opacity: 1;
+ transform: scaleX(1);
+ }
+}
+
+.statusPulse {
+ animation: document-draft-status-pulse 6s ease-out infinite;
+}
+
+@keyframes document-draft-status-pulse {
+ 0%,
+ 20% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 30%, transparent);
+ }
+ 36% {
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bar0,
+ .bar1,
+ .bar2,
+ .bar3,
+ .bar4,
+ .statusPulse {
+ animation: none;
+ }
+}
diff --git a/apps/sim/app/(landing)/solutions/components/feature-graphics/document-draft-graphic.tsx b/apps/sim/app/(landing)/solutions/components/feature-graphics/document-draft-graphic.tsx
new file mode 100644
index 00000000000..c4273adcc92
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/components/feature-graphics/document-draft-graphic.tsx
@@ -0,0 +1,106 @@
+import { ChipTag, cn } from '@sim/emcn'
+import { CircleCheck, File } from '@sim/emcn/icons'
+import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics'
+import styles from '@/app/(landing)/solutions/components/feature-graphics/document-draft-graphic.module.css'
+
+/**
+ * Per-line draft-bar widths, varied so the greeked page reads as prose —
+ * a short opening line, two full paragraph lines, then a second
+ * paragraph that trails off mid-line.
+ */
+const DRAFT_BAR_WIDTHS = ['w-[62%]', 'w-full', 'w-[86%]', 'w-[94%]', 'w-[54%]'] as const
+
+/** Per-index draw classes — the stagger order is baked into each class's delay. */
+const DRAFT_BAR_CLASSES = [styles.bar0, styles.bar1, styles.bar2, styles.bar3, styles.bar4] as const
+
+/**
+ * A document being drafted by a Sim agent, told inside the lifecycle
+ * tile's outlined window chrome: the window is a 1px `--border-1`
+ * hairline outline with no fill (tile grey showing through), anchored to
+ * the text column's left edge (`left-0`, `top-5`) and bleeding off the
+ * right and bottom edges with a `rounded-tl-xl` top-left corner — the
+ * same slot geometry as the build, lifecycle, and rollback windows. The
+ * title bar pairs the document name (led by a `File` icon in the
+ * lifecycle header's outlined `size-6` icon box) with a right-aligned
+ * mono status tag on the grey-ground `--surface-6` pill fill, at the
+ * family's `h-12` header height.
+ *
+ * Inside, the draft is greeked: quiet `--surface-6` prose bars of varied
+ * measure draw in left-to-right one after another (from
+ * `document-draft-graphic.module.css` — a one-shot stagger like the
+ * audit tile's stamp-in, since the draft is written once, not re-typed),
+ * and the closing delivery row is the tile's highlight — a white card in
+ * the audit tile's exact chrome (`--white` fill, 1px `--border-1`
+ * hairline, `rounded-xl`, `shadow-sm`) pairing a passing circle-check
+ * and the delivery claim with a right-aligned timestamp. The header's
+ * status tag carries the family's shared quiet 6s ring pulse. Both
+ * motions are removed under `prefers-reduced-motion`.
+ *
+ * The window bleeds `2rem` past the tile's visible right edge (`1.5rem`
+ * under `max-lg`), so the header and body carry matching extra right
+ * padding (`pr-12 max-lg:pr-10`) to keep the status tag and the white
+ * card fully inside the visible tile — the rollback window's
+ * compensation.
+ */
+interface DocumentDraftGraphicProps {
+ /** Document name in the title bar. */
+ title?: string
+ /** Mono status tag on the title bar's right side. */
+ statusTag?: string
+ /** Delivery claim on the closing white card. */
+ footerLabel?: string
+ /** Right-aligned timestamp on the closing white card. */
+ footerDetail?: string
+}
+
+export function DocumentDraftGraphic({
+ title = 'Draft',
+ statusTag = 'Auto-drafted',
+ footerLabel = 'Ready for review',
+ footerDetail = 'Just now',
+}: DocumentDraftGraphicProps = {}) {
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {statusTag}
+
+
+
+
+ {DRAFT_BAR_WIDTHS.map((width, index) => (
+
+ ))}
+
+
+
+
+ {footerLabel}
+
+ {footerDetail}
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/solutions/components/feature-graphics/index.ts b/apps/sim/app/(landing)/solutions/components/feature-graphics/index.ts
new file mode 100644
index 00000000000..85913e07aa2
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/components/feature-graphics/index.ts
@@ -0,0 +1,2 @@
+export { DocumentDraftGraphic } from './document-draft-graphic'
+export { KnowledgeAnswerGraphic } from './knowledge-answer-graphic'
diff --git a/apps/sim/app/(landing)/solutions/components/feature-graphics/knowledge-answer-graphic.module.css b/apps/sim/app/(landing)/solutions/components/feature-graphics/knowledge-answer-graphic.module.css
new file mode 100644
index 00000000000..bcb73deb415
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/components/feature-graphics/knowledge-answer-graphic.module.css
@@ -0,0 +1,62 @@
+/**
+ * The KnowledgeAnswerGraphic's motion: the exchange stamps in top to
+ * bottom — question, answer, then source card — the audit tile's
+ * one-shot settle from above, never re-played, and the source card's
+ * icon box then carries the family's shared quiet 6s ring-pulse beat.
+ * Under prefers-reduced-motion everything renders settled and static.
+ */
+
+.stepQuestion,
+.stepAnswer,
+.stepSource {
+ animation: knowledge-answer-stamp-in 0.9s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+.stepQuestion {
+ animation-delay: 0.35s;
+}
+
+.stepAnswer {
+ animation-delay: 0.85s;
+}
+
+.stepSource {
+ animation-delay: 1.35s;
+}
+
+@keyframes knowledge-answer-stamp-in {
+ from {
+ opacity: 0;
+ transform: translateY(-6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.sourcePulse {
+ animation: knowledge-answer-source-pulse 6s ease-out 1.8s infinite;
+}
+
+@keyframes knowledge-answer-source-pulse {
+ 0%,
+ 20% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 30%, transparent);
+ }
+ 36% {
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .stepQuestion,
+ .stepAnswer,
+ .stepSource,
+ .sourcePulse {
+ animation: none;
+ }
+}
diff --git a/apps/sim/app/(landing)/solutions/components/feature-graphics/knowledge-answer-graphic.tsx b/apps/sim/app/(landing)/solutions/components/feature-graphics/knowledge-answer-graphic.tsx
new file mode 100644
index 00000000000..044b3566bf7
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/components/feature-graphics/knowledge-answer-graphic.tsx
@@ -0,0 +1,105 @@
+import { cn } from '@sim/emcn'
+import { BookOpen } from '@sim/emcn/icons'
+import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics'
+import styles from '@/app/(landing)/solutions/components/feature-graphics/knowledge-answer-graphic.module.css'
+
+/**
+ * An internal help-desk agent answering from the team's own docs, told
+ * as a frameless chat vignette (the audit and monitoring tiles'
+ * composition — no window chrome): the employee's question sits
+ * right-aligned as a chat bubble in the family's white card chrome
+ * (`--white` fill, 1px `--border-1` hairline — the build tile's
+ * `--surface-3` bubble would vanish against this tile's own
+ * `--surface-3` fill), the agent's grounded answer reads back as plain
+ * `--text-primary` prose, and the tile's highlight is the citation — a
+ * white source card in the audit tile's exact chrome (`--white` fill,
+ * 1px `--border-1` hairline, `rounded-xl`, `shadow-sm`) pairing a
+ * `BookOpen` icon in the lifecycle header's outlined `size-6` icon box
+ * with the knowledge-base document the answer came from.
+ *
+ * Motion (from `knowledge-answer-graphic.module.css`): the exchange
+ * stamps in top to bottom one element after another — question, answer,
+ * then source — the audit tile's one-shot settle, never re-played — and
+ * the source card's icon box then carries the family's shared quiet 6s
+ * ring pulse to mark the grounding as the point. Both are removed under
+ * `prefers-reduced-motion`.
+ *
+ * The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
+ * `max-lg`) but not left, so this centered vignette adds matching right
+ * padding to land on the tile's visible center instead of the bled
+ * slot's center. The column is fluid (`w-full max-w-[312px]`) so it
+ * never exceeds the compensated slot at narrow tile widths — the source
+ * label truncates instead of clipping. On the wide spanned tile of the
+ * two-column band (container ≥500px inside `sm`..`lg`) the column
+ * relaxes to 400px so the exchange breathes into the wide slot.
+ */
+interface KnowledgeAnswerGraphicProps {
+ /** The employee's question bubble. */
+ question?: string
+ /** The agent's grounded answer. */
+ answer?: string
+ /** Document name on the source card. */
+ sourceLabel?: string
+ /** Attribution line beneath the source name. */
+ sourceDetail?: string
+}
+
+export function KnowledgeAnswerGraphic({
+ question = 'How do I reset my SSO password?',
+ answer = 'Head to id.acme.com, choose "Forgot password", and follow the email link — no ticket needed.',
+ sourceLabel = 'IT handbook',
+ sourceDetail = 'Answered from your docs',
+}: KnowledgeAnswerGraphicProps = {}) {
+ return (
+
+
+
+
+ {question}
+
+
+
+ {answer}
+
+
+
+
+
+
+
+
+ {sourceLabel}
+
+
+ {sourceDetail}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/solutions/engineering/components/engineering-hero-loop.tsx b/apps/sim/app/(landing)/solutions/engineering/components/engineering-hero-loop.tsx
new file mode 100644
index 00000000000..57d36b5f80d
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/engineering/components/engineering-hero-loop.tsx
@@ -0,0 +1,119 @@
+'use client'
+
+import { AgentIcon, ConditionalIcon, JiraIcon, SlackIcon, StartIcon } from '@/components/icons'
+import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop'
+import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
+
+/**
+ * Engineering content for the shared platform loop - an on-call triage agent:
+ * the prompt asks for alert triage into Jira and Slack, the sidebar reads like
+ * the engineering team's Brightwave workspace, and the staged workflow builds
+ * the alert-triage flow on the enterprise stage geometry (spine at x=155,
+ * terminals fanned at y=560). Colors follow the stage convention - grey ramp
+ * for platform blocks, brand tiles only for real third-party marks (Slack's
+ * plum, Jira's mark on a hairlined white tile).
+ */
+const ENGINEERING_LOOP_CONTENT: EnterpriseLoopContent = {
+ workspaceName: 'Brightwave Engineering',
+ greeting: 'What should we get done, Alex?',
+ placeholder: 'Ask Sim to automate a process.',
+ prompt:
+ 'When a PagerDuty alert fires, pull recent deploys and error traces, triage severity, and file a Jira issue with the context on-call needs.',
+ reply:
+ "On it. I'll triage each alert against recent deploys and traces, set severity, and file the Jira issue with full context for on-call.",
+ sidebarChats: [
+ 'Flaky deploy pipeline',
+ 'PR review backlog',
+ 'Incident 4213 postmortem',
+ 'Staging env access',
+ ],
+ sidebarWorkflows: [
+ 'Code review agent',
+ 'On-call triage',
+ 'Release notes drafts',
+ 'CI failure triage',
+ 'Runbook sync',
+ ],
+ suggestedActions: [
+ 'Review open pull requests in GitHub',
+ 'Triage overnight PagerDuty alerts',
+ 'Summarize failing CI runs',
+ 'Draft release notes for v2.31',
+ ],
+ stageBlocks: [
+ {
+ id: 'start',
+ name: 'Start',
+ icon: StartIcon,
+ bgColor: 'var(--text-muted)',
+ isTrigger: true,
+ rows: [{ title: 'Inputs', value: '-' }],
+ x: 155,
+ y: 12,
+ },
+ {
+ id: 'triage',
+ name: 'Triage alert',
+ icon: AgentIcon,
+ bgColor: 'var(--text-primary)',
+ rows: [
+ { title: 'Messages', value: '-' },
+ { title: 'Model', value: '-' },
+ ],
+ x: 155,
+ y: 172,
+ },
+ {
+ id: 'severity',
+ name: 'Set severity',
+ icon: ConditionalIcon,
+ bgColor: 'var(--text-secondary)',
+ rows: [{ title: 'Conditions', value: '-' }],
+ x: 155,
+ y: 372,
+ },
+ {
+ id: 'oncall',
+ name: 'Notify on-call',
+ icon: SlackIcon,
+ bgColor: '#611F69',
+ isTerminal: true,
+ rows: [
+ { title: 'Channel', value: '-' },
+ { title: 'Message', value: '-' },
+ ],
+ x: 0,
+ y: 560,
+ },
+ {
+ id: 'jira',
+ name: 'File Jira issue',
+ icon: JiraIcon,
+ bgColor: '#FFFFFF',
+ tileBorder: true,
+ isTerminal: true,
+ rows: [
+ { title: 'Project', value: '-' },
+ { title: 'Summary', value: '-' },
+ ],
+ x: 310,
+ y: 560,
+ },
+ ],
+ stageEdges: [
+ ['start', 'triage'],
+ ['triage', 'severity'],
+ ['severity', 'oncall'],
+ ['severity', 'jira'],
+ ],
+ stageCanvas: { width: 560, height: 680 },
+}
+
+/**
+ * The engineering hero's platform loop - the shared enterprise loop replayed
+ * with the on-call triage story above. Client wrapper so the icon-bearing
+ * content object never crosses a server/client boundary.
+ */
+export function EngineeringHeroLoop() {
+ return
+}
diff --git a/apps/sim/app/(landing)/solutions/engineering/engineering.tsx b/apps/sim/app/(landing)/solutions/engineering/engineering.tsx
index b2aef432679..cf72717bbdf 100644
--- a/apps/sim/app/(landing)/solutions/engineering/engineering.tsx
+++ b/apps/sim/app/(landing)/solutions/engineering/engineering.tsx
@@ -1,23 +1,45 @@
-import { SolutionsPage, type SolutionsPageConfig } from '@/app/(landing)/components'
+import {
+ PlatformHeroVisual,
+ SolutionsPage,
+ type SolutionsPageConfig,
+} from '@/app/(landing)/components'
+import {
+ DeployGraphic,
+ OperationsTeamsGraphic,
+ RunMonitoringGraphic,
+ StagingGraphic,
+ TechnicalTeamsGraphic,
+} from '@/app/(landing)/enterprise/components/feature-graphics'
+import { DocumentDraftGraphic } from '@/app/(landing)/solutions/components/feature-graphics'
+import { EngineeringHeroLoop } from '@/app/(landing)/solutions/engineering/components/engineering-hero-loop'
/**
- * Engineering solution page - a reference consumer of {@link SolutionsPage}.
+ * Engineering solution page - a consumer of {@link SolutionsPage} rendered
+ * with the enterprise page's feature-tile treatment.
*
- * The whole page is one typed {@link SolutionsPageConfig} rendered inside the
- * shared route-group layout's chrome. Visual slots are `null`, so each renders the
- * layout's reserved placeholder panel; a real page swaps in its own client
- * island without touching the layout.
+ * The whole page is one typed {@link SolutionsPageConfig} rendered inside
+ * the shared route-group layout's chrome. Every visual slot carries an
+ * enterprise feature graphic - reused directly where its story fits the
+ * card (the code-review diff, the promotion window) or retold through the
+ * graphics' content props for engineering's use cases (on-call routing,
+ * CI/CD deploys, runbook docs) - so the page shares the enterprise design
+ * language without any new visual vocabulary.
*/
const ENGINEERING_CONFIG: SolutionsPageConfig = {
module: 'Engineering',
path: '/solutions/engineering',
hero: {
+ eyebrow: 'Engineering',
heading: 'Automate code review, on-call, and docs with Sim agents.',
description:
'Engineering teams build AI agents in Sim, the open-source AI workspace, wired into GitHub, CI/CD, and 1,000+ integrations, visually, conversationally, or with code.',
summary:
'Sim is the open-source AI workspace where engineering teams build, deploy, and manage AI agents across the software lifecycle, automating code review, on-call triage, and documentation, wired into GitHub, CI/CD, and 1,000+ integrations.',
- visual: null,
+ visual: (
+
+
+
+ ),
},
rows: [
{
@@ -30,18 +52,32 @@ const ENGINEERING_CONFIG: SolutionsPageConfig = {
title: 'Review pull requests',
description:
'Sim agents review diffs, flag risks, and leave inline comments before a human looks.',
- visual: null,
+ visual:
,
},
{
title: 'Triage on-call',
description:
'Sim reads alerts, gathers context, and proposes a fix so on-call starts ahead.',
- visual: null,
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
},
{
title: 'Generate docs',
description: 'Sim keeps READMEs and runbooks in sync with the code as it changes.',
- visual: null,
+ visual: (
+
+ ),
},
],
},
@@ -54,19 +90,53 @@ const ENGINEERING_CONFIG: SolutionsPageConfig = {
{
title: 'GitHub and GitLab',
description: 'Sim agents act on issues, PRs, and releases across your repositories.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'CI/CD pipelines',
description:
'Sim triggers on builds and deploys, so agents react the moment something ships.',
- visual: null,
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
},
{
title: 'Observability',
description:
'Sim pulls from your logs and traces to give agents real production context.',
- visual: null,
+ visual: (
+
+ ),
},
],
},
diff --git a/apps/sim/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic.module.css b/apps/sim/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic.module.css
new file mode 100644
index 00000000000..b0e6451da99
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic.module.css
@@ -0,0 +1,69 @@
+/**
+ * The ReconcileGraphic's motion: the matched rows stamp in top to bottom
+ * one after another (the audit tile's one-shot settle — a match is
+ * confirmed once, never re-played), the exception card lands last, and
+ * its Flagged tag then carries the family's shared quiet 6s ring-pulse
+ * beat. Under prefers-reduced-motion everything renders settled and
+ * static.
+ */
+
+.row0,
+.row1,
+.row2,
+.exceptionIn {
+ animation: reconcile-stamp-in 0.9s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+.row0 {
+ animation-delay: 0.35s;
+}
+
+.row1 {
+ animation-delay: 0.6s;
+}
+
+.row2 {
+ animation-delay: 0.85s;
+}
+
+.exceptionIn {
+ animation-delay: 1.2s;
+}
+
+@keyframes reconcile-stamp-in {
+ from {
+ opacity: 0;
+ transform: translateY(-6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.flaggedPulse {
+ animation: reconcile-flagged-pulse 6s ease-out 1.6s infinite;
+}
+
+@keyframes reconcile-flagged-pulse {
+ 0%,
+ 20% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 30%, transparent);
+ }
+ 36% {
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .row0,
+ .row1,
+ .row2,
+ .exceptionIn,
+ .flaggedPulse {
+ animation: none;
+ }
+}
diff --git a/apps/sim/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic.tsx b/apps/sim/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic.tsx
new file mode 100644
index 00000000000..5461532f397
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic.tsx
@@ -0,0 +1,121 @@
+import { ChipTag, cn } from '@sim/emcn'
+import { CircleCheck } from '@sim/emcn/icons'
+import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics'
+import styles from '@/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic.module.css'
+
+interface MatchedRow {
+ /** Transaction label. */
+ label: string
+ /** Right-aligned amount. */
+ amount: string
+}
+
+/** The auto-matched transactions, quiet passing rows above the exception. */
+const MATCHED_ROWS: readonly MatchedRow[] = [
+ { label: 'Stripe payout', amount: '$8,214.20' },
+ { label: 'Payroll run', amount: '$42,900.00' },
+ { label: 'AWS invoice', amount: '$3,118.75' },
+] as const
+
+/** Per-index stamp-in classes — the stagger order is baked into each class's delay. */
+const ROW_STEP_CLASSES = [styles.row0, styles.row1, styles.row2] as const
+
+/**
+ * Account reconciliation told as a frameless match ledger (the audit and
+ * monitoring tiles' composition — no window chrome): a small
+ * "Reconciliation" header with a mono period tag (fill stepped up to
+ * `--surface-6` so the pill stays legible on the grey ground) sits
+ * directly on the tile above the matched transactions — quiet
+ * hairline-ruled rows, each a passing circle-check, the transaction
+ * label, and a right-aligned mono amount. The tile's highlight is the
+ * one exception: a white card in the audit tile's exact chrome
+ * (`--white` fill, 1px `--border-1` hairline, `rounded-xl`, `shadow-sm`)
+ * pairing the unmatched transaction and its routed-for-review line with
+ * a `Flagged` tag that carries the tile's one repeating motion, the
+ * family's shared quiet 6s ring pulse.
+ *
+ * Motion (from `reconcile-graphic.module.css`): the matched rows stamp
+ * in top to bottom one after another — a one-shot settle, the audit
+ * tile's append vocabulary, since matches are confirmed once — then the
+ * exception card lands last and its tag pulses. Everything is removed
+ * under `prefers-reduced-motion`.
+ *
+ * The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
+ * `max-lg`) but not left, so this centered vignette adds matching right
+ * padding to land on the tile's visible center instead of the bled
+ * slot's center. The column is fluid (`w-full max-w-[312px]`) so it
+ * never exceeds the compensated slot at narrow tile widths — labels
+ * truncate instead of clipping.
+ */
+export function ReconcileGraphic() {
+ return (
+
+
+
+
+
+ Reconciliation
+
+
+ March close
+
+
+
+ {MATCHED_ROWS.map((row, index) => (
+
0 && 'border-[var(--border-1)] border-t',
+ ROW_STEP_CLASSES[index]
+ )}
+ >
+
+
+ {row.label}
+
+
+ {row.amount}
+
+
+ ))}
+
+
+
+
+
+ Wire transfer
+
+
+ $12,400.00
+
+
+
+ No ledger match · routed for review
+
+
+
+ Flagged
+
+
+
+
+
+
+ Matched automatically
+
+ 132 of 133
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/solutions/finance/components/finance-hero-loop.tsx b/apps/sim/app/(landing)/solutions/finance/components/finance-hero-loop.tsx
new file mode 100644
index 00000000000..b9c1bd82b2f
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/finance/components/finance-hero-loop.tsx
@@ -0,0 +1,116 @@
+'use client'
+
+import { AgentIcon, ConditionalIcon, MailIcon, StartIcon, TableIcon } from '@/components/icons'
+import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop'
+import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
+
+/**
+ * Finance content for the shared platform loop - a month-end close agent: the
+ * prompt asks for Stripe-to-NetSuite reconciliation with controller review,
+ * the sidebar reads like the finance team's Brightwave workspace, and the
+ * staged workflow builds the reconciliation flow on the enterprise stage
+ * geometry. All tiles stay on the grey ramp, matching the enterprise flow.
+ */
+const FINANCE_LOOP_CONTENT: EnterpriseLoopContent = {
+ workspaceName: 'Brightwave Finance',
+ greeting: 'What should we get done, Elena?',
+ placeholder: 'Ask Sim to automate a process.',
+ prompt:
+ 'At month-end close, match transactions in Stripe against the NetSuite ledger, flag unreconciled items, and send the summary to the controller.',
+ reply:
+ "On it. I'll match every Stripe transaction to the NetSuite ledger, flag what doesn't reconcile, and send the close summary to the controller.",
+ sidebarChats: [
+ 'June close checklist',
+ 'Unreconciled Stripe payouts',
+ 'Vendor invoice exceptions',
+ 'Budget variance review',
+ ],
+ sidebarWorkflows: [
+ 'Month-end reconciliation',
+ 'Invoice exception routing',
+ 'Expense report audit',
+ 'Revenue recognition',
+ 'Weekly cash report',
+ ],
+ suggestedActions: [
+ 'Reconcile this week\u2019s Stripe payouts',
+ 'Review flagged vendor invoices',
+ 'Summarize budget variances',
+ 'Draft the weekly cash report',
+ ],
+ stageBlocks: [
+ {
+ id: 'start',
+ name: 'Start',
+ icon: StartIcon,
+ bgColor: 'var(--text-muted)',
+ isTrigger: true,
+ rows: [{ title: 'Inputs', value: '-' }],
+ x: 155,
+ y: 12,
+ },
+ {
+ id: 'match',
+ name: 'Match transactions',
+ icon: AgentIcon,
+ bgColor: 'var(--text-primary)',
+ rows: [
+ { title: 'Messages', value: '-' },
+ { title: 'Model', value: '-' },
+ ],
+ x: 155,
+ y: 172,
+ },
+ {
+ id: 'exceptions',
+ name: 'Flag exceptions',
+ icon: ConditionalIcon,
+ bgColor: 'var(--text-secondary)',
+ rows: [{ title: 'Conditions', value: '-' }],
+ x: 155,
+ y: 372,
+ },
+ {
+ id: 'review',
+ name: 'Controller review',
+ icon: MailIcon,
+ bgColor: 'var(--text-body)',
+ isTerminal: true,
+ rows: [
+ { title: 'To', value: '-' },
+ { title: 'Subject', value: '-' },
+ ],
+ x: 0,
+ y: 560,
+ },
+ {
+ id: 'ledger',
+ name: 'Post to ledger',
+ icon: TableIcon,
+ bgColor: 'var(--text-muted)',
+ isTerminal: true,
+ rows: [
+ { title: 'Table', value: '-' },
+ { title: 'Operation', value: '-' },
+ ],
+ x: 310,
+ y: 560,
+ },
+ ],
+ stageEdges: [
+ ['start', 'match'],
+ ['match', 'exceptions'],
+ ['exceptions', 'review'],
+ ['exceptions', 'ledger'],
+ ],
+ stageCanvas: { width: 560, height: 680 },
+}
+
+/**
+ * The finance hero's platform loop - the shared enterprise loop replayed with
+ * the month-end close story above. Client wrapper so the icon-bearing content
+ * object never crosses a server/client boundary.
+ */
+export function FinanceHeroLoop() {
+ return
+}
diff --git a/apps/sim/app/(landing)/solutions/finance/finance.tsx b/apps/sim/app/(landing)/solutions/finance/finance.tsx
index 5406198f784..79017fc9484 100644
--- a/apps/sim/app/(landing)/solutions/finance/finance.tsx
+++ b/apps/sim/app/(landing)/solutions/finance/finance.tsx
@@ -1,23 +1,44 @@
-import { SolutionsPage, type SolutionsPageConfig } from '@/app/(landing)/components'
+import {
+ PlatformHeroVisual,
+ SolutionsPage,
+ type SolutionsPageConfig,
+} from '@/app/(landing)/components'
+import {
+ AuditTrailGraphic,
+ OperationsTeamsGraphic,
+ RunMonitoringGraphic,
+ StagingGraphic,
+} from '@/app/(landing)/enterprise/components/feature-graphics'
+import { DocumentDraftGraphic } from '@/app/(landing)/solutions/components/feature-graphics'
+import { ReconcileGraphic } from '@/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic'
+import { FinanceHeroLoop } from '@/app/(landing)/solutions/finance/components/finance-hero-loop'
/**
- * Finance solution page - a reference consumer of {@link SolutionsPage}.
+ * Finance solution page - a consumer of {@link SolutionsPage} rendered with
+ * the enterprise page's feature-tile treatment.
*
- * The whole page is one typed {@link SolutionsPageConfig}; the shared
- * route-group layout provides the chrome. Visual slots are `null`, so each
- * renders the layout's reserved placeholder panel; a real page swaps in its own
- * client island without touching the layout.
+ * The whole page is one typed {@link SolutionsPageConfig} rendered inside
+ * the shared route-group layout's chrome. Every visual slot carries a
+ * feature graphic in the enterprise design language - enterprise graphics
+ * retold through their content props for finance's use cases (invoice
+ * routing, approval gates, spend monitoring, the finance audit ledger),
+ * plus one finance-specific vignette, the reconciliation match ledger.
*/
const FINANCE_CONFIG: SolutionsPageConfig = {
module: 'Finance',
path: '/solutions/finance',
hero: {
+ eyebrow: 'Finance',
heading: 'Automate invoice processing, reconciliation, and close with Sim agents.',
description:
'Finance teams build AI agents in Sim, the open-source AI workspace, with human approvals, anomaly detection, and full audit trails, across 1,000+ integrations and every major LLM.',
summary:
'Sim is the open-source AI workspace where finance teams build, deploy, and manage AI agents that automate reconciliation, invoice processing, and reporting, with human approvals, anomaly detection, and full audit trails across 1,000+ integrations.',
- visual: null,
+ visual: (
+
+
+
+ ),
},
rows: [
{
@@ -30,19 +51,33 @@ const FINANCE_CONFIG: SolutionsPageConfig = {
title: 'Reconcile accounts',
description:
'Sim matches transactions across systems and flags only the exceptions for review.',
- visual: null,
+ visual:
,
},
{
title: 'Process invoices',
description:
'Sim reads, codes, and routes invoices for approval without manual data entry.',
- visual: null,
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
},
{
title: 'Build reports',
description:
'Sim assembles recurring financial reports from your source systems on schedule.',
- visual: null,
+ visual: (
+
+ ),
},
],
},
@@ -55,17 +90,76 @@ const FINANCE_CONFIG: SolutionsPageConfig = {
{
title: 'Route approvals',
description: 'Sim sends the right items to the right approver and waits before it acts.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Detect anomalies',
description: 'Sim watches spend and flags unusual transactions before they post.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Keep audit trails',
description: 'Sim logs every run block by block, so finance can prove every control.',
- visual: null,
+ visual: (
+
+ ),
},
],
},
diff --git a/apps/sim/app/(landing)/solutions/hr/components/hr-hero-loop.tsx b/apps/sim/app/(landing)/solutions/hr/components/hr-hero-loop.tsx
new file mode 100644
index 00000000000..f880a0f77b0
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/hr/components/hr-hero-loop.tsx
@@ -0,0 +1,118 @@
+'use client'
+
+import { AgentIcon, ConditionalIcon, OktaIcon, SlackIcon, StartIcon } from '@/components/icons'
+import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop'
+import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
+
+/**
+ * HR content for the shared platform loop - a new-hire onboarding agent: the
+ * prompt asks for account provisioning, orientation scheduling, and the
+ * day-one welcome, the sidebar reads like the people team's Brightwave
+ * workspace, and the staged workflow builds the onboarding flow on the
+ * enterprise stage geometry. Brand tiles follow the stage convention - Slack's
+ * plum, Okta's monochrome mark on a hairlined white tile.
+ */
+const HR_LOOP_CONTENT: EnterpriseLoopContent = {
+ workspaceName: 'Brightwave HR',
+ greeting: 'What should we get done, Sam?',
+ placeholder: 'Ask Sim to automate a process.',
+ prompt:
+ 'When a new hire signs their offer, create their accounts in Okta and Slack, schedule orientation, and send the day-one welcome packet.',
+ reply:
+ "On it. I'll provision Okta and Slack accounts, schedule orientation with the team, and send the welcome packet on day one.",
+ sidebarChats: [
+ 'July onboarding cohort',
+ 'Benefits enrollment Qs',
+ 'PTO policy update',
+ 'Manager training invites',
+ ],
+ sidebarWorkflows: [
+ 'New hire onboarding',
+ 'Employee questions bot',
+ 'PTO request routing',
+ 'Benefits enrollment',
+ 'Offboarding checklist',
+ ],
+ suggestedActions: [
+ 'Onboard Monday\u2019s new hires',
+ 'Answer open benefits questions',
+ 'Review pending PTO requests',
+ 'Draft the new-team announcement',
+ ],
+ stageBlocks: [
+ {
+ id: 'start',
+ name: 'Start',
+ icon: StartIcon,
+ bgColor: 'var(--text-muted)',
+ isTrigger: true,
+ rows: [{ title: 'Inputs', value: '-' }],
+ x: 155,
+ y: 12,
+ },
+ {
+ id: 'onboard',
+ name: 'Onboard hire',
+ icon: AgentIcon,
+ bgColor: 'var(--text-primary)',
+ rows: [
+ { title: 'Messages', value: '-' },
+ { title: 'Model', value: '-' },
+ ],
+ x: 155,
+ y: 172,
+ },
+ {
+ id: 'tasks',
+ name: 'Route setup tasks',
+ icon: ConditionalIcon,
+ bgColor: 'var(--text-secondary)',
+ rows: [{ title: 'Conditions', value: '-' }],
+ x: 155,
+ y: 372,
+ },
+ {
+ id: 'accounts',
+ name: 'Create accounts',
+ icon: OktaIcon,
+ bgColor: '#FFFFFF',
+ tileBorder: true,
+ isTerminal: true,
+ rows: [
+ { title: 'Apps', value: '-' },
+ { title: 'Action', value: '-' },
+ ],
+ x: 0,
+ y: 560,
+ },
+ {
+ id: 'welcome',
+ name: 'Welcome message',
+ icon: SlackIcon,
+ bgColor: '#611F69',
+ isTerminal: true,
+ rows: [
+ { title: 'Channel', value: '-' },
+ { title: 'Message', value: '-' },
+ ],
+ x: 310,
+ y: 560,
+ },
+ ],
+ stageEdges: [
+ ['start', 'onboard'],
+ ['onboard', 'tasks'],
+ ['tasks', 'accounts'],
+ ['tasks', 'welcome'],
+ ],
+ stageCanvas: { width: 560, height: 680 },
+}
+
+/**
+ * The HR hero's platform loop - the shared enterprise loop replayed with the
+ * onboarding story above. Client wrapper so the icon-bearing content object
+ * never crosses a server/client boundary.
+ */
+export function HrHeroLoop() {
+ return
+}
diff --git a/apps/sim/app/(landing)/solutions/hr/hr.tsx b/apps/sim/app/(landing)/solutions/hr/hr.tsx
index 5cb8576445e..e186162b636 100644
--- a/apps/sim/app/(landing)/solutions/hr/hr.tsx
+++ b/apps/sim/app/(landing)/solutions/hr/hr.tsx
@@ -1,23 +1,46 @@
-import { SolutionsPage, type SolutionsPageConfig } from '@/app/(landing)/components'
+import {
+ PlatformHeroVisual,
+ SolutionsPage,
+ type SolutionsPageConfig,
+} from '@/app/(landing)/components'
+import {
+ ItPlatformTeamsGraphic,
+ OperationsTeamsGraphic,
+ RunMonitoringGraphic,
+ StagingGraphic,
+} from '@/app/(landing)/enterprise/components/feature-graphics'
+import {
+ DocumentDraftGraphic,
+ KnowledgeAnswerGraphic,
+} from '@/app/(landing)/solutions/components/feature-graphics'
+import { HrHeroLoop } from '@/app/(landing)/solutions/hr/components/hr-hero-loop'
/**
- * HR solution page - a reference consumer of {@link SolutionsPage}.
+ * HR solution page - a consumer of {@link SolutionsPage} rendered with the
+ * enterprise page's feature-tile treatment.
*
- * The whole page is one typed {@link SolutionsPageConfig} rendered inside the
- * shared route-group layout. Visual slots are `null`, so each renders the
- * layout's reserved placeholder panel; a real page swaps in its own client
- * island without touching the layout.
+ * The whole page is one typed {@link SolutionsPageConfig} rendered inside
+ * the shared route-group layout's chrome. Every visual slot carries a
+ * feature graphic in the enterprise design language, retold through the
+ * graphics' content props for HR's use cases - onboarding fan-out across
+ * systems, a benefits answer grounded in the team's docs, offer-letter
+ * drafting, PTO approvals, surveys, and people reports.
*/
const HR_CONFIG: SolutionsPageConfig = {
module: 'HR',
path: '/solutions/hr',
hero: {
+ eyebrow: 'HR',
heading: 'Automate onboarding, employee questions, and approvals with Sim agents.',
description:
'HR teams build AI agents in Sim, the open-source AI workspace, wired into your HRIS and 1,000+ integrations, so the team spends time on people, not paperwork.',
summary:
'Sim is the open-source AI workspace where HR teams build, deploy, and manage AI agents that automate onboarding, employee questions, and approvals, connecting your HRIS and 1,000+ integrations so the team focuses on people, not paperwork.',
- visual: null,
+ visual: (
+
+
+
+ ),
},
rows: [
{
@@ -30,19 +53,39 @@ const HR_CONFIG: SolutionsPageConfig = {
title: 'Onboard new hires',
description:
'Sim runs the onboarding checklist across every system so day one just works.',
- visual: null,
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
},
{
title: 'Answer HR questions',
description:
'Sim deploys an agent that answers policy and benefits questions from your docs.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Generate documents',
description:
'Sim drafts offer letters and policy docs from your templates automatically.',
- visual: null,
+ visual: (
+
+ ),
},
],
},
@@ -56,17 +99,56 @@ const HR_CONFIG: SolutionsPageConfig = {
title: 'Route approvals',
description:
'Sim sends PTO and expense requests to the right manager and tracks the response.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Run surveys',
description: 'Sim collects and summarizes engagement feedback so trends surface early.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Build reports',
description: 'Sim assembles headcount and people reports from your HRIS on schedule.',
- visual: null,
+ visual: (
+
+ ),
},
],
},
diff --git a/apps/sim/app/(landing)/solutions/it/components/it-hero-loop.tsx b/apps/sim/app/(landing)/solutions/it/components/it-hero-loop.tsx
new file mode 100644
index 00000000000..f79f8870a4e
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/it/components/it-hero-loop.tsx
@@ -0,0 +1,118 @@
+'use client'
+
+import { AgentIcon, ConditionalIcon, MailIcon, OktaIcon, StartIcon } from '@/components/icons'
+import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop'
+import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
+
+/**
+ * IT content for the shared platform loop - an access-request agent: the
+ * prompt asks for Okta-verified provisioning with an audit note, the sidebar
+ * reads like the IT team's Brightwave workspace, and the staged workflow
+ * builds the access flow on the enterprise stage geometry. The Okta mark is
+ * monochrome (`currentColor`), so its tile follows the Jira convention - the
+ * real mark on a hairlined white tile.
+ */
+const IT_LOOP_CONTENT: EnterpriseLoopContent = {
+ workspaceName: 'Brightwave IT',
+ greeting: 'What should we get done, Priya?',
+ placeholder: 'Ask Sim to automate a process.',
+ prompt:
+ 'When an access request comes in, check the requester\u2019s role in Okta, provision the right groups, and close the ticket with an audit note.',
+ reply:
+ "On it. I'll verify each request against Okta roles, provision the groups automatically, and close the ticket with a full audit note.",
+ sidebarChats: [
+ 'Okta group cleanup',
+ 'Laptop refresh queue',
+ 'VPN access requests',
+ 'Zendesk backlog triage',
+ ],
+ sidebarWorkflows: [
+ 'Access provisioning',
+ 'Ticket triage',
+ 'Employee offboarding',
+ 'License audit',
+ 'Device compliance check',
+ ],
+ suggestedActions: [
+ 'Triage new IT tickets in Zendesk',
+ 'Review pending access requests',
+ 'Audit unused software licenses',
+ 'Draft the weekly IT ops summary',
+ ],
+ stageBlocks: [
+ {
+ id: 'start',
+ name: 'Start',
+ icon: StartIcon,
+ bgColor: 'var(--text-muted)',
+ isTrigger: true,
+ rows: [{ title: 'Inputs', value: '-' }],
+ x: 155,
+ y: 12,
+ },
+ {
+ id: 'verify',
+ name: 'Verify request',
+ icon: AgentIcon,
+ bgColor: 'var(--text-primary)',
+ rows: [
+ { title: 'Messages', value: '-' },
+ { title: 'Model', value: '-' },
+ ],
+ x: 155,
+ y: 172,
+ },
+ {
+ id: 'policy',
+ name: 'Route by policy',
+ icon: ConditionalIcon,
+ bgColor: 'var(--text-secondary)',
+ rows: [{ title: 'Conditions', value: '-' }],
+ x: 155,
+ y: 372,
+ },
+ {
+ id: 'okta',
+ name: 'Provision in Okta',
+ icon: OktaIcon,
+ bgColor: '#FFFFFF',
+ tileBorder: true,
+ isTerminal: true,
+ rows: [
+ { title: 'Groups', value: '-' },
+ { title: 'Action', value: '-' },
+ ],
+ x: 0,
+ y: 560,
+ },
+ {
+ id: 'notify',
+ name: 'Notify requester',
+ icon: MailIcon,
+ bgColor: 'var(--text-body)',
+ isTerminal: true,
+ rows: [
+ { title: 'To', value: '-' },
+ { title: 'Subject', value: '-' },
+ ],
+ x: 310,
+ y: 560,
+ },
+ ],
+ stageEdges: [
+ ['start', 'verify'],
+ ['verify', 'policy'],
+ ['policy', 'okta'],
+ ['policy', 'notify'],
+ ],
+ stageCanvas: { width: 560, height: 680 },
+}
+
+/**
+ * The IT hero's platform loop - the shared enterprise loop replayed with the
+ * access-provisioning story above. Client wrapper so the icon-bearing content
+ * object never crosses a server/client boundary.
+ */
+export function ItHeroLoop() {
+ return
+}
diff --git a/apps/sim/app/(landing)/solutions/it/it.tsx b/apps/sim/app/(landing)/solutions/it/it.tsx
index 93c7b3df4fc..8361e12edd2 100644
--- a/apps/sim/app/(landing)/solutions/it/it.tsx
+++ b/apps/sim/app/(landing)/solutions/it/it.tsx
@@ -1,23 +1,45 @@
-import { SolutionsPage, type SolutionsPageConfig } from '@/app/(landing)/components'
+import {
+ PlatformHeroVisual,
+ SolutionsPage,
+ type SolutionsPageConfig,
+} from '@/app/(landing)/components'
+import {
+ AccessControlGraphic,
+ AuditTrailGraphic,
+ ItPlatformTeamsGraphic,
+ OperationsTeamsGraphic,
+ RunMonitoringGraphic,
+} from '@/app/(landing)/enterprise/components/feature-graphics'
+import { KnowledgeAnswerGraphic } from '@/app/(landing)/solutions/components/feature-graphics'
+import { ItHeroLoop } from '@/app/(landing)/solutions/it/components/it-hero-loop'
/**
- * IT solution page - a reference consumer of {@link SolutionsPage}.
+ * IT solution page - a consumer of {@link SolutionsPage} rendered with the
+ * enterprise page's feature-tile treatment.
*
- * The whole page is one typed {@link SolutionsPageConfig} rendered inside the
- * shared route-group layout's chrome. Visual slots are `null`, so each renders
- * the layout's reserved placeholder panel; a real page swaps in its own client
- * island without touching the layout.
+ * The whole page is one typed {@link SolutionsPageConfig} rendered inside
+ * the shared route-group layout's chrome. Every visual slot carries an
+ * enterprise feature graphic - reused directly where its story fits the
+ * card (the access-control role graph, the audit ledger) or retold through
+ * the graphics' content props for IT's use cases (ticket routing, incident
+ * runbooks, infrastructure monitors) - so the page shares the enterprise
+ * design language without any new visual vocabulary.
*/
const IT_CONFIG: SolutionsPageConfig = {
module: 'IT',
path: '/solutions/it',
hero: {
+ eyebrow: 'IT',
heading: 'Automate ticket triage, access, and monitoring with Sim agents.',
description:
'IT teams build AI agents in Sim, the open-source AI workspace, with the governance, access controls, and audit trails IT needs, across 1,000+ integrations and every major LLM.',
summary:
'Sim is the open-source AI workspace where IT teams build, deploy, and manage AI agents that automate ticket triage, access provisioning, and infrastructure monitoring, connecting 1,000+ integrations and every major LLM under IT-grade governance.',
- visual: null,
+ visual: (
+
+
+
+ ),
},
rows: [
{
@@ -30,18 +52,25 @@ const IT_CONFIG: SolutionsPageConfig = {
title: 'Triage tickets',
description:
'Sim routes, tags, and resolves incoming tickets across your help desk automatically.',
- visual: null,
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
},
{
title: 'Provision access',
description:
'Sim grants and revokes access by policy, so requests are handled in seconds, not days.',
- visual: null,
+ visual:
,
},
{
title: 'Answer common questions',
description: 'Sim deploys an internal help-desk agent that answers from your own docs.',
- visual: null,
+ visual:
,
},
],
},
@@ -55,19 +84,45 @@ const IT_CONFIG: SolutionsPageConfig = {
title: 'Monitor infrastructure',
description:
'Sim agents watch logs and metrics and flag anomalies the moment they appear.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Respond to incidents',
description:
'Sim runs your runbooks automatically and escalates to the right on-call engineer.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Audit every action',
description:
'Sim logs every agent run block by block, so IT can prove exactly what happened.',
- visual: null,
+ visual:
,
},
],
},
diff --git a/apps/sim/app/(landing)/solutions/sales/components/sales-hero-loop.tsx b/apps/sim/app/(landing)/solutions/sales/components/sales-hero-loop.tsx
new file mode 100644
index 00000000000..27355ac0b4a
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/sales/components/sales-hero-loop.tsx
@@ -0,0 +1,125 @@
+'use client'
+
+import {
+ AgentIcon,
+ ConditionalIcon,
+ SalesforceIcon,
+ SlackIcon,
+ StartIcon,
+} from '@/components/icons'
+import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop'
+import type { EnterpriseLoopContent } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
+
+/**
+ * Sales content for the shared platform loop - an inbound-lead agent: the
+ * prompt asks for lead research, fit scoring, and Salesforce record creation
+ * with a Slack alert to the owner, the sidebar reads like the sales team's
+ * Brightwave workspace, and the staged workflow builds the routing flow on
+ * the enterprise stage geometry. The Salesforce mark is a colored brand
+ * mark, so its tile follows the Jira convention - the real mark on a
+ * hairlined white tile.
+ */
+const SALES_LOOP_CONTENT: EnterpriseLoopContent = {
+ workspaceName: 'Brightwave Sales',
+ greeting: 'What should we get done, Marcus?',
+ placeholder: 'Ask Sim to automate a process.',
+ prompt:
+ 'When a new lead comes in, research the company, score the fit, create the record in Salesforce, and alert the account owner in Slack.',
+ reply:
+ "On it. I'll research every new lead, score the fit against your best customers, create the Salesforce record, and alert the owner in Slack.",
+ sidebarChats: [
+ 'Inbound lead backlog',
+ 'Acme Co renewal prep',
+ 'Q3 forecast review',
+ 'Stale opportunity cleanup',
+ ],
+ sidebarWorkflows: [
+ 'Lead research',
+ 'Inbound routing',
+ 'CRM hygiene',
+ 'Meeting prep briefs',
+ 'Weekly pipeline digest',
+ ],
+ suggestedActions: [
+ 'Research this week\u2019s inbound leads',
+ 'Update stale opportunities',
+ 'Draft follow-ups from yesterday\u2019s calls',
+ 'Summarize the Q3 pipeline',
+ ],
+ stageBlocks: [
+ {
+ id: 'start',
+ name: 'Start',
+ icon: StartIcon,
+ bgColor: 'var(--text-muted)',
+ isTrigger: true,
+ rows: [{ title: 'Inputs', value: '-' }],
+ x: 155,
+ y: 12,
+ },
+ {
+ id: 'research',
+ name: 'Research lead',
+ icon: AgentIcon,
+ bgColor: 'var(--text-primary)',
+ rows: [
+ { title: 'Messages', value: '-' },
+ { title: 'Model', value: '-' },
+ ],
+ x: 155,
+ y: 172,
+ },
+ {
+ id: 'score',
+ name: 'Score the fit',
+ icon: ConditionalIcon,
+ bgColor: 'var(--text-secondary)',
+ rows: [{ title: 'Conditions', value: '-' }],
+ x: 155,
+ y: 372,
+ },
+ {
+ id: 'salesforce',
+ name: 'Create in Salesforce',
+ icon: SalesforceIcon,
+ bgColor: '#FFFFFF',
+ tileBorder: true,
+ isTerminal: true,
+ rows: [
+ { title: 'Object', value: '-' },
+ { title: 'Action', value: '-' },
+ ],
+ x: 0,
+ y: 560,
+ },
+ {
+ id: 'notify',
+ name: 'Alert owner',
+ icon: SlackIcon,
+ bgColor: '#611F69',
+ isTerminal: true,
+ rows: [
+ { title: 'Channel', value: '-' },
+ { title: 'Message', value: '-' },
+ ],
+ x: 310,
+ y: 560,
+ },
+ ],
+ stageEdges: [
+ ['start', 'research'],
+ ['research', 'score'],
+ ['score', 'salesforce'],
+ ['score', 'notify'],
+ ],
+ stageCanvas: { width: 560, height: 680 },
+}
+
+/**
+ * The sales hero's platform loop - the shared enterprise loop replayed with
+ * the inbound-lead story above. Client wrapper so the icon-bearing content
+ * object never crosses a server/client boundary.
+ */
+export function SalesHeroLoop() {
+ return
+}
diff --git a/apps/sim/app/(landing)/solutions/sales/page.tsx b/apps/sim/app/(landing)/solutions/sales/page.tsx
new file mode 100644
index 00000000000..15b36cec945
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/sales/page.tsx
@@ -0,0 +1,21 @@
+import { buildLandingMetadata } from '@/lib/landing/seo'
+import SalesSolution from '@/app/(landing)/solutions/sales/sales'
+
+export const revalidate = 3600
+
+const TITLE = 'AI Agents for Lead Research & CRM Updates | Sim'
+const DESCRIPTION =
+ 'Sales teams use Sim, the open-source AI workspace, to build and deploy AI agents that automate lead research, personalized outreach, and CRM updates.'
+
+export const metadata = buildLandingMetadata({
+ title: TITLE,
+ description: DESCRIPTION,
+ path: '/solutions/sales',
+ keywords:
+ 'AI workspace, AI agents for sales, sales automation, lead research, CRM automation, pipeline reporting, open-source AI agent platform',
+ twitterImageAlt: 'Sim',
+})
+
+export default function Page() {
+ return
+}
diff --git a/apps/sim/app/(landing)/solutions/sales/sales.tsx b/apps/sim/app/(landing)/solutions/sales/sales.tsx
new file mode 100644
index 00000000000..5fb08b5d3c8
--- /dev/null
+++ b/apps/sim/app/(landing)/solutions/sales/sales.tsx
@@ -0,0 +1,159 @@
+import {
+ PlatformHeroVisual,
+ SolutionsPage,
+ type SolutionsPageConfig,
+} from '@/app/(landing)/components'
+import {
+ ItPlatformTeamsGraphic,
+ OperationsTeamsGraphic,
+ RunMonitoringGraphic,
+ StagingGraphic,
+} from '@/app/(landing)/enterprise/components/feature-graphics'
+import {
+ DocumentDraftGraphic,
+ KnowledgeAnswerGraphic,
+} from '@/app/(landing)/solutions/components/feature-graphics'
+import { SalesHeroLoop } from '@/app/(landing)/solutions/sales/components/sales-hero-loop'
+
+/**
+ * Sales solution page - a consumer of {@link SolutionsPage} rendered with
+ * the enterprise page's feature-tile treatment.
+ *
+ * The whole page is one typed {@link SolutionsPageConfig} rendered inside
+ * the shared route-group layout's chrome. Every visual slot carries a
+ * feature graphic in the enterprise design language, retold through the
+ * graphics' content props for sales' use cases - inbound lead routing
+ * across systems, a research brief grounded in real account context,
+ * outreach drafting, CRM stage updates, meeting prep, and the pipeline
+ * digest.
+ */
+const SALES_CONFIG: SolutionsPageConfig = {
+ module: 'Sales',
+ path: '/solutions/sales',
+ hero: {
+ eyebrow: 'Sales',
+ heading: 'Automate lead research, outreach, and CRM updates with Sim agents.',
+ description:
+ 'Sales teams build AI agents in Sim, the open-source AI workspace, wired into Salesforce, HubSpot, and 1,000+ integrations, so reps spend their time selling, not updating records.',
+ summary:
+ 'Sim is the open-source AI workspace where sales teams build, deploy, and manage AI agents that automate lead research, personalized outreach, and CRM updates, wired into Salesforce, HubSpot, and 1,000+ integrations so the pipeline stays current and reps spend their time selling.',
+ visual: (
+
+
+
+ ),
+ },
+ rows: [
+ {
+ id: 'pipeline',
+ title: 'Fill the pipeline.',
+ subtitle: 'Sim agents handle research, outreach, and follow-up around the clock.',
+ cta: { label: 'See sales agents', href: '/signup' },
+ cards: [
+ {
+ title: 'Route inbound leads',
+ description: 'Sim scores, routes, and follows up on new leads the moment they arrive.',
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Research every lead',
+ description:
+ 'Sim researches the company, the news, and the stack, and writes the brief before you call.',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Draft outreach',
+ description:
+ 'Sim drafts personalized outbound from the research, in your voice, ready to send.',
+ visual: (
+
+ ),
+ },
+ ],
+ },
+ {
+ id: 'crm',
+ title: 'Keep the CRM true.',
+ subtitle: 'Sim keeps records current so the forecast is built on real data.',
+ cta: { label: 'Explore CRM automation', href: '/signup' },
+ cards: [
+ {
+ title: 'Update the CRM',
+ description: 'Sim logs calls, sets next steps, and moves stages after every touchpoint.',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Prep every meeting',
+ description: 'Sim assembles a brief before every call from your CRM, email, and notes.',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Report the pipeline',
+ description: 'Sim assembles pipeline and forecast digests from your CRM on schedule.',
+ visual: (
+
+ ),
+ },
+ ],
+ },
+ ],
+}
+
+export default function SalesSolution() {
+ return
+}
diff --git a/apps/sim/app/(landing)/tables/components/feature-graphics/enrichment-fill-graphic.module.css b/apps/sim/app/(landing)/tables/components/feature-graphics/enrichment-fill-graphic.module.css
new file mode 100644
index 00000000000..c2359231850
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/components/feature-graphics/enrichment-fill-graphic.module.css
@@ -0,0 +1,49 @@
+/**
+ * The EnrichmentFillGraphic's motion: each found value fades up into its
+ * empty cell one after another - the audit tile's one-shot settle, never
+ * re-played - so the ledger reads as an enrichment pass completing the
+ * row. Under prefers-reduced-motion the values render fully settled.
+ */
+
+.value0,
+.value1,
+.value2,
+.value3 {
+ animation: enrichment-value-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+.value0 {
+ animation-delay: 0.45s;
+}
+
+.value1 {
+ animation-delay: 0.95s;
+}
+
+.value2 {
+ animation-delay: 1.45s;
+}
+
+.value3 {
+ animation-delay: 1.95s;
+}
+
+@keyframes enrichment-value-in {
+ from {
+ opacity: 0;
+ transform: translateY(3px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .value0,
+ .value1,
+ .value2,
+ .value3 {
+ animation: none;
+ }
+}
diff --git a/apps/sim/app/(landing)/tables/components/feature-graphics/enrichment-fill-graphic.tsx b/apps/sim/app/(landing)/tables/components/feature-graphics/enrichment-fill-graphic.tsx
new file mode 100644
index 00000000000..b88ff80deb3
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/components/feature-graphics/enrichment-fill-graphic.tsx
@@ -0,0 +1,97 @@
+import { ChipTag, cn } from '@sim/emcn'
+import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics'
+import styles from '@/app/(landing)/tables/components/feature-graphics/enrichment-fill-graphic.module.css'
+
+interface EnrichmentRowDef {
+ /** The enrichment's column label, matching Sim's built-in enrichments. */
+ label: string
+ /** The found value the pass writes into the cell. */
+ value: string
+ /** Value treatment: `mono` for machine-shaped values, `strong` for names. */
+ variant: 'mono' | 'strong'
+}
+
+/**
+ * One lead's empty cells being completed, drawn from Sim's real enrichment
+ * catalog (work email, phone number, company domain, company info) so the
+ * ledger reads as the product's own enrichment pass.
+ */
+const ENRICHMENT_ROWS: readonly EnrichmentRowDef[] = [
+ { label: 'Company domain', value: 'brightside.io', variant: 'mono' },
+ { label: 'Work email', value: 'jonas@brightside.io', variant: 'mono' },
+ { label: 'Phone number', value: '+1 (303) 555-0148', variant: 'mono' },
+ { label: 'Company info', value: 'SaaS · 120 employees', variant: 'strong' },
+] as const
+
+/** Per-value fill classes - the stagger order is baked into each class's delay. */
+const VALUE_STEP_CLASSES = [styles.value0, styles.value1, styles.value2, styles.value3] as const
+
+/**
+ * Enrichments told as a frameless key-value ledger (the run-monitoring
+ * tile's composition, which shares the page): a small "Enrichments" header
+ * with a quiet `Auto-run` mono ChipTag (fill stepped up to `--surface-6`
+ * so the pill stays legible on the grey ground) names the surface, and a
+ * "leads · Jonas Weber" attribution line pins the pass to one record.
+ * Below, each of the four rows pairs an enrichment column label with the
+ * value the pass found - the labels are Sim's real enrichment catalog
+ * (company domain, work email, phone number, company info) - as airy rows
+ * ruled by quiet 1px `--border-1` hairlines, values in mono or the
+ * stronger sans ink.
+ *
+ * The found values fade up into their cells one after another (from
+ * `enrichment-fill-graphic.module.css`, the audit tile's one-shot settle)
+ * so the ledger reads as the pass completing the row - never re-played.
+ * Under `prefers-reduced-motion` the values render fully settled.
+ *
+ * The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
+ * `max-lg`) but not left, so this centered vignette adds matching right
+ * padding to land on the tile's visible center instead of the bled slot's
+ * center.
+ */
+export function EnrichmentFillGraphic() {
+ return (
+
+
+
+
+
+ Enrichments
+
+
+ Auto-run
+
+
+
leads · Jonas Weber
+
+
+ {ENRICHMENT_ROWS.map((row, index) => (
+
0 && 'border-[var(--border-1)] border-t'
+ )}
+ >
+ {row.label}
+
+ {row.value}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/tables/components/feature-graphics/index.ts b/apps/sim/app/(landing)/tables/components/feature-graphics/index.ts
new file mode 100644
index 00000000000..7ee1bc1f48d
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/components/feature-graphics/index.ts
@@ -0,0 +1,3 @@
+export { EnrichmentFillGraphic } from './enrichment-fill-graphic'
+export { TableGridGraphic } from './table-grid-graphic'
+export { TableQueryGraphic } from './table-query-graphic'
diff --git a/apps/sim/app/(landing)/tables/components/feature-graphics/table-grid-graphic.module.css b/apps/sim/app/(landing)/tables/components/feature-graphics/table-grid-graphic.module.css
new file mode 100644
index 00000000000..47aac7e99b4
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/components/feature-graphics/table-grid-graphic.module.css
@@ -0,0 +1,55 @@
+/**
+ * The TableGridGraphic's motion: the record rows stamp in top to bottom
+ * one after another - the audit tile's one-shot settle, never re-played -
+ * so the grid reads as an agent writing records in. Under
+ * prefers-reduced-motion the rows render fully settled.
+ */
+
+.row0,
+.row1,
+.row2,
+.row3,
+.row4 {
+ animation: table-grid-row-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+.row0 {
+ animation-delay: 0.35s;
+}
+
+.row1 {
+ animation-delay: 0.55s;
+}
+
+.row2 {
+ animation-delay: 0.75s;
+}
+
+.row3 {
+ animation-delay: 0.95s;
+}
+
+.row4 {
+ animation-delay: 1.15s;
+}
+
+@keyframes table-grid-row-in {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .row0,
+ .row1,
+ .row2,
+ .row3,
+ .row4 {
+ animation: none;
+ }
+}
diff --git a/apps/sim/app/(landing)/tables/components/feature-graphics/table-grid-graphic.tsx b/apps/sim/app/(landing)/tables/components/feature-graphics/table-grid-graphic.tsx
new file mode 100644
index 00000000000..4554994db29
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/components/feature-graphics/table-grid-graphic.tsx
@@ -0,0 +1,119 @@
+import { cn } from '@sim/emcn'
+import { Table, TypeBoolean, TypeText } from '@sim/emcn/icons'
+import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics'
+import styles from '@/app/(landing)/tables/components/feature-graphics/table-grid-graphic.module.css'
+
+interface GridColumnDef {
+ /** Column header label. */
+ label: string
+ /** Header type icon - text or boolean, per the real editor's headers. */
+ type: 'text' | 'boolean'
+ /** Tailwind width class in the window's fixed geometry. */
+ widthClass: string
+}
+
+/**
+ * The Leads grid's cropped schema - the identity column plus two of the
+ * fields agents keep current, with the boolean column bleeding off the
+ * window's cropped right edge.
+ */
+const COLUMNS: readonly GridColumnDef[] = [
+ { label: 'Name', type: 'text', widthClass: 'w-[128px]' },
+ { label: 'Company', type: 'text', widthClass: 'w-[118px]' },
+ { label: 'Qualified', type: 'boolean', widthClass: 'flex-1' },
+] as const
+
+interface GridRowDef {
+ /** Cell values, left to right; the boolean renders as a check mark or dash. */
+ cells: readonly [string, string, boolean]
+}
+
+/** The records the vignette stamps in, newest work landing last. */
+const ROWS: readonly GridRowDef[] = [
+ { cells: ['Alice Johnson', 'Acme Corp', true] },
+ { cells: ['Bob Williams', 'TechCo', false] },
+ { cells: ['Carol Davis', 'StartupCo', true] },
+ { cells: ['Dan Miller', 'BigCorp', true] },
+ { cells: ['Eva Chen', 'Design IO', false] },
+] as const
+
+/** Per-row stamp-in classes - the stagger order is baked into each class's delay. */
+const ROW_STEP_CLASSES = [styles.row0, styles.row1, styles.row2, styles.row3, styles.row4] as const
+
+/**
+ * The Tables grid told inside a cropped product window: the window keeps
+ * the dark tiles' slot geometry (`top-5`, `left-0`, bleeding off the right
+ * and bottom edges, `rounded-tl-xl`) but wears the light tiles' card
+ * chrome - `--white` fill, 1px `--border-1` hairline, `shadow-sm` - so the
+ * grid reads as the workspace's own editor. Its `h-12` title bar pairs the
+ * `Table` icon (in a hairline `size-6` icon box, the lifecycle header's
+ * treatment) with the `Leads` table name over a hairline rule, and the
+ * grid below is the landing Tables preview's exact cell vocabulary: typed
+ * column headers (`TypeText`/`TypeBoolean` icons), hairline-ruled cells,
+ * the boolean column rendered as quiet check marks and dashes.
+ *
+ * The record rows stamp in top to bottom once (from
+ * `table-grid-graphic.module.css`, the audit tile's one-shot settle) - an
+ * agent writing records, never re-played. Under `prefers-reduced-motion`
+ * the grid renders fully settled.
+ */
+export function TableGridGraphic() {
+ return (
+
+
+
+
+
+ {COLUMNS.map((column) => {
+ const Icon = column.type === 'boolean' ? TypeBoolean : TypeText
+ return (
+
+
+
+ {column.label}
+
+
+ )
+ })}
+
+
+ {ROWS.map((row, index) => (
+
+ {COLUMNS.map((column, columnIndex) => {
+ const value = row.cells[columnIndex]
+ return (
+
+ {typeof value === 'boolean' ? (value ? '✓' : '—') : value}
+
+ )
+ })}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/tables/components/feature-graphics/table-query-graphic.tsx b/apps/sim/app/(landing)/tables/components/feature-graphics/table-query-graphic.tsx
new file mode 100644
index 00000000000..1306f100204
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/components/feature-graphics/table-query-graphic.tsx
@@ -0,0 +1,62 @@
+import { Table } from '@sim/emcn/icons'
+import {
+ type CodeSegment,
+ CodeWindowGraphic,
+} from '@/app/(landing)/components/shared/code-window-graphic'
+
+/**
+ * A `query-leads.ts` excerpt - agent logic reading rows out of the Leads
+ * table and writing a result back, the table reached from code.
+ */
+const CODE_LINES: readonly CodeSegment[][] = [
+ [
+ { text: 'import', tone: 'muted' },
+ { text: ' ' },
+ { text: '{ tables }', tone: 'primary' },
+ { text: ' ' },
+ { text: 'from', tone: 'muted' },
+ { text: ' ' },
+ { text: "'@sim/sdk'", tone: 'primary' },
+ ],
+ [
+ { text: 'const', tone: 'muted' },
+ { text: ' ' },
+ { text: 'leads', tone: 'primary' },
+ { text: ' ' },
+ { text: '= await', tone: 'muted' },
+ { text: ' ' },
+ { text: 'tables', tone: 'primary' },
+ ],
+ [{ text: " .query('leads'", tone: 'primary' }, { text: ', {' }],
+ [
+ { text: ' ' },
+ { text: 'where:', tone: 'muted' },
+ { text: ' ' },
+ { text: '{ qualified: true }', tone: 'primary' },
+ { text: ',' },
+ ],
+ [
+ { text: ' ' },
+ { text: 'orderBy:', tone: 'muted' },
+ { text: ' ' },
+ { text: "'created_at'", tone: 'primary' },
+ { text: ',' },
+ ],
+ [{ text: ' })' }],
+] as const
+
+/**
+ * Querying tables from agent logic told in the shared
+ * {@link CodeWindowGraphic} editor window: the `Table` mark and the
+ * `query-leads.ts` filename over an SDK excerpt reading qualified leads
+ * out of the Leads table.
+ */
+export function TableQueryGraphic() {
+ return (
+
}
+ filename='query-leads.ts'
+ lines={CODE_LINES}
+ />
+ )
+}
diff --git a/apps/sim/app/(landing)/tables/components/tables-hero-loop.module.css b/apps/sim/app/(landing)/tables/components/tables-hero-loop.module.css
new file mode 100644
index 00000000000..7693aa46049
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/components/tables-hero-loop.module.css
@@ -0,0 +1,45 @@
+/**
+ * The TablesHeroLoop's motion: an appended record row stamps in once from
+ * above when the parent clock mounts it, and an enriched cell's value fades
+ * up once when the enrichment sweep reaches it. Both are one-shot mount
+ * animations - the loop's restart unmounts and remounts the elements, so a
+ * new cycle replays them naturally. Under prefers-reduced-motion the parent
+ * renders the finished frame and these never play.
+ */
+
+.rowIn {
+ animation: tables-row-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+.cellIn {
+ animation: tables-cell-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+@keyframes tables-row-in {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes tables-cell-in {
+ from {
+ opacity: 0;
+ transform: translateY(2px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .rowIn,
+ .cellIn {
+ animation: none;
+ }
+}
diff --git a/apps/sim/app/(landing)/tables/components/tables-hero-loop.tsx b/apps/sim/app/(landing)/tables/components/tables-hero-loop.tsx
new file mode 100644
index 00000000000..081ef8aec8d
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/components/tables-hero-loop.tsx
@@ -0,0 +1,406 @@
+'use client'
+
+import { useState } from 'react'
+import { Checkbox, cn } from '@sim/emcn'
+import { ChevronDown, Table, TypeBoolean, TypeText } from '@sim/emcn/icons'
+import { HeroLoopShell } from '@/app/(landing)/components/shared/hero-loop-shell'
+import { RESET_FADE_MS } from '@/app/(landing)/hooks/use-design-scale'
+import { useMotionSafeCycle } from '@/app/(landing)/hooks/use-motion-safe-cycle'
+import styles from '@/app/(landing)/tables/components/tables-hero-loop.module.css'
+
+/** Sidebar content for the tables hero - a data-minded workspace. */
+const SIDEBAR_CHATS = [
+ 'Lead intake agent',
+ 'Enrichment backfill',
+ 'Dedupe the leads table',
+ 'Weekly pipeline digest',
+] as const
+
+/** Deployed workflows in the sidebar - five fill the design height. */
+const SIDEBAR_WORKFLOWS = [
+ 'Lead intake',
+ 'Lead enrichment',
+ 'Ticket triage',
+ 'Invoice matching',
+ 'Pipeline report',
+] as const
+
+/** The grid's cell chrome, copied from the landing Tables preview vocabulary. */
+const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
+const CELL_CHECKBOX =
+ 'border-[var(--border)] border-r border-b px-1 py-[7px] align-middle select-none'
+const CELL_HEADER =
+ 'border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'
+const CELL_HEADER_CHECKBOX =
+ 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
+const CELL_CONTENT =
+ 'relative min-h-[20px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-small'
+
+interface GridColumn {
+ /** Stable key into each row's cell record. */
+ id: 'name' | 'company' | 'email' | 'phone' | 'qualified'
+ /** Column header label. */
+ label: string
+ /** Column type icon - text or boolean, per the real editor's headers. */
+ type: 'text' | 'boolean'
+ /** Fixed pixel width in the 1280-wide design space. */
+ width: number
+}
+
+/**
+ * The Leads table's schema: identity columns the agent writes on intake
+ * (Name, Company) and the columns enrichments fill afterwards (Work email,
+ * Phone, Qualified).
+ */
+const GRID_COLUMNS: readonly GridColumn[] = [
+ { id: 'name', label: 'Name', type: 'text', width: 200 },
+ { id: 'company', label: 'Company', type: 'text', width: 190 },
+ { id: 'email', label: 'Work email', type: 'text', width: 240 },
+ { id: 'phone', label: 'Phone', type: 'text', width: 170 },
+ { id: 'qualified', label: 'Qualified', type: 'boolean', width: 110 },
+] as const
+
+/** Column ids the enrichment sweep fills, in sweep order (column-major). */
+const ENRICHED_COLUMNS: readonly GridColumn['id'][] = ['email', 'phone', 'qualified']
+
+interface LeadRow {
+ /** Every cell value; `qualified` holds `'true'`/`'false'`. */
+ cells: Record
+}
+
+/** Settled records already in the table when the loop opens. */
+const BASE_ROWS: readonly LeadRow[] = [
+ {
+ cells: {
+ name: 'Alice Johnson',
+ company: 'Acme Corp',
+ email: 'alice@acme.com',
+ phone: '+1 (415) 555-0132',
+ qualified: 'true',
+ },
+ },
+ {
+ cells: {
+ name: 'Bob Williams',
+ company: 'TechCo',
+ email: 'bob@techco.io',
+ phone: '+1 (206) 555-0119',
+ qualified: 'false',
+ },
+ },
+ {
+ cells: {
+ name: 'Carol Davis',
+ company: 'StartupCo',
+ email: 'carol@startup.co',
+ phone: '+1 (512) 555-0177',
+ qualified: 'true',
+ },
+ },
+ {
+ cells: {
+ name: 'Dan Miller',
+ company: 'BigCorp',
+ email: 'dan@bigcorp.com',
+ phone: '+1 (312) 555-0140',
+ qualified: 'true',
+ },
+ },
+ {
+ cells: {
+ name: 'Eva Chen',
+ company: 'Design IO',
+ email: 'eva@design.io',
+ phone: '+1 (646) 555-0102',
+ qualified: 'false',
+ },
+ },
+ {
+ cells: {
+ name: 'Frank Lee',
+ company: 'Ventures',
+ email: 'frank@ventures.co',
+ phone: '+1 (628) 555-0163',
+ qualified: 'true',
+ },
+ },
+ {
+ cells: {
+ name: 'Grace Kim',
+ company: 'Northbeam',
+ email: 'grace@northbeam.ai',
+ phone: '+1 (917) 555-0181',
+ qualified: 'true',
+ },
+ },
+ {
+ cells: {
+ name: 'Henry Osei',
+ company: 'Atlas Freight',
+ email: 'henry@atlasfreight.com',
+ phone: '+1 (773) 555-0155',
+ qualified: 'false',
+ },
+ },
+ {
+ cells: {
+ name: 'Ivy Patel',
+ company: 'Lumen Labs',
+ email: 'ivy@lumenlabs.dev',
+ phone: '+1 (408) 555-0126',
+ qualified: 'true',
+ },
+ },
+] as const
+
+/**
+ * Records the intake agent appends during the loop - they land with only the
+ * identity columns filled, then the enrichment sweep completes them.
+ */
+const APPENDED_ROWS: readonly LeadRow[] = [
+ {
+ cells: {
+ name: 'Jonas Weber',
+ company: 'Brightside',
+ email: 'jonas@brightside.io',
+ phone: '+1 (303) 555-0148',
+ qualified: 'true',
+ },
+ },
+ {
+ cells: {
+ name: 'Kara Novak',
+ company: 'Fieldstone',
+ email: 'kara@fieldstone.co',
+ phone: '+1 (215) 555-0193',
+ qualified: 'false',
+ },
+ },
+ {
+ cells: {
+ name: 'Liam Byrne',
+ company: 'Harborline',
+ email: 'liam@harborline.com',
+ phone: '+1 (617) 555-0171',
+ qualified: 'true',
+ },
+ },
+ {
+ cells: {
+ name: 'Mia Torres',
+ company: 'Skylark',
+ email: 'mia@skylark.app',
+ phone: '+1 (702) 555-0117',
+ qualified: 'true',
+ },
+ },
+ {
+ cells: {
+ name: 'Noah Brandt',
+ company: 'Coastal Supply',
+ email: 'noah@coastalsupply.com',
+ phone: '+1 (858) 555-0139',
+ qualified: 'false',
+ },
+ },
+] as const
+
+/** Total enriched cells the sweep fills - one per enriched column per appended row. */
+const TOTAL_FILLED_CELLS = APPENDED_ROWS.length * ENRICHED_COLUMNS.length
+
+/** The settled grid holds this long before the first appended row lands. */
+const IDLE_HOLD_MS = 900
+/** Appended row N stamps in at IDLE_HOLD_MS + N * ROW_STEP_MS. */
+const ROW_STEP_MS = 550
+/** The enrichment sweep starts this long after the last row lands. */
+const ENRICH_AFTER_MS = 600
+/** One enriched cell fills every this many ms, column by column. */
+const CELL_STEP_MS = 170
+/** The fully enriched grid holds this long before the fade. */
+const FILLED_HOLD_MS = 4200
+
+/**
+ * Renders one cell's value - text as-is, booleans as the real editor's
+ * checkbox, rendered non-interactive (no tab stop) since the whole frame is
+ * `aria-hidden` decoration. Enriched cells stamp in with the module's
+ * cell-in animation.
+ */
+function renderValue(column: GridColumn, value: string, animate: boolean) {
+ const content =
+ column.type === 'boolean' ? (
+
+
+
+ ) : (
+ value
+ )
+ if (!animate) return content
+ return {content}
+}
+
+interface TablesGridPaneProps {
+ /** How many appended rows are on the grid (0..APPENDED_ROWS.length). */
+ rowCount: number
+ /** How many enriched cells the sweep has filled (0..TOTAL_FILLED_CELLS). */
+ filledCount: number
+}
+
+/**
+ * The static Tables editor pane in the landing Tables preview's exact
+ * vocabulary - breadcrumb header, typed column headers, numbered rows -
+ * rendered from the parent clock's `rowCount`/`filledCount` beats. The
+ * enrichment sweep fills column-major (all Work emails, then Phones, then
+ * Qualified), so it reads as an enrichment running per column.
+ */
+function TablesGridPane({ rowCount, filledCount }: TablesGridPaneProps) {
+ const cellFilled = (appendedIndex: number, columnId: GridColumn['id']) => {
+ const sweepColumn = ENRICHED_COLUMNS.indexOf(columnId)
+ if (sweepColumn === -1) return true
+ return sweepColumn * APPENDED_ROWS.length + appendedIndex < filledCount
+ }
+
+ const renderRow = (row: LeadRow, rowIndex: number, appendedIndex: number | null) => (
+
+
+ {rowIndex + 1}
+
+ {GRID_COLUMNS.map((column) => {
+ const filled = appendedIndex === null || cellFilled(appendedIndex, column.id)
+ const animate = appendedIndex !== null && ENRICHED_COLUMNS.includes(column.id)
+ return (
+
+
+ {filled ? renderValue(column, row.cells[column.id], animate && filled) : null}
+
+
+ )
+ })}
+
+ )
+
+ return (
+
+
+
+
+
+ Tables
+
+
/
+
+ Leads
+
+
+
+
+
+
+
+
+
+ {GRID_COLUMNS.map((column) => (
+
+ ))}
+
+
+
+
+ {GRID_COLUMNS.map((column) => {
+ const Icon = column.type === 'boolean' ? TypeBoolean : TypeText
+ return (
+
+
+
+
+ {column.label}
+
+
+
+
+ )
+ })}
+
+
+
+ {BASE_ROWS.map((row, index) => renderRow(row, index, null))}
+ {APPENDED_ROWS.slice(0, rowCount).map((row, index) =>
+ renderRow(row, BASE_ROWS.length + index, index)
+ )}
+
+
+
+
+ )
+}
+
+/**
+ * The tables hero's editor loop - the grid-pane sibling of the workflows
+ * editor loop. Same architecture (fixed 1280x735 design-space layer scaled
+ * to the window via ResizeObserver + `transform: scale`, a parent-owned
+ * clock driving a presentational pane, reduced-motion showing the finished
+ * frame) and the same live {@link EnterpriseSidebar} with its Tables nav
+ * row highlighted, but the workspace pane is the Leads table itself: the
+ * intake agent appends five new records (identity columns only), then the
+ * enrichment sweep fills the empty Work email, Phone, and Qualified cells
+ * column by column, the grid holds fully enriched, and the scene fades
+ * before the cycle restarts.
+ *
+ * Everything is `pointer-events-none` decorative, matching the hero's
+ * `aria-hidden` frame. Under `prefers-reduced-motion` the loop never
+ * starts: the fully appended, fully enriched grid renders statically
+ * (including reacting to media-query changes).
+ */
+export function TablesHeroLoop() {
+ const [rowCount, setRowCount] = useState(0)
+ const [filledCount, setFilledCount] = useState(0)
+ const [fading, setFading] = useState(false)
+ const [cycleId, setCycleId] = useState(0)
+
+ useMotionSafeCycle({
+ scheduleCycle: () => {
+ setFading(false)
+ setRowCount(0)
+ setFilledCount(0)
+ setCycleId((c) => c + 1)
+ const sweepAt = IDLE_HOLD_MS + (APPENDED_ROWS.length - 1) * ROW_STEP_MS + ENRICH_AFTER_MS
+ const totalMs = sweepAt + TOTAL_FILLED_CELLS * CELL_STEP_MS + FILLED_HOLD_MS
+ return {
+ timers: [
+ ...APPENDED_ROWS.map((_, i) =>
+ setTimeout(() => setRowCount(i + 1), IDLE_HOLD_MS + i * ROW_STEP_MS)
+ ),
+ ...Array.from({ length: TOTAL_FILLED_CELLS }, (_, i) =>
+ setTimeout(() => setFilledCount(i + 1), sweepAt + i * CELL_STEP_MS)
+ ),
+ setTimeout(() => setFading(true), totalMs - RESET_FADE_MS),
+ ],
+ totalMs,
+ }
+ },
+ showFinished: () => {
+ setFading(false)
+ setRowCount(APPENDED_ROWS.length)
+ setFilledCount(TOTAL_FILLED_CELLS)
+ },
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/tables/page.tsx b/apps/sim/app/(landing)/tables/page.tsx
new file mode 100644
index 00000000000..39e84fac5fd
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/page.tsx
@@ -0,0 +1,20 @@
+import { buildLandingMetadata } from '@/lib/landing/seo'
+import Tables from '@/app/(landing)/tables/tables'
+
+export const revalidate = 3600
+
+const TITLE = 'Tables | Structured Data for Agents in Sim, the AI Workspace'
+const DESCRIPTION =
+ 'Tables is the database built into Sim, the open-source AI workspace. Store, query, and wire structured data into agent runs — records, enrichments, and state between runs.'
+
+export const metadata = buildLandingMetadata({
+ title: TITLE,
+ description: DESCRIPTION,
+ path: '/tables',
+ keywords:
+ 'AI workspace, built-in database, structured data for AI agents, AI agent memory, data enrichment, agent state between runs, open-source AI agent platform, tables for agents',
+})
+
+export default function Page() {
+ return
+}
diff --git a/apps/sim/app/(landing)/tables/tables.tsx b/apps/sim/app/(landing)/tables/tables.tsx
new file mode 100644
index 00000000000..7ddf7da8287
--- /dev/null
+++ b/apps/sim/app/(landing)/tables/tables.tsx
@@ -0,0 +1,166 @@
+import {
+ PlatformHeroVisual,
+ SolutionsPage,
+ type SolutionsPageConfig,
+} from '@/app/(landing)/components'
+import {
+ AuditTrailGraphic,
+ OperationsTeamsGraphic,
+ RunMonitoringGraphic,
+} from '@/app/(landing)/enterprise/components/feature-graphics'
+import {
+ EnrichmentFillGraphic,
+ TableGridGraphic,
+ TableQueryGraphic,
+} from '@/app/(landing)/tables/components/feature-graphics'
+import { TablesHeroLoop } from '@/app/(landing)/tables/components/tables-hero-loop'
+
+/**
+ * Tables platform page - a consumer of {@link SolutionsPage} rendered
+ * with the enterprise page's feature-tile treatment.
+ *
+ * The whole page is one typed {@link SolutionsPageConfig} rendered inside
+ * the shared route-group layout chrome: identity (for structured data), a
+ * hero, and two rows of three cards. Every visual slot carries a feature
+ * graphic in the enterprise design language - three tables-specific
+ * vignettes (the cropped Leads grid, the enrichment ledger, and the dark
+ * query-window twin of the workflows page's code tile) plus the
+ * monitoring panel, switchboard, and audit ledger retold for runs writing
+ * rows, data routing into tables, and row-change history.
+ *
+ * The JSON-LD emitted by {@link SolutionsPage} is structurally identical
+ * to the platform page's (`WebPage` + `BreadcrumbList` +
+ * `WebApplication`), so the switch to feature tiles is SEO-neutral.
+ */
+const TABLES_CONFIG: SolutionsPageConfig = {
+ module: 'Tables',
+ path: '/tables',
+ hero: {
+ eyebrow: 'Tables',
+ heading: 'Power agents with structured data in Sim.',
+ description:
+ 'Tables is the database built into Sim, the open-source AI workspace. Store the records agents read and write, let enrichments fill empty cells, and carry state from one run to the next.',
+ summary:
+ 'Tables is the built-in database in Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. Teams store records agents read and write, run enrichments that fill cells automatically, query rows from agent logic, and keep state between runs, all in one workspace.',
+ visual: (
+
+
+
+ ),
+ },
+ rows: [
+ {
+ id: 'records',
+ title: 'Give agents structured data to act on.',
+ subtitle:
+ 'Sim stores the records agents work with, leads, tickets, and invoices, as tables that live in the same workspace as the agents themselves.',
+ cta: { label: 'Explore Tables', href: '/signup' },
+ cards: [
+ {
+ title: 'Records agents act on',
+ description:
+ 'Store leads, tickets, and invoices as rows agents read and write. Sim keeps the data next to the agents that work it.',
+ visual: ,
+ },
+ {
+ title: 'Enrichments fill the blanks',
+ description:
+ 'Sim runs enrichments over new rows, finding work emails, phone numbers, and company info, so empty cells fill themselves.',
+ visual: ,
+ },
+ {
+ title: 'Query from agent logic',
+ description:
+ 'Read and write rows from code or any workflow block. Sim treats every table as part of your agent logic.',
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: ,
+ },
+ ],
+ },
+ {
+ id: 'memory',
+ title: 'Tables are your agents’ memory.',
+ subtitle:
+ 'Sim carries state between runs. Every run writes rows, every change is recorded, and agents pick up exactly where they left off.',
+ cta: { label: 'See how agents use Tables', href: '/signup' },
+ cards: [
+ {
+ title: 'State between runs',
+ description:
+ 'Each run writes its results back to the table, so the next run starts from what Sim already knows.',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Wire data in and out',
+ description:
+ 'Sim routes records from your tools into tables and back out again, one switchboard for structured data.',
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Every change recorded',
+ description:
+ 'Sim records every insert and update with who made it, so the history of a table is always inspectable.',
+ visual: (
+
+ ),
+ },
+ ],
+ },
+ ],
+}
+
+export default function Tables() {
+ return
+}
diff --git a/apps/sim/app/(landing)/workflows/components/feature-graphics/agent-code-graphic.tsx b/apps/sim/app/(landing)/workflows/components/feature-graphics/agent-code-graphic.tsx
new file mode 100644
index 00000000000..d3303ac6eea
--- /dev/null
+++ b/apps/sim/app/(landing)/workflows/components/feature-graphics/agent-code-graphic.tsx
@@ -0,0 +1,62 @@
+import { FolderCode } from '@sim/emcn/icons'
+import {
+ type CodeSegment,
+ CodeWindowGraphic,
+} from '@/app/(landing)/components/shared/code-window-graphic'
+
+/**
+ * The `support-agent.ts` excerpt the build-methods tile types out,
+ * rendered settled — the same agent, reached from code.
+ */
+const CODE_LINES: readonly CodeSegment[][] = [
+ [
+ { text: 'import', tone: 'muted' },
+ { text: ' ' },
+ { text: '{ agent }', tone: 'primary' },
+ { text: ' ' },
+ { text: 'from', tone: 'muted' },
+ { text: ' ' },
+ { text: "'@sim/sdk'", tone: 'primary' },
+ ],
+ [
+ { text: 'const', tone: 'muted' },
+ { text: ' ' },
+ { text: 'supportAgent', tone: 'primary' },
+ { text: ' ' },
+ { text: '= await', tone: 'muted' },
+ { text: ' ' },
+ { text: 'agent', tone: 'primary' },
+ ],
+ [{ text: ' .workflow({' }],
+ [
+ { text: ' ' },
+ { text: 'name:', tone: 'muted' },
+ { text: ' ' },
+ { text: "'Support agent'", tone: 'primary' },
+ { text: ',' },
+ ],
+ [
+ { text: ' ' },
+ { text: 'tools:', tone: 'muted' },
+ { text: ' ' },
+ { text: '[zendesk, slack]', tone: 'primary' },
+ { text: ',' },
+ ],
+ [{ text: ' })' }],
+] as const
+
+/**
+ * Code-first building told in the shared {@link CodeWindowGraphic} editor
+ * window: the `FolderCode` mark and the `support-agent.ts` filename over
+ * the same SDK excerpt the build-methods tile types out, sitting settled
+ * so the two tiles read as the same file at rest and mid-write.
+ */
+export function AgentCodeGraphic() {
+ return (
+ }
+ filename='support-agent.ts'
+ lines={CODE_LINES}
+ />
+ )
+}
diff --git a/apps/sim/app/(landing)/workflows/components/feature-graphics/index.ts b/apps/sim/app/(landing)/workflows/components/feature-graphics/index.ts
new file mode 100644
index 00000000000..3e814f8371d
--- /dev/null
+++ b/apps/sim/app/(landing)/workflows/components/feature-graphics/index.ts
@@ -0,0 +1,2 @@
+export { AgentCodeGraphic } from './agent-code-graphic'
+export { WorkflowCanvasGraphic } from './workflow-canvas-graphic'
diff --git a/apps/sim/app/(landing)/workflows/components/feature-graphics/workflow-canvas-graphic.module.css b/apps/sim/app/(landing)/workflows/components/feature-graphics/workflow-canvas-graphic.module.css
new file mode 100644
index 00000000000..4a2475f7b5d
--- /dev/null
+++ b/apps/sim/app/(landing)/workflows/components/feature-graphics/workflow-canvas-graphic.module.css
@@ -0,0 +1,88 @@
+/**
+ * The WorkflowCanvasGraphic's motion, all on one shared 6s cycle: the
+ * three edges draw in with a dash-normalized stroke sweep (every path
+ * carries `pathLength=1`, the deploy tile's pattern), staggered top to
+ * bottom — trigger into agent first, then each output — with each edge's
+ * stagger baked into its own keyframe percentages so every edge resets
+ * on the same loop boundary and the cycle reads draw-once-then-hold. As
+ * the fan-out lands, the agent's port node blooms the family's soft ring
+ * pulse. Under prefers-reduced-motion everything is static: edges fully
+ * drawn, no pulse.
+ */
+
+.edgeDraw {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+}
+
+.edgeDraw0 {
+ animation: workflow-canvas-edge-draw-0 6s ease-in-out infinite;
+}
+
+.edgeDraw1 {
+ animation: workflow-canvas-edge-draw-1 6s ease-in-out infinite;
+}
+
+.edgeDraw2 {
+ animation: workflow-canvas-edge-draw-2 6s ease-in-out infinite;
+}
+
+@keyframes workflow-canvas-edge-draw-0 {
+ 0% {
+ stroke-dashoffset: 1;
+ }
+ 16%,
+ 100% {
+ stroke-dashoffset: 0;
+ }
+}
+
+@keyframes workflow-canvas-edge-draw-1 {
+ 0%,
+ 16% {
+ stroke-dashoffset: 1;
+ }
+ 34%,
+ 100% {
+ stroke-dashoffset: 0;
+ }
+}
+
+@keyframes workflow-canvas-edge-draw-2 {
+ 0%,
+ 26% {
+ stroke-dashoffset: 1;
+ }
+ 44%,
+ 100% {
+ stroke-dashoffset: 0;
+ }
+}
+
+.agentPulse {
+ animation: workflow-canvas-agent-pulse 6s ease-out infinite;
+}
+
+@keyframes workflow-canvas-agent-pulse {
+ 0%,
+ 44% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 35%, transparent);
+ }
+ 62% {
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .edgeDraw {
+ animation: none;
+ stroke-dashoffset: 0;
+ }
+
+ .agentPulse {
+ animation: none;
+ }
+}
diff --git a/apps/sim/app/(landing)/workflows/components/feature-graphics/workflow-canvas-graphic.tsx b/apps/sim/app/(landing)/workflows/components/feature-graphics/workflow-canvas-graphic.tsx
new file mode 100644
index 00000000000..a559ac61a33
--- /dev/null
+++ b/apps/sim/app/(landing)/workflows/components/feature-graphics/workflow-canvas-graphic.tsx
@@ -0,0 +1,122 @@
+import { ChipTag, cn } from '@sim/emcn'
+import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics'
+import styles from '@/app/(landing)/workflows/components/feature-graphics/workflow-canvas-graphic.module.css'
+
+/** Fixed pixel canvas the mini builder is drawn on, centered inside the shell. */
+const CANVAS = { WIDTH: 320, HEIGHT: 250 } as const
+
+/**
+ * Wires between the three block tiers, with vertical tangents at both
+ * ends (the access tile's edge geometry): the trigger drops straight
+ * into the agent, then the agent fans out to its two output blocks.
+ */
+const EDGE_PATHS = [
+ 'M 160 50 L 160 102',
+ 'M 160 144 C 160 172 80 168 80 196',
+ 'M 160 144 C 160 172 240 168 240 196',
+] as const
+
+/** Per-index draw classes — the stagger order is baked into each class's keyframes. */
+const EDGE_DRAW_CLASSES = [styles.edgeDraw0, styles.edgeDraw1, styles.edgeDraw2] as const
+
+/** Output blocks fanned beneath the agent, mirrored around the center axis. */
+const OUTPUT_BLOCKS = [
+ { label: 'Slack', leftClass: 'left-[80px]' },
+ { label: 'Sheets', leftClass: 'left-[240px]' },
+] as const
+
+/**
+ * The visual builder told as a mini workflow canvas, with no window
+ * framing (the access tile's frameless node-graph composition): three
+ * tiers of blocks — a trigger pill across the top, the agent block at
+ * center, and two output blocks fanned below — joined by 1px curved SVG
+ * edges with vertical tangents landing on small port dots (the access
+ * tile's junction vocabulary). Every block is a white card in the audit
+ * tile's exact chrome (`--white` fill, 1px `--border-1` hairline,
+ * `rounded-lg`, `shadow-sm`) so the canvas reads as the workspace's own
+ * block language; the agent is the tile's strongest element, pairing its
+ * name with a solid `Agent` ChipTag.
+ *
+ * Motion (from `workflow-canvas-graphic.module.css`, one shared 6s
+ * cycle): the edges draw in with a dash-normalized stroke sweep
+ * (`pathLength=1`, the deploy tile's pattern), staggered top to bottom
+ * so the graph wires up the way a builder connects it — trigger into
+ * agent, then each output — and the agent's port node blooms the
+ * family's shared ring pulse as the fan-out lands. Under
+ * `prefers-reduced-motion` the graph renders fully drawn and static.
+ *
+ * The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
+ * `max-lg`) but not left, so this centered vignette adds matching right
+ * padding to land on the tile's visible center instead of the bled
+ * slot's center. The fixed-size canvas is `shrink-0` so it keeps its
+ * geometry; narrow grid columns are handled by the feature tile itself,
+ * which zooms its whole design-space canvas down proportionally (see
+ * `SOLUTIONS_VISUAL`), so the outer blocks are never cropped — the
+ * access tile's sizing strategy exactly.
+ */
+export function WorkflowCanvasGraphic() {
+ return (
+
+
+
+
+ {EDGE_PATHS.map((path, index) => (
+
+ ))}
+
+
+
+
+
+ New ticket
+
+
+
+
+
+
+ Support agent
+
+ Agent
+
+
+ {OUTPUT_BLOCKS.map((block) => (
+
+
+
+ {block.label}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/sim/app/(landing)/workflows/components/workflows-editor-loop.tsx b/apps/sim/app/(landing)/workflows/components/workflows-editor-loop.tsx
new file mode 100644
index 00000000000..bb324d19f4c
--- /dev/null
+++ b/apps/sim/app/(landing)/workflows/components/workflows-editor-loop.tsx
@@ -0,0 +1,129 @@
+'use client'
+
+import {
+ AgentIcon,
+ ConditionalIcon,
+ JiraIcon,
+ SlackIcon,
+ StartIcon,
+ TableIcon,
+} from '@/components/icons'
+import { EditorLoop, type EditorLoopContent } from '@/app/(landing)/components/shared/editor-loop'
+
+/**
+ * The workflows hero's content for the shared {@link EditorLoop}: a builder's
+ * workspace sidebar and the complete support-routing workflow - a trigger, an
+ * agent, a router, and a three-way fan-out to Slack, Jira, and Tables. Wider
+ * than the chat heroes' half-pane flows (center spine at x=555, terminals
+ * fanned across 1360 design px) because the canvas owns the whole workspace
+ * pane here. Colors follow the stage convention - grey ramp for platform
+ * blocks, brand tiles only for real third-party marks. Blocks are ordered by
+ * build sequence; an edge draws once both endpoints are on canvas. The agent
+ * block is the one the "editing" beat selects once the flow is assembled.
+ */
+const WORKFLOWS_EDITOR_CONTENT: EditorLoopContent = {
+ sidebarChats: [
+ 'Support bot revamp',
+ 'Lead scoring tweaks',
+ 'Invoice matching flow',
+ 'Weekly digest agent',
+ ],
+ sidebarWorkflows: [
+ 'Support ticket routing',
+ 'Lead enrichment',
+ 'Invoice matching',
+ 'Weekly digest',
+ 'Churn-risk alerts',
+ ],
+ blocks: [
+ {
+ id: 'start',
+ name: 'Start',
+ icon: StartIcon,
+ bgColor: 'var(--text-muted)',
+ isTrigger: true,
+ rows: [{ title: 'Inputs', value: '-' }],
+ x: 555,
+ y: 20,
+ },
+ {
+ id: 'agent',
+ name: 'Support agent',
+ icon: AgentIcon,
+ bgColor: 'var(--text-primary)',
+ rows: [
+ { title: 'Messages', value: '-' },
+ { title: 'Model', value: '-' },
+ ],
+ x: 555,
+ y: 230,
+ },
+ {
+ id: 'route',
+ name: 'Route intent',
+ icon: ConditionalIcon,
+ bgColor: 'var(--text-secondary)',
+ rows: [{ title: 'Conditions', value: '-' }],
+ x: 555,
+ y: 470,
+ },
+ {
+ id: 'slack',
+ name: 'Reply in Slack',
+ icon: SlackIcon,
+ bgColor: '#611F69',
+ isTerminal: true,
+ rows: [
+ { title: 'Channel', value: '-' },
+ { title: 'Message', value: '-' },
+ ],
+ x: 100,
+ y: 700,
+ },
+ {
+ id: 'jira',
+ name: 'Escalate to Jira',
+ icon: JiraIcon,
+ bgColor: '#FFFFFF',
+ tileBorder: true,
+ isTerminal: true,
+ rows: [
+ { title: 'Project', value: '-' },
+ { title: 'Summary', value: '-' },
+ ],
+ x: 555,
+ y: 700,
+ },
+ {
+ id: 'tables',
+ name: 'Log to Tables',
+ icon: TableIcon,
+ bgColor: 'var(--text-body)',
+ isTerminal: true,
+ rows: [
+ { title: 'Table', value: '-' },
+ { title: 'Operation', value: '-' },
+ ],
+ x: 1010,
+ y: 700,
+ },
+ ],
+ edges: [
+ ['start', 'agent'],
+ ['agent', 'route'],
+ ['route', 'slack'],
+ ['route', 'jira'],
+ ['route', 'tables'],
+ ],
+ canvas: { width: 1360, height: 910 },
+ selectedBlockId: 'agent',
+}
+
+/**
+ * The workflows hero's editor loop - the shared {@link EditorLoop} replaying
+ * the support-routing workflow with the agent block as the "being edited"
+ * beat.
+ */
+export function WorkflowsEditorLoop() {
+ return
+}
diff --git a/apps/sim/app/(landing)/workflows/workflows.tsx b/apps/sim/app/(landing)/workflows/workflows.tsx
index 6f70e9bd6a9..b87557e4619 100644
--- a/apps/sim/app/(landing)/workflows/workflows.tsx
+++ b/apps/sim/app/(landing)/workflows/workflows.tsx
@@ -1,27 +1,54 @@
-import { PlatformPage, type PlatformPageConfig } from '@/app/(landing)/components'
+import {
+ PlatformHeroVisual,
+ SolutionsPage,
+ type SolutionsPageConfig,
+} from '@/app/(landing)/components'
+import {
+ AuditTrailGraphic,
+ BuildMethodsGraphic,
+ DeployGraphic,
+ RunMonitoringGraphic,
+} from '@/app/(landing)/enterprise/components/feature-graphics'
+import { KnowledgeAnswerGraphic } from '@/app/(landing)/solutions/components/feature-graphics'
+import {
+ AgentCodeGraphic,
+ WorkflowCanvasGraphic,
+} from '@/app/(landing)/workflows/components/feature-graphics'
+import { WorkflowsEditorLoop } from '@/app/(landing)/workflows/components/workflows-editor-loop'
/**
- * Workflows platform page - the reference consumer of {@link PlatformPage}.
+ * Workflows platform page - a consumer of {@link SolutionsPage} rendered
+ * with the enterprise page's feature-tile treatment.
*
- * The whole page is one typed {@link PlatformPageConfig} rendered inside the
- * shared route-group layout chrome: identity (for structured data), a hero, and
- * card rows of 3-4 cards. Every spacing, gutter, and section rhythm lives inside
- * the components - this page passes only content, so it cannot mis-space anything.
+ * The whole page is one typed {@link SolutionsPageConfig} rendered inside
+ * the shared route-group layout chrome: identity (for structured data), a
+ * hero, and card rows of 3-4 cards. Every visual slot carries a feature
+ * graphic in the enterprise design language - the build-methods loop and
+ * deploy window reused for the stories they already tell, the monitoring
+ * panel, chat answer, and audit ledger retold for scheduled runs, Slack
+ * bots, and run tracing (as a 2×2 grid, so each vignette keeps its full
+ * treatment), plus two workflows-specific vignettes: the mini builder
+ * canvas and its code-side twin.
*
- * Visual slots are `null` here, so each one renders the layout's reserved
- * `--surface-2` placeholder panel; a real page swaps in its own client visual
- * island (a product mock or animation) without touching the layout.
+ * The JSON-LD emitted by {@link SolutionsPage} is structurally identical
+ * to the platform page's (`WebPage` + `BreadcrumbList` +
+ * `WebApplication`), so the switch to feature tiles is SEO-neutral.
*/
-const WORKFLOWS_CONFIG: PlatformPageConfig = {
+const WORKFLOWS_CONFIG: SolutionsPageConfig = {
module: 'Workflows',
path: '/workflows',
hero: {
+ eyebrow: 'Workflows',
heading: 'Build Slack bots, compliance agents, and data pipelines in Sim.',
description:
'Connect blocks, every major LLM, and 1,000+ integrations into agent logic, the visual builder in Sim, the open-source AI workspace. Build visually, conversationally, or with code.',
summary:
'Workflows is the visual builder in Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. Wire blocks, every major LLM, and 1,000+ integrations into agent logic, then deploy and run it without leaving Sim, visually, conversationally, or with code.',
- visual: null,
+ visual: (
+
+
+
+ ),
},
rows: [
{
@@ -35,19 +62,21 @@ const WORKFLOWS_CONFIG: PlatformPageConfig = {
title: 'Drag and connect',
description:
'Wire blocks, models, and integrations on the visual builder. Sim turns the graph into agent logic you can run.',
- visual: null,
+ visual: ,
},
{
title: 'Describe it in words',
description:
'Tell Sim what the agent should do in plain language, and the workspace assembles the workflow for you.',
- visual: null,
+ visual: ,
},
{
title: 'Drop into code',
description:
'Reach for code blocks when you need exact control. Sim runs your logic alongside every other block.',
- visual: null,
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: ,
},
],
},
@@ -57,30 +86,78 @@ const WORKFLOWS_CONFIG: PlatformPageConfig = {
subtitle:
'Ship agents to production as APIs, Slack bots, or scheduled jobs, and trace every run block by block, all in one workspace.',
cta: { label: 'Learn about deployment', href: '/signup' },
+ columns: 2,
cards: [
{
title: 'Ship as an API',
description:
'Sim exposes every workflow as an endpoint, so any system can call your agent with one request.',
- visual: null,
+ featureTileTone: 'dark',
+ featureTileDescriptionTone: 'soft',
+ visual: (
+
+ ),
},
{
title: 'Run on a schedule',
description:
'Set agents to run on a cadence. Sim handles the triggers so the work happens on its own.',
- visual: null,
+ visual: ,
},
{
title: 'Connect to Slack',
description:
'Turn a workflow into a Slack bot your team talks to. Sim wires the integration end to end.',
- visual: null,
+ visual: (
+
+ ),
},
{
title: 'Trace every run',
description:
'Sim logs each run block by block, so teams see exactly what an agent did and why.',
- visual: null,
+ visual: (
+
+ ),
},
],
},
@@ -88,5 +165,5 @@ const WORKFLOWS_CONFIG: PlatformPageConfig = {
}
export default function Workflows() {
- return
+ return
}
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index 367821cb7d0..b582fdee99f 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -7,6 +7,11 @@ import { and, desc, eq } from 'drizzle-orm'
import { withLeaderLock } from '@/lib/concurrency/leader-lock'
import { coalesceLocally } from '@/lib/concurrency/singleflight'
import { decryptSecret } from '@/lib/core/security/encryption'
+import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
+import {
+ parseTokenServiceAccountSecretBlob,
+ type TokenServiceAccountSecretBlob,
+} from '@/lib/credentials/token-service-accounts/server'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
@@ -320,49 +325,102 @@ export async function getAtlassianServiceAccountSecret(
}
/**
- * For Atlassian service accounts, the API token IS the access token —
- * blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
- * No exchange or refresh is needed; we just decrypt and return the raw token.
+ * Result of resolving a `service_account` credential into a usable token. For
+ * Atlassian and the token-paste providers, the stored token IS the access
+ * token — no exchange or refresh is needed; Google mints a short-lived token
+ * via the JWT-bearer flow instead.
*/
export interface ServiceAccountTokenResult {
accessToken: string
/** Atlassian only — the resolved Jira/Confluence cloud id. */
cloudId?: string
- /** Atlassian only — the site domain. */
+ /** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */
domain?: string
}
/**
- * Single dispatch point for turning a `service_account` credential into an
- * access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
- * `POST /api/auth/oauth/token` route go through here, so a new service-account
- * provider is one edit and an unknown provider fails loudly instead of silently
- * attempting a Google JWT.
+ * Loads and parses the decrypted secret blob for a token service-account
+ * credential (pasted long-lived provider token). Throws if the credential is
+ * missing or the blob doesn't belong to the expected provider.
*/
-export async function resolveServiceAccountToken(
+async function getTokenServiceAccountSecret(
credentialId: string,
- providerId: string | null | undefined,
- scopes?: string[],
+ providerId: string
+): Promise {
+ const [credentialRow] = await db
+ .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
+ .from(credential)
+ .where(eq(credential.id, credentialId))
+ .limit(1)
+
+ if (!credentialRow?.encryptedServiceAccountKey) {
+ throw new Error('Token service account secret not found')
+ }
+
+ const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
+ return parseTokenServiceAccountSecretBlob(decrypted, providerId)
+}
+
+interface ServiceAccountTokenOptions {
+ scopes?: string[]
impersonateEmail?: string
-): Promise {
- if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
+}
+
+type ServiceAccountTokenResolver = (
+ credentialId: string,
+ options: ServiceAccountTokenOptions
+) => Promise
+
+/**
+ * Resolver registry for the bespoke service-account providers. Token-paste
+ * providers (registered in `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`) resolve
+ * generically: the stored token IS the access token.
+ */
+const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record = {
+ [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => {
const secret = await getAtlassianServiceAccountSecret(credentialId)
return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain }
- }
- if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
+ },
+ [SLACK_CUSTOM_BOT_PROVIDER_ID]: async (credentialId) => {
const botCredential = await getSlackBotCredential(credentialId)
if (!botCredential) {
throw new Error('Slack bot credential not found')
}
return { accessToken: botCredential.botToken }
- }
- if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) {
+ },
+ [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId, { scopes, impersonateEmail }) => {
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) }
- }
- throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
+ },
+}
+
+/**
+ * Single dispatch point for turning a `service_account` credential into an
+ * access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the
+ * `POST /api/auth/oauth/token` route go through here, so a new service-account
+ * provider is one registry entry and an unknown provider fails loudly instead
+ * of silently attempting a Google JWT.
+ */
+export async function resolveServiceAccountToken(
+ credentialId: string,
+ providerId: string | null | undefined,
+ scopes?: string[],
+ impersonateEmail?: string
+): Promise {
+ if (providerId && isTokenServiceAccountProviderId(providerId)) {
+ const secret = await getTokenServiceAccountSecret(credentialId, providerId)
+ return { accessToken: secret.apiToken, domain: secret.domain }
+ }
+ const resolver =
+ providerId && Object.hasOwn(SERVICE_ACCOUNT_TOKEN_RESOLVERS, providerId)
+ ? SERVICE_ACCOUNT_TOKEN_RESOLVERS[providerId]
+ : undefined
+ if (!resolver) {
+ throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`)
+ }
+ return resolver(credentialId, { scopes, impersonateEmail })
}
/**
diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts
index 9eb104547ff..82f3def0220 100644
--- a/apps/sim/app/api/chat/[identifier]/route.test.ts
+++ b/apps/sim/app/api/chat/[identifier]/route.test.ts
@@ -14,7 +14,6 @@ import {
workflowsApiUtilsMock,
workflowsApiUtilsMockFns,
} from '@sim/testing'
-import { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
@@ -65,19 +64,13 @@ const createMockStream = () => {
})
}
-const {
- mockValidateChatAuth,
- mockSetChatAuthCookie,
- mockValidateAuthToken,
- mockAssertChatEmbedAllowed,
- mockProcessChatFiles,
-} = vi.hoisted(() => ({
- mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
- mockSetChatAuthCookie: vi.fn(),
- mockValidateAuthToken: vi.fn().mockReturnValue(false),
- mockAssertChatEmbedAllowed: vi.fn().mockResolvedValue(null),
- mockProcessChatFiles: vi.fn(),
-}))
+const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken, mockProcessChatFiles } =
+ vi.hoisted(() => ({
+ mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
+ mockSetChatAuthCookie: vi.fn(),
+ mockValidateAuthToken: vi.fn().mockReturnValue(false),
+ mockProcessChatFiles: vi.fn(),
+ }))
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
@@ -97,7 +90,6 @@ vi.mock('@/lib/core/security/deployment', () => ({
vi.mock('@/app/api/chat/utils', () => ({
validateChatAuth: mockValidateChatAuth,
setChatAuthCookie: mockSetChatAuthCookie,
- assertChatEmbedAllowed: mockAssertChatEmbedAllowed,
}))
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
@@ -245,24 +237,6 @@ describe('Chat Identifier API Route', () => {
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
})
- it('should return 403 when embedding is blocked for a cross-origin caller', async () => {
- mockAssertChatEmbedAllowed.mockResolvedValueOnce(
- NextResponse.json(
- { error: 'Embedding this chat on external sites requires a paid plan' },
- {
- status: 403,
- }
- )
- )
-
- const req = createMockNextRequest('GET', undefined, { origin: 'https://evil.example.com' })
- const params = Promise.resolve({ identifier: 'test-chat' })
-
- const response = await GET(req, { params })
-
- expect(response.status).toBe(403)
- })
-
it('should return 404 for non-existent identifier', async () => {
dbChainMockFns.select.mockImplementation(() => {
return {
@@ -335,28 +309,6 @@ describe('Chat Identifier API Route', () => {
})
describe('POST endpoint', () => {
- it('should return 403 when embedding is blocked for a cross-origin caller', async () => {
- mockAssertChatEmbedAllowed.mockResolvedValueOnce(
- NextResponse.json(
- { error: 'Embedding this chat on external sites requires a paid plan' },
- {
- status: 403,
- }
- )
- )
-
- const req = createMockNextRequest(
- 'POST',
- { input: 'Hello' },
- { origin: 'https://evil.example.com' }
- )
- const params = Promise.resolve({ identifier: 'test-chat' })
-
- const response = await POST(req, { params })
-
- expect(response.status).toBe(403)
- })
-
it('should return chat config on successful authentication', async () => {
const req = createMockNextRequest('POST', { password: 'test-password' })
const params = Promise.resolve({ identifier: 'password-protected-chat' })
diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts
index 819c732db5b..578be93c189 100644
--- a/apps/sim/app/api/chat/[identifier]/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/route.ts
@@ -15,7 +15,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { ChatFiles } from '@/lib/uploads'
-import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
+import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatIdentifierAPI')
@@ -135,9 +135,6 @@ export const POST = withRouteHandler(
return createErrorResponse('This chat is currently unavailable', 403)
}
- const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId)
- if (embedBlock) return embedBlock
-
const authResult = await validateChatAuth(requestId, deployment, request, parsedBody)
if (!authResult.authorized) {
const response = createErrorResponse(
@@ -361,9 +358,6 @@ export const GET = withRouteHandler(
return createErrorResponse('This chat is currently unavailable', 403)
}
- const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId)
- if (embedBlock) return embedBlock
-
const cookieName = `chat_auth_${deployment.id}`
const authCookie = request.cookies.get(cookieName)
diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts
index a6eea9107e4..4e457c1181d 100644
--- a/apps/sim/app/api/chat/utils.test.ts
+++ b/apps/sim/app/api/chat/utils.test.ts
@@ -5,7 +5,6 @@
*/
import {
dbChainMock,
- dbChainMockFns,
encryptionMock,
encryptionMockFns,
loggingSessionMock,
@@ -22,8 +21,6 @@ const {
mockIsEmailAllowed,
mockGetSession,
mockCheckRateLimitDirect,
- mockIsWorkspaceApiExecutionEntitled,
- flagState,
} = vi.hoisted(() => ({
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
@@ -32,16 +29,10 @@ const {
mockIsEmailAllowed: vi.fn(),
mockGetSession: vi.fn(),
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
- mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
- flagState: { isBillingEnabled: false, isFreeApiDeploymentGateEnabled: true },
}))
vi.mock('@sim/db', () => dbChainMock)
-vi.mock('@/lib/billing/core/api-access', () => ({
- isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
-}))
-
vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: class {
checkRateLimitDirect = mockCheckRateLimitDirect
@@ -79,28 +70,10 @@ vi.mock('@/lib/core/security/deployment', () => ({
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
}))
-vi.mock('@/lib/core/config/env-flags', () => ({
- isDev: true,
- isProd: false,
- get isBillingEnabled() {
- return flagState.isBillingEnabled
- },
- get isFreeApiDeploymentGateEnabled() {
- return flagState.isFreeApiDeploymentGateEnabled
- },
-}))
-
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
-import { NextRequest } from 'next/server'
import { decryptSecret } from '@/lib/core/security/encryption'
-import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
-
-function chatRequest(origin?: string): NextRequest {
- return new NextRequest('https://www.sim.ai/api/chat/abc', {
- headers: origin ? { origin } : undefined,
- })
-}
+import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
describe('Chat API Utils', () => {
beforeEach(() => {
@@ -479,77 +452,3 @@ describe('Chat API Utils', () => {
})
})
})
-
-describe('assertChatEmbedAllowed', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- flagState.isBillingEnabled = true
- flagState.isFreeApiDeploymentGateEnabled = true
- mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true)
- dbChainMockFns.limit.mockResolvedValue([{ workspaceId: 'ws-1' }])
- })
-
- it('returns 403 for a cross-site origin when the owner is on the free plan', async () => {
- mockIsWorkspaceApiExecutionEntitled.mockResolvedValueOnce(false)
- const res = await assertChatEmbedAllowed(
- chatRequest('https://evil.example.com'),
- 'wf-1',
- 'req-1'
- )
- expect(res?.status).toBe(403)
- })
-
- it('allows a cross-site origin when the owner is on a paid plan', async () => {
- const res = await assertChatEmbedAllowed(
- chatRequest('https://evil.example.com'),
- 'wf-1',
- 'req-1'
- )
- expect(res).toBeNull()
- })
-
- it('returns 403 for a cross-site origin when the workflow has no active workspace', async () => {
- dbChainMockFns.limit.mockResolvedValueOnce([])
- const res = await assertChatEmbedAllowed(
- chatRequest('https://evil.example.com'),
- 'wf-1',
- 'req-1'
- )
- expect(res?.status).toBe(403)
- expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
- })
-
- it('allows a first-party *.sim.ai origin without gating', async () => {
- const res = await assertChatEmbedAllowed(chatRequest('https://chat.sim.ai'), 'wf-1', 'req-1')
- expect(res).toBeNull()
- expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
- })
-
- it('allows requests with no Origin header', async () => {
- const res = await assertChatEmbedAllowed(chatRequest(), 'wf-1', 'req-1')
- expect(res).toBeNull()
- expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
- })
-
- it('is a no-op when billing is disabled', async () => {
- flagState.isBillingEnabled = false
- const res = await assertChatEmbedAllowed(
- chatRequest('https://evil.example.com'),
- 'wf-1',
- 'req-1'
- )
- expect(res).toBeNull()
- expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
- })
-
- it('is a no-op when the gate feature flag is disabled', async () => {
- flagState.isFreeApiDeploymentGateEnabled = false
- const res = await assertChatEmbedAllowed(
- chatRequest('https://evil.example.com'),
- 'wf-1',
- 'req-1'
- )
- expect(res).toBeNull()
- expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
- })
-})
diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts
index c200a47adb5..5b17f3cb6e8 100644
--- a/apps/sim/app/api/chat/utils.ts
+++ b/apps/sim/app/api/chat/utils.ts
@@ -1,20 +1,13 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
-import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
-import { getEnv } from '@/lib/core/config/env'
-import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags'
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
import {
type DeploymentAuthResult,
validateDeploymentAuth,
} from '@/lib/core/security/deployment-auth'
-import { createErrorResponse } from '@/app/api/workflows/utils'
-
-const logger = createLogger('ChatAuthUtils')
export function setChatAuthCookie(
response: NextResponse,
@@ -25,60 +18,6 @@ export function setChatAuthCookie(
setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword)
}
-/**
- * A first-party origin is the app itself or any `*.sim.ai` host (chat subdomains
- * + apex). Anything else is a third-party embed. Malformed origins are treated
- * as third-party.
- */
-function isFirstPartyOrigin(origin: string): boolean {
- try {
- const host = new URL(origin).hostname.toLowerCase()
- if (host === 'sim.ai' || host.endsWith('.sim.ai')) return true
- const appUrl = getEnv('NEXT_PUBLIC_APP_URL')
- if (appUrl && host === new URL(appUrl).hostname.toLowerCase()) return true
- return false
- } catch {
- return false
- }
-}
-
-/**
- * Gates cross-origin (embedded) chat requests behind a paid plan on hosted.
- * Same-origin / SSR / first-party requests — including the chat page rendered in
- * a third-party iframe, which calls the API from a `*.sim.ai` origin — are never
- * gated. Returns a 403 response to short-circuit the route, or `null` to allow.
- */
-export async function assertChatEmbedAllowed(
- request: NextRequest,
- workflowId: string,
- requestId: string
-): Promise {
- if (!isBillingEnabled || !isFreeApiDeploymentGateEnabled) return null
-
- const origin = request.headers.get('origin')
- if (!origin || isFirstPartyOrigin(origin)) return null
-
- const [wf] = await db
- .select({ workspaceId: workflow.workspaceId })
- .from(workflow)
- .where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt)))
- .limit(1)
-
- if (!wf?.workspaceId) {
- logger.warn(
- `[${requestId}] Chat embed blocked: no active workspace for workflow ${workflowId}, origin=${origin}`
- )
- return createErrorResponse('This chat is currently unavailable', 403)
- }
-
- if (!(await isWorkspaceApiExecutionEntitled(wf.workspaceId))) {
- logger.warn(`[${requestId}] Chat embed blocked: workspace on free plan, origin=${origin}`)
- return createErrorResponse('Embedding this chat on external sites requires a paid plan', 403)
- }
-
- return null
-}
-
/**
* Check if user has permission to create a chat for a specific workflow
*/
diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts
index db1dc91c47c..e1708f0b48b 100644
--- a/apps/sim/app/api/credentials/route.ts
+++ b/apps/sim/app/api/credentials/route.ts
@@ -27,6 +27,7 @@ import {
ServiceAccountSecretError,
verifyAndBuildServiceAccountSecret,
} from '@/lib/credentials/service-account-secret'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -597,6 +598,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
return NextResponse.json({ code: error.code, error: error.code }, { status: 400 })
}
+ if (error instanceof TokenServiceAccountValidationError) {
+ logger.warn(`[${requestId}] Token service-account credential rejected: ${error.code}`, {
+ code: error.code,
+ upstreamStatus: error.status,
+ ...error.logDetail,
+ })
+ return NextResponse.json({ code: error.code, error: error.code }, { status: 400 })
+ }
if (error instanceof DuplicateCredentialError) {
return NextResponse.json(
{
diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
index f0af2021175..cd8ec829cae 100644
--- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
+++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
@@ -20,19 +20,12 @@ const {
mockResolveBillingAttribution,
mockSerializeBillingAttributionHeader,
fetchMock,
- mockIsWorkspaceApiExecutionEntitled,
} = vi.hoisted(() => ({
mockAssertBillingAttributionSnapshot: vi.fn(),
mockGenerateInternalToken: vi.fn(),
mockResolveBillingAttribution: vi.fn(),
mockSerializeBillingAttributionHeader: vi.fn(),
fetchMock: vi.fn(),
- mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
-}))
-
-vi.mock('@/lib/billing/core/api-access', () => ({
- API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
- isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
}))
vi.mock('@/lib/billing/core/billing-attribution', () => ({
@@ -129,26 +122,6 @@ describe('MCP Serve Route', () => {
expect(response.status).toBe(401)
})
- it('returns 402 when the workspace billed account is on the free plan', async () => {
- dbChainMockFns.limit.mockResolvedValueOnce([
- {
- id: 'server-1',
- name: 'Private Server',
- workspaceId: 'ws-1',
- isPublic: false,
- createdBy: 'owner-1',
- },
- ])
- mockIsWorkspaceApiExecutionEntitled.mockResolvedValueOnce(false)
-
- const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
- method: 'POST',
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }),
- })
- const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
- expect(response.status).toBe(402)
- })
-
it('returns 401 on GET for private server when auth fails', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts
index 5186d6c3410..a5650de1042 100644
--- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts
+++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts
@@ -30,10 +30,6 @@ import {
} from '@/lib/api/contracts/mcp'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
-import {
- API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
- isWorkspaceApiExecutionEntitled,
-} from '@/lib/billing/core/api-access'
import {
assertBillingAttributionSnapshot,
BILLING_ATTRIBUTION_HEADER,
@@ -341,15 +337,6 @@ async function authorizeMcpServeRequest(
server: WorkflowMcpServeServer,
options: { requireAuthForPublic?: boolean } = {}
): Promise<{ response?: NextResponse; executeAuthContext?: ExecuteAuthContext }> {
- if (!(await isWorkspaceApiExecutionEntitled(server.workspaceId))) {
- return {
- response: NextResponse.json(
- { error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE },
- { status: 402 }
- ),
- }
- }
-
if (server.isPublic && !options.requireAuthForPublic) return {}
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts
index 9b0b500e0db..c549654b80a 100644
--- a/apps/sim/app/api/tools/linear/projects/route.ts
+++ b/apps/sim/app/api/tools/linear/projects/route.ts
@@ -88,7 +88,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- const linearClient = new LinearClient({ accessToken })
+ const linearClient = accessToken.startsWith('lin_api_')
+ ? // Personal API keys use the SDK's apiKey option (bare Authorization
+ // header), matching linearAuthorizationHeader in @/tools/linear/utils.
+ new LinearClient({ apiKey: accessToken })
+ : new LinearClient({ accessToken })
/**
* teamId may be a single ID or a comma-separated list when the basic-mode
diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts
index f71adec6b0a..a03ccaea4d6 100644
--- a/apps/sim/app/api/tools/linear/teams/route.ts
+++ b/apps/sim/app/api/tools/linear/teams/route.ts
@@ -86,7 +86,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- const linearClient = new LinearClient({ accessToken })
+ const linearClient = accessToken.startsWith('lin_api_')
+ ? // Personal API keys use the SDK's apiKey option (bare Authorization
+ // header), matching linearAuthorizationHeader in @/tools/linear/utils.
+ new LinearClient({ apiKey: accessToken })
+ : new LinearClient({ accessToken })
const allTeams = await fetchAllTeams(linearClient)
const teams = allTeams.map((team: Team) => ({
id: team.id,
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
index 60d0beca8f1..ab090a83b8b 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
@@ -116,7 +116,6 @@ const {
handleWebhookEventFilterMock,
queueWebhookExecutionMock,
dispatchResolvedWebhookTargetMock,
- isWorkspaceApiExecutionEntitledMock,
shouldSkipWebhookEventMock,
admissionRejectedResponseMock,
tryAdmitMock,
@@ -176,17 +175,11 @@ const {
return NextResponse.json({ message: 'Webhook processed' })
}),
dispatchResolvedWebhookTargetMock: vi.fn(),
- isWorkspaceApiExecutionEntitledMock: vi.fn().mockResolvedValue(true),
shouldSkipWebhookEventMock: vi.fn().mockReturnValue(false),
admissionRejectedResponseMock: vi.fn(),
tryAdmitMock: vi.fn<() => { release: () => void } | null>(() => ({ release: vi.fn() })),
}))
-vi.mock('@/lib/billing/core/api-access', () => ({
- API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
- isWorkspaceApiExecutionEntitled: isWorkspaceApiExecutionEntitledMock,
-}))
-
vi.mock('@/lib/core/admission/gate', () => ({
admissionRejectedResponse: admissionRejectedResponseMock,
tryAdmit: tryAdmitMock,
@@ -513,7 +506,6 @@ describe('Webhook Trigger API Route', () => {
isFromNormalizedTables: true,
})
workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true)
- isWorkspaceApiExecutionEntitledMock.mockResolvedValue(true)
handleWebhookEventFilterMock.mockResolvedValue(null)
shouldSkipWebhookEventMock.mockReturnValue(false)
@@ -859,70 +851,6 @@ describe('Webhook Trigger API Route', () => {
expect(data.message).toBe('Webhook processed')
})
- it('blocks a generic webhook when the workspace is on the free plan', async () => {
- testData.webhooks.push({
- id: 'generic-webhook-id',
- provider: 'generic',
- path: 'test-path',
- isActive: true,
- providerConfig: { requireAuth: false },
- workflowId: 'test-workflow-id',
- rateLimitCount: 100,
- rateLimitPeriod: 60,
- })
- testData.workflows.push({
- id: 'test-workflow-id',
- userId: 'test-user-id',
- workspaceId: 'test-workspace-id',
- })
- isWorkspaceApiExecutionEntitledMock.mockResolvedValueOnce(false)
-
- const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
- const params = Promise.resolve({ path: 'test-path' })
-
- const response = await POST(req, { params })
-
- expect(response.status).toBe(402)
- })
-
- it('returns 402 (not 500) when every webhook in a shared path is generic and free', async () => {
- testData.webhooks.push(
- {
- id: 'generic-webhook-a',
- provider: 'generic',
- path: 'test-path',
- isActive: true,
- providerConfig: { requireAuth: false },
- workflowId: 'test-workflow-id',
- rateLimitCount: 100,
- rateLimitPeriod: 60,
- },
- {
- id: 'generic-webhook-b',
- provider: 'generic',
- path: 'test-path',
- isActive: true,
- providerConfig: { requireAuth: false },
- workflowId: 'test-workflow-id',
- rateLimitCount: 100,
- rateLimitPeriod: 60,
- }
- )
- testData.workflows.push({
- id: 'test-workflow-id',
- userId: 'test-user-id',
- workspaceId: 'test-workspace-id',
- })
- isWorkspaceApiExecutionEntitledMock.mockResolvedValue(false)
-
- const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
- const params = Promise.resolve({ path: 'test-path' })
-
- const response = await POST(req, { params })
-
- expect(response.status).toBe(402)
- })
-
it('should authenticate with Bearer token when no custom header is configured', async () => {
testData.webhooks.push({
id: 'generic-webhook-id',
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
index 3779e3b31ea..5fbbb744ecb 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
@@ -2,10 +2,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { webhookTriggerGetContract, webhookTriggerPostContract } from '@/lib/api/contracts/webhooks'
import { parseRequest } from '@/lib/api/server'
-import {
- API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
- isWorkspaceApiExecutionEntitled,
-} from '@/lib/billing/core/api-access'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -134,7 +130,6 @@ async function handleWebhookPost(
// Process each webhook matched on this path
const responses: NextResponse[] = []
const failures: NextResponse[] = []
- let billingBlocked = false
for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
const provider = foundWebhook.provider
@@ -152,19 +147,6 @@ async function handleWebhookPost(
return missingProviderResponse
}
- // Generic ("custom") webhooks are an unauthenticated programmatic execution
- // surface, so they fall under the same paid-plan gate as the API. Provider
- // webhooks (slack, github, ...) are unaffected.
- if (
- provider === 'generic' &&
- !(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId ?? undefined))
- ) {
- logger.warn(`[${requestId}] Generic webhook blocked: workspace on free plan`)
- billingBlocked = true
- if (webhooksForPath.length > 1) continue
- return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 })
- }
-
const authError = await verifyProviderAuth(
foundWebhook,
foundWorkflow,
@@ -218,9 +200,6 @@ async function handleWebhookPost(
}
if (responses.length === 0) {
- if (billingBlocked) {
- return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 })
- }
if (failures.length > 0) {
return failures[0]
}
diff --git a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts
index 04aaa4e5e9e..c23bba7d666 100644
--- a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts
@@ -21,19 +21,12 @@ const {
mockRegisterLargeValueOwner,
mockUploadFile,
uploadedFiles,
- mockIsWorkspaceApiExecutionEntitled,
} = vi.hoisted(() => ({
mockAddLargeValueReference: vi.fn(),
mockDownloadFile: vi.fn(),
mockRegisterLargeValueOwner: vi.fn(),
mockUploadFile: vi.fn(),
uploadedFiles: new Map(),
- mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
-}))
-
-vi.mock('@/lib/billing/core/api-access', () => ({
- API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
- isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
}))
const MATERIALIZATION_CONTEXT = {
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
index bbab08761d8..ce55f06ee23 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
@@ -31,7 +31,6 @@ const {
mockGetWorkspaceBillingSettings,
mockHandlePostExecutionPauseState,
mockHasDurableExecutionOwner,
- mockIsWorkspaceApiExecutionEntitled,
mockReleaseExecutionIdClaim,
mockReleaseExecutionSlot,
mockRequireBillingAttributionHeader,
@@ -50,7 +49,6 @@ const {
mockGetWorkspaceBillingSettings: vi.fn(),
mockHandlePostExecutionPauseState: vi.fn(),
mockHasDurableExecutionOwner: vi.fn(),
- mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
mockReleaseExecutionIdClaim: vi.fn(),
mockReleaseExecutionSlot: vi.fn(),
mockRequireBillingAttributionHeader: vi.fn(),
@@ -59,11 +57,6 @@ const {
vi.mock('@sim/db', () => dbChainMock)
-vi.mock('@/lib/billing/core/api-access', () => ({
- API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
- isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
-}))
-
vi.mock('@/lib/billing/core/billing-attribution', () => ({
assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index a0ca81c0464..cbc3ea579c8 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -14,10 +14,6 @@ import {
} from '@/lib/api/contracts/workflows'
import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid'
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
-import {
- API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
- isWorkspaceApiExecutionEntitled,
-} from '@/lib/billing/core/api-access'
import {
assertBillingAttributionSnapshot,
type BillingAttributionSnapshot,
@@ -563,7 +559,6 @@ async function handleExecutePost(
let userId: string
let isPublicApiAccess = false
- let gateWorkspaceId: string | undefined
if (!auth.success || !auth.userId) {
const hasExplicitCredentials =
@@ -598,31 +593,10 @@ async function handleExecutePost(
userId = wf.userId
isPublicApiAccess = true
- gateWorkspaceId = wf.workspaceId
} else {
userId = auth.userId
}
- // Programmatic execution (API key or public API) is gated on the workflow's
- // workspace billed account — the same entity MCP/webhooks/chat gate on —
- // so a paid workspace is never blocked because an individual is on free.
- if (auth.authType === AuthType.API_KEY || isPublicApiAccess) {
- if (!gateWorkspaceId) {
- const [wfRow] = await db
- .select({ workspaceId: workflowTable.workspaceId })
- .from(workflowTable)
- .where(eq(workflowTable.id, workflowId))
- .limit(1)
- gateWorkspaceId = wfRow?.workspaceId ?? undefined
- }
- if (!(await isWorkspaceApiExecutionEntitled(gateWorkspaceId))) {
- return NextResponse.json(
- { error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE },
- { status: 402 }
- )
- }
- }
-
let body: any = {}
try {
body = await readExecuteRequestBody(req)
diff --git a/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts b/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts
deleted file mode 100644
index 2ae727ae92b..00000000000
--- a/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * @vitest-environment node
- */
-import { createMockRequest } from '@sim/testing'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const { mockGetSession, mockGetUserEntityPermissions, mockGetWorkspaceOwnerSubscriptionAccess } =
- vi.hoisted(() => ({
- mockGetSession: vi.fn(),
- mockGetUserEntityPermissions: vi.fn(),
- mockGetWorkspaceOwnerSubscriptionAccess: vi.fn(),
- }))
-
-vi.mock('@/lib/auth', () => ({
- auth: { api: { getSession: vi.fn() } },
- getSession: mockGetSession,
-}))
-
-vi.mock('@/lib/workspaces/permissions/utils', () => ({
- getUserEntityPermissions: mockGetUserEntityPermissions,
-}))
-
-vi.mock('@/lib/billing/core/workspace-access', () => ({
- getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess,
-}))
-
-import { GET } from '@/app/api/workspaces/[id]/owner-billing/route'
-
-const WORKSPACE_ID = 'ws-1'
-
-const PAID_ACCESS = {
- plan: 'team_25000',
- status: 'active',
- isPaid: true,
- isPro: false,
- isTeam: true,
- isEnterprise: false,
- isOrgScoped: true,
- organizationId: 'org-1',
-}
-
-function buildParams() {
- return { params: Promise.resolve({ id: WORKSPACE_ID }) }
-}
-
-async function callGet() {
- const request = createMockRequest('GET')
- const response = await GET(request, buildParams())
- return { status: response.status, body: await response.json() }
-}
-
-describe('GET /api/workspaces/[id]/owner-billing', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockGetSession.mockResolvedValue({ user: { id: 'u-1' } })
- mockGetUserEntityPermissions.mockResolvedValue('read')
- mockGetWorkspaceOwnerSubscriptionAccess.mockResolvedValue(PAID_ACCESS)
- })
-
- it('returns 401 when unauthenticated', async () => {
- mockGetSession.mockResolvedValue(null)
- const { status } = await callGet()
- expect(status).toBe(401)
- expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
- })
-
- it('returns 404 when the caller has no workspace access', async () => {
- mockGetUserEntityPermissions.mockResolvedValue(null)
- const { status } = await callGet()
- expect(status).toBe(404)
- expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
- })
-
- it('returns the workspace owner subscription access for a member', async () => {
- const { status, body } = await callGet()
- expect(status).toBe(200)
- expect(body).toEqual(PAID_ACCESS)
- expect(mockGetWorkspaceOwnerSubscriptionAccess).toHaveBeenCalledWith(WORKSPACE_ID)
- })
-})
diff --git a/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts b/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts
deleted file mode 100644
index 6766c3351a4..00000000000
--- a/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { NextRequest } from 'next/server'
-import { NextResponse } from 'next/server'
-import { getWorkspaceOwnerBillingContract } from '@/lib/api/contracts/workspaces'
-import { parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-
-/**
- * Subscription access state of the workspace's billed account — the workspace-
- * scoped counterpart to the viewer `/api/billing`. Lets the UI gate workspace
- * features (e.g. the deploy modal) on the owner's plan rather than the viewer's,
- * so a free member of a paid workspace isn't shown an upgrade wall.
- */
-export const GET = withRouteHandler(
- async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(getWorkspaceOwnerBillingContract, req, context)
- if (!parsed.success) return parsed.response
- const { id: workspaceId } = parsed.data.params
-
- const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
- if (!permission) {
- return NextResponse.json({ error: 'Not found' }, { status: 404 })
- }
-
- const ownerAccess = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
- return NextResponse.json(ownerAccess)
- }
-)
diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts
index 1bd7a26efa5..b468d883742 100644
--- a/apps/sim/app/sitemap.ts
+++ b/apps/sim/app/sitemap.ts
@@ -59,6 +59,21 @@ export default async function sitemap(): Promise {
{
url: `${baseUrl}/workflows`,
},
+ {
+ url: `${baseUrl}/knowledge`,
+ },
+ {
+ url: `${baseUrl}/tables`,
+ },
+ {
+ url: `${baseUrl}/files`,
+ },
+ {
+ url: `${baseUrl}/logs`,
+ },
+ {
+ url: `${baseUrl}/scheduled-tasks`,
+ },
{
url: `${baseUrl}/pricing`,
},
@@ -89,6 +104,9 @@ export default async function sitemap(): Promise {
{
url: `${baseUrl}/solutions/it`,
},
+ {
+ url: `${baseUrl}/solutions/sales`,
+ },
{
url: `${baseUrl}/blog`,
lastModified: latestPostDateValue,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index 1283dc8617e..f5ffd95f765 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -12,14 +12,16 @@ import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
import '@sim/emcn/components/code/code.css'
import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn'
+import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
+import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import {
type ContentSegment,
PendingTagIndicator,
parseSpecialTags,
SpecialTags,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
-import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
+import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { useSmoothText } from '@/hooks/use-smooth-text'
import { sanitizeChatDisplayContent } from './chat-sanitize'
@@ -132,6 +134,31 @@ function appendInlineReferenceMarkdown(
type TdProps = ComponentPropsWithoutRef<'td'>
type ThProps = ComponentPropsWithoutRef<'th'>
+/**
+ * Maps a `#wsres-{type}-{ref}` link's resource type to the chat-context kind
+ * whose icon represents it, so inline resource references render the same
+ * type icon as the user-input context chips.
+ */
+const WSRES_LINK_KINDS: Record = {
+ workflow: 'workflow',
+ table: 'table',
+ file: 'file',
+}
+
+/**
+ * Label used to pick a file link's extension-aware document icon. The visible
+ * link text can be a custom title without an extension, so prefer the file
+ * name carried in the link's VFS path (its last extension-bearing segment).
+ */
+function fileIconLabel(ref: string, fallback: string): string {
+ const segments = ref.split('/').filter(Boolean)
+ for (let i = segments.length - 1; i >= 0; i--) {
+ const decoded = decodeVfsSegmentSafe(segments[i])
+ if (decoded.includes('.')) return decoded
+ }
+ return fallback
+}
+
const MARKDOWN_COMPONENTS = {
table({ children }: { children?: React.ReactNode }) {
return (
@@ -202,28 +229,40 @@ const MARKDOWN_COMPONENTS = {
},
a({ children, href }: { children?: React.ReactNode; href?: string }) {
if (href?.startsWith('#wsres-')) {
+ const match = href.match(/^#wsres-(\w+)-(.+)$/)
+ const type = match?.[1]
+ const ref = match?.[2]
+ const kind = type ? WSRES_LINK_KINDS[type] : undefined
+ const label = extractTextContent(children)
return (
{
e.preventDefault()
- const match = href.match(/^#wsres-(\w+)-(.+)$/)
- if (match) {
- const type = match[1]
- const ref = match[2]
- const linkText = e.currentTarget.textContent || ref
- window.dispatchEvent(
- new CustomEvent('wsres-click', {
- detail:
- type === 'file'
- ? { type, path: ref, title: linkText }
- : { type, id: ref, title: linkText },
- })
- )
- }
+ if (!type || !ref) return
+ const linkText = label || ref
+ window.dispatchEvent(
+ new CustomEvent('wsres-click', {
+ detail:
+ type === 'file'
+ ? { type, path: ref, title: linkText }
+ : { type, id: ref, title: linkText },
+ })
+ )
}}
>
+ {kind && ref && (
+
+ )}
{children}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
index 6ca5964d8c4..d7ecd79e36e 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
@@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
+import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors'
import {
blockTypeToIconMap,
type Integration,
@@ -87,6 +88,15 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
}, [isSlackBot, blockOverlayVersion])
const hasServiceAccount =
Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden
+ // Vendor-accurate connect label: token-paste providers use their own noun
+ // ("Add API key", "Add private app token"); only true service-account
+ // providers (Google, Atlassian) say "Add service account".
+ const tokenDescriptor = getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId)
+ const serviceAccountConnectLabel = isSlackBot
+ ? 'Set up a custom bot'
+ : tokenDescriptor
+ ? `Add ${tokenDescriptor.connectNoun}`
+ : 'Add service account'
const hasHandledConnectQueryRef = useRef(false)
useEffect(() => {
@@ -116,7 +126,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
},
{
value: CONNECT_MODE.serviceAccount,
- label: isSlackBot ? 'Set up a custom bot' : 'Add service account',
+ label: serviceAccountConnectLabel,
icon: oauthService.serviceIcon,
},
]
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx
index 16375108878..6c8f6722601 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx
@@ -14,10 +14,15 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isApiClientError } from '@/lib/api/client/errors'
import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials'
+import {
+ getTokenServiceAccountDescriptor,
+ type TokenServiceAccountProviderId,
+} from '@/lib/credentials/token-service-accounts/descriptors'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
+import { TokenServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal'
import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
import {
useCreateWorkspaceCredential,
@@ -32,6 +37,7 @@ export type ServiceAccountProviderId =
| typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
| typeof SLACK_CUSTOM_BOT_PROVIDER_ID
+ | TokenServiceAccountProviderId
/** Sim setup guides for each provider, docked bottom-left of each modal. */
const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account'
@@ -121,6 +127,22 @@ export function ConnectServiceAccountModal({
credentialDisplayName,
credentialDescription,
}: ConnectServiceAccountModalProps) {
+ const tokenDescriptor = getTokenServiceAccountDescriptor(serviceAccountProviderId)
+ if (tokenDescriptor) {
+ return (
+
+ )
+ }
if (serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
return (
void
+ workspaceId: string
+ descriptor: TokenServiceAccountDescriptor
+ serviceName: string
+ serviceIcon: ComponentType<{ className?: string }>
+ /** When set, reconnect (rotate the secret on) this credential in place. */
+ credentialId?: string
+ initialDisplayName?: string
+ initialDescription?: string
+}
+
+/**
+ * Generic connect modal for token-paste service accounts. Renders the fields
+ * declared by the provider's {@link TokenServiceAccountDescriptor} (a secret
+ * token, plus a domain for providers that need one) and submits through the
+ * same create/update credential mutations as the other service-account modals.
+ * Server-side verification failures are mapped from the route's `error.code`.
+ */
+export function TokenServiceAccountModal({
+ open,
+ onOpenChange,
+ workspaceId,
+ descriptor,
+ serviceName,
+ serviceIcon: ServiceIcon,
+ credentialId,
+ initialDisplayName,
+ initialDescription,
+}: TokenServiceAccountModalProps) {
+ const [apiToken, setApiToken] = useState('')
+ const [domain, setDomain] = useState('')
+ const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
+ const [description, setDescription] = useState(initialDescription ?? '')
+ const [error, setError] = useState(null)
+
+ const createCredential = useCreateWorkspaceCredential()
+ const updateCredential = useUpdateWorkspaceCredential()
+
+ useEffect(() => {
+ if (open) return
+ setApiToken('')
+ setDomain('')
+ setDisplayName(initialDisplayName ?? '')
+ setDescription(initialDescription ?? '')
+ setError(null)
+ }, [open, initialDisplayName, initialDescription])
+
+ const tokenField = descriptor.fields.find((field) => field.id === 'apiToken')
+ const domainField = descriptor.fields.find((field) => field.id === 'domain')
+
+ const trimmedToken = apiToken.trim()
+ const normalizedDomain = normalizeDomainInput(domain)
+ const isPending = createCredential.isPending || updateCredential.isPending
+ const isDisabled = !trimmedToken || (Boolean(domainField) && !normalizedDomain) || isPending
+
+ const hintFor = (field: TokenServiceAccountField, value: string): string | undefined => {
+ if (!field.hintPattern || !field.hintMessage || value.length === 0) return undefined
+ return field.hintPattern.test(value) ? undefined : field.hintMessage
+ }
+
+ const handleSubmit = async () => {
+ setError(null)
+ if (isDisabled) return
+ try {
+ const secretFields = {
+ apiToken: trimmedToken,
+ ...(domainField ? { domain: normalizedDomain } : {}),
+ }
+ if (credentialId) {
+ await updateCredential.mutateAsync({
+ credentialId,
+ ...secretFields,
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ })
+ } else {
+ await createCredential.mutateAsync({
+ workspaceId,
+ type: 'service_account',
+ providerId: descriptor.providerId,
+ ...secretFields,
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ })
+ }
+ onOpenChange(false)
+ } catch (err: unknown) {
+ setError(messageForTokenAccountError(err, descriptor))
+ logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err)
+ }
+ }
+
+ return (
+
+ onOpenChange(false)}>
+ Add {serviceName} {descriptor.connectNoun}
+
+
+ {tokenField && (
+
+ {
+ setApiToken(value)
+ if (error) setError(null)
+ }}
+ placeholder={tokenField.placeholder}
+ name={`${descriptor.providerId}_api_token`}
+ autoComplete='new-password'
+ autoCorrect='off'
+ autoCapitalize='off'
+ data-lpignore='true'
+ data-form-type='other'
+ />
+
+ )}
+
+ {domainField && (
+ {
+ setDomain(value)
+ if (error) setError(null)
+ }}
+ placeholder={domainField.placeholder}
+ autoComplete='off'
+ required
+ error={hintFor(domainField, normalizedDomain)}
+ />
+ )}
+
+
+
+
+
+ {error}
+
+ onOpenChange(false)}
+ secondaryActions={[
+ {
+ label: 'Setup guide',
+ onClick: () => openDocs(descriptor.docsUrl),
+ },
+ ]}
+ primaryAction={{
+ label: isPending ? 'Adding...' : `Add ${descriptor.connectNoun}`,
+ onClick: handleSubmit,
+ disabled: isDisabled,
+ }}
+ />
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts
index a07ca838a1b..e96410422a5 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts
@@ -144,7 +144,7 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [
},
{
label: 'API endpoint',
- values: ['0', '100', '200', 'Custom'],
+ values: ['30', '100', '200', 'Custom'],
},
],
},
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
index 4a524b30fe0..7e6558af26a 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
@@ -29,7 +29,7 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
export const PRO_PLAN_FEATURES: readonly string[] = [
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
- 'Deploy workflows as APIs',
+ 'Higher rate limits',
'Extended run timeouts',
'More storage & tables',
]
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx
deleted file mode 100644
index 8f580283a51..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-'use client'
-
-import { ChipLink } from '@sim/emcn'
-import { useQueryClient } from '@tanstack/react-query'
-import { ArrowRight } from 'lucide-react'
-import { useParams, useRouter } from 'next/navigation'
-import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
-import { prefetchUpgradeBillingData } from '@/hooks/queries/subscription'
-import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
-
-interface DeployUpgradeGateProps {
- feature: 'API' | 'MCP'
-}
-
-export function DeployUpgradeGate({ feature }: DeployUpgradeGateProps) {
- const router = useRouter()
- const queryClient = useQueryClient()
- const { workspaceId } = useParams<{ workspaceId: string }>()
- const upgradeHref = buildUpgradeHref(workspaceId)
-
- // Warm the upgrade route + the queries it gates on so the click lands on
- // cached data. ChipLink isn't memoized, so no useCallback is needed.
- const prefetchUpgrade = () => {
- router.prefetch(upgradeHref)
- prefetchUpgradeBillingData(queryClient)
- prefetchWorkspaceSettings(queryClient, workspaceId)
- }
-
- return (
-
-
-
- {feature} deployment requires a paid plan
-
-
- {feature} deployment lets external apps run this workflow programmatically. Upgrade to Pro
- or higher to enable it.
-
-
-
- Explore plans
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts
deleted file mode 100644
index a124dede56d..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { DeployUpgradeGate } from './deploy-upgrade-gate'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts
index 630f2b53a5a..be8c8f199dd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts
@@ -1,5 +1,4 @@
export { ApiDeploy } from './api'
export { ChatDeploy, type ExistingChat } from './chat'
-export { DeployUpgradeGate } from './deploy-upgrade-gate'
export { GeneralDeploy } from './general'
export { McpDeploy } from './mcp'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
index 8d6d4f46ea2..68178c9f3c4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type ReactNode, useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import {
Badge,
Button,
@@ -22,7 +22,6 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -47,38 +46,18 @@ import {
} from '@/hooks/queries/deployments'
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
import { useWorkflowMap } from '@/hooks/queries/workflows'
-import { useWorkspaceOwnerBilling, useWorkspaceSettings } from '@/hooks/queries/workspace'
+import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
-import {
- ApiDeploy,
- ChatDeploy,
- DeployUpgradeGate,
- type ExistingChat,
- GeneralDeploy,
- McpDeploy,
-} from './components'
+import { ApiDeploy, ChatDeploy, type ExistingChat, GeneralDeploy, McpDeploy } from './components'
import { ApiInfoModal } from './components/general/components/api-info-modal'
const logger = createLogger('DeployModal')
-/** Renders the upgrade prompt in place of a programmatic-deploy tab when gated. */
-function GatedTabContent({
- gated,
- feature,
- children,
-}: {
- gated: boolean
- feature: 'API' | 'MCP'
- children: ReactNode
-}) {
- return gated ? : <>{children}>
-}
-
interface DeployModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -153,16 +132,6 @@ export function DeployModal({
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig()
- // Gate on the WORKSPACE owner's plan (billed account, rolled up), not the
- // viewer's individual plan, so a free member of a paid workspace isn't shown
- // the upgrade wall. Keyed on the URL `workspaceId` (available on mount). Uses
- // `isPaid` — the same check the server gate runs (any paid plan in an entitled
- // status, incl. `past_due`) — rather than `hasUsablePaidAccess`, which would
- // reject `past_due`/billing-blocked owners the API still allows. While loading
- // the data is undefined → gate stays closed (no flash); only a resolved,
- // non-paid owner gates.
- const { data: ownerBilling } = useWorkspaceOwnerBilling(workspaceId ?? undefined)
- const gateProgrammaticDeploy = isBillingEnabled && !!ownerBilling && !ownerBilling.isPaid
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
workflowWorkspaceId || ''
@@ -581,17 +550,15 @@ export function DeployModal({
-
-
-
+
@@ -611,22 +578,20 @@ export function DeployModal({
-
- {workflowId && (
-
- )}
-
+ {workflowId && (
+
+ )}
@@ -646,7 +611,7 @@ export function DeployModal({
}}
/>
)}
- {activeTab === 'api' && !gateProgrammaticDeploy && (
+ {activeTab === 'api' && (
@@ -698,7 +663,7 @@ export function DeployModal({
)}
- {activeTab === 'mcp' && !gateProgrammaticDeploy && isDeployed && hasMcpServers && (
+ {activeTab === 'mcp' && isDeployed && hasMcpServers && (
diff --git a/apps/sim/content/authors/andrew.json b/apps/sim/content/authors/andrew.json
new file mode 100644
index 00000000000..3b108522492
--- /dev/null
+++ b/apps/sim/content/authors/andrew.json
@@ -0,0 +1,5 @@
+{
+ "id": "andrew",
+ "name": "Andrew Caslow",
+ "avatarUrl": "/authors/andrew.jpg"
+}
diff --git a/apps/sim/content/blog/copilot/index.mdx b/apps/sim/content/blog/copilot/index.mdx
index 5e65575549d..7ea345209ed 100644
--- a/apps/sim/content/blog/copilot/index.mdx
+++ b/apps/sim/content/blog/copilot/index.mdx
@@ -15,6 +15,19 @@ timeRequired: PT7M
canonical: https://www.sim.ai/blog/copilot
featured: false
draft: true
+faq:
+ - q: "What is Sim Copilot?"
+ a: "Copilot is a context-aware assistant embedded directly in the Sim editor. It has first-class access, with user approval, to your workflows, blocks, logs, and docs, and the system is retrieval-centric with strong guardrails and deterministic execution paths."
+ - q: "How does Copilot retrieve context from my workflows?"
+ a: "Copilot indexes workspace workflows, block metadata, execution logs, and product docs using hybrid scoring that combines BM25 lexical search with embeddings, plus recency decay and per-source caps. Content is chunked with stable anchors so proposed diffs stay line-referential."
+ - q: "Can Copilot make changes to my workflows automatically?"
+ a: "No. All writes are gated behind explicit user approval in a human-in-the-loop model: diffs are reviewed and applied only on approval. Actions are also scope-limited to the open workspace and require explicit user triggers."
+ - q: "What kinds of actions can Copilot take beyond answering questions?"
+ a: "Copilot's tooling spans read-only tools like explain, compare, and search; propose-changes tools that generate diffs; and execution tools for approved actions. It can also help set up test runs and compare outputs side by side."
+ - q: "How accurate is Copilot at proposing fixes or generating block configurations?"
+ a: "The post is explicit that this section is a benchmark scaffold: the listed numbers, including 92% top-1 retrieval and 88% edit accuracy for explaining a workflow block, versus 74% retrieval and 65% edit accuracy for generating a new block configuration, are described as placeholders for the table's structure rather than results from completed benchmark runs. Sim plans to publish a full benchmark harness with anonymized evaluation sets and a detailed methodology post."
+ - q: "Does Copilot use the same permissions as the rest of Sim?"
+ a: "Yes. Copilot works with the same permissions model already used in Sim, and it enforces scope so actions stay limited to the open workspace, with sensitive data policies and redaction applied in logs."
---
> This is a technical deep‑dive scaffold for Sim Copilot. We’ll keep updating it as we publish more results and open up additional capabilities.
diff --git a/apps/sim/content/blog/emcn/index.mdx b/apps/sim/content/blog/emcn/index.mdx
index 8b427baf27d..bb0366d605d 100644
--- a/apps/sim/content/blog/emcn/index.mdx
+++ b/apps/sim/content/blog/emcn/index.mdx
@@ -15,6 +15,19 @@ timeRequired: PT6M
canonical: https://www.sim.ai/blog/emcn
featured: false
draft: true
+faq:
+ - q: "What is Emcn?"
+ a: "Emcn is the design system that powers Sim's product and brand. It combines strongly-typed, themeable tokens with a composable component library to keep UI consistent and accessible across web surfaces."
+ - q: "Is Emcn open source?"
+ a: "Sim plans to share Emcn's foundations and many of its components as part of its broader open-source commitment. A public docs and examples site is planned for a later release stage."
+ - q: "What accessibility standard does Emcn target?"
+ a: "Emcn is built to be accessible by default at WCAG AA and above, with color contrast tracked at the token level and keyboard and screen reader interactions defined per component."
+ - q: "Does Emcn support dark mode?"
+ a: "Yes. Emcn ships light and dark themes with brand accent tokens, plus an SSR-safe color mode designed to avoid a flash on hydration."
+ - q: "How does Emcn connect Figma and code?"
+ a: "Emcn's Figma library is mapped one-to-one to code components, and its tokens are exported as both TypeScript and CSS variables, giving a direct path from design files to implementation."
+ - q: "What components does Emcn include so far?"
+ a: "The initial set spans primitives like Button, Input, and Select, navigation elements like NavBar and Tabs, feedback patterns like Toast and Dialog, layout building blocks like Grid and Card, and content components like CodeBlock and Table."
---
> This post is the scaffolding for Emcn, our new design system. We’ll fill it in as we publish the full documentation and component gallery.
@@ -80,14 +93,6 @@ Emcn is the design system that powers Sim’s product and brand. It aims to give
- v1: Public docs and examples site
- v1.x: Data display, advanced forms, charts bridge
-## FAQ
-
-- What does “Emcn” mean?
- A short, crisp name we liked—easy to type and remember.
-
-- Will Emcn be open‑sourced?
- We plan to share the foundations and many components as part of our commitment to open source.
-
## We’re hiring
We’re hiring designers and engineers who care deeply about craft and DX. If you want to help shape Emcn and Sim’s product, we’d love to talk.
diff --git a/apps/sim/content/blog/enterprise/index.mdx b/apps/sim/content/blog/enterprise/index.mdx
index 59fe8b32040..42123bacc5b 100644
--- a/apps/sim/content/blog/enterprise/index.mdx
+++ b/apps/sim/content/blog/enterprise/index.mdx
@@ -15,6 +15,19 @@ timeRequired: PT10M
canonical: https://www.sim.ai/blog/enterprise
featured: true
draft: false
+faq:
+ - q: "Can Sim be deployed fully on-premises or in an air-gapped environment?"
+ a: "Yes. Sim can run entirely on your infrastructure via Docker Compose or Helm charts for Kubernetes, with the application, WebSocket server, and PostgreSQL database staying inside your network. Air-gapped setups are supported by pairing self-hosting with Ollama or vLLM for local model inference, so no external network access is required."
+ - q: "Does using our own LLM API keys (BYOK) keep our data from passing through Sim's servers?"
+ a: "Yes. When you configure your own API keys for providers like OpenAI, Anthropic, Google, Azure OpenAI, or AWS Bedrock, prompts and completions route directly between Sim and that provider without passing through Sim's infrastructure. BYOK is available to everyone, not just enterprise plans, and is the default with no Sim-managed keys involved in self-hosted deployments."
+ - q: "Can Copilot be used without sending workflow data to an external AI service?"
+ a: "Yes. Copilot can run entirely within a self-hosted deployment using your own LLM keys, so prompts containing context from your workflows, execution logs, and workspace configuration route directly to your chosen provider and never leave your network."
+ - q: "What identity providers does Sim support for SSO, and what happens when an employee is deprovisioned?"
+ a: "Sim integrates with Okta, Azure AD (Entra ID), Google Workspace, OneLogin, Auth0, JumpCloud, Ping Identity, ADFS, and any SAML 2.0 or OIDC compliant identity provider. Session management ties to your IdP, so logging out there terminates Sim sessions, and account deprovisioning immediately revokes access."
+ - q: "Is Sim SOC 2 certified, and can we get a copy of the report for vendor review?"
+ a: "Sim maintains SOC 2 Type II certification with annual audits covering security, availability, and confidentiality controls, and shares the report directly with prospective customers under NDA. Sim also provides penetration test reports, architecture documentation, and completed security questionnaires (SIG, CAIQ, and custom formats) for vendor review."
+ - q: "Can we restrict which model providers or integrations are available to certain teams?"
+ a: "Yes, through permission groups that are enforced at the execution layer, not just hidden in the UI. Administrators can allowlist approved model providers so unapproved ones fail to execute, disable specific workflow blocks like HTTP calls, and block integrations that haven't cleared security review — while users outside any permission group keep full access by default."
---
We've been working with security teams at larger organizations to bring Sim into environments with strict compliance and data handling requirements. This post covers the enterprise capabilities we've built: granular access control, bring-your-own-keys, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, compliance, and programmatic management via the Admin API.
diff --git a/apps/sim/content/blog/executor/index.mdx b/apps/sim/content/blog/executor/index.mdx
index 634bb334dca..5541c73f2a4 100644
--- a/apps/sim/content/blog/executor/index.mdx
+++ b/apps/sim/content/blog/executor/index.mdx
@@ -15,6 +15,19 @@ timeRequired: PT12M
canonical: https://www.sim.ai/blog/executor
featured: false
draft: false
+faq:
+ - q: "How does Sim's executor handle true parallel execution instead of just interleaving branches?"
+ a: "The executor uses an event-driven ready queue: when a parallel block expands into branch-indexed nodes, all entry nodes across every branch enter the queue simultaneously and launch concurrently rather than one after another. Sim doesn't distinguish parallel branches from any other independent nodes that happen to be ready at the same time — concurrency simply emerges from the DAG structure."
+ - q: "How does Sim implement loops in a DAG, which is acyclic by definition?"
+ a: "Sim expands each loop into a sentinel start and sentinel end node during compilation. The backward edge from end to start doesn't count as a dependency initially — it only activates if the loop condition says to continue — which preserves the DAG's acyclic property while still allowing iteration."
+ - q: "Can a Sim workflow pause for human approval in the middle of a loop or across parallel branches?"
+ a: "Yes. When a block returns pause metadata, the executor stops processing its outgoing edges and captures the full execution state, including loop iteration and parallel branch progress. Each pause point gets a unique context ID based on its position, so multiple simultaneous pauses (e.g. three parallel branches each hitting an approval block) can each resume independently."
+ - q: "What gets saved when a Sim workflow pauses, and is it enough to resume later without losing state?"
+ a: "The snapshot serializes block states, execution logs, loop and parallel scopes, routing decisions, and workflow variables to JSON. The critical piece is the DAG's incoming edge state — which dependencies each node still has outstanding — so partially completed loops and in-flight parallel branches can be resumed exactly where they left off."
+ - q: "How does AI-driven routing (a router block choosing a path) stay deterministic and debuggable in Sim?"
+ a: "When a router block returns its chosen block ID, the edge manager activates only the matching outgoing edge and deactivates all others, with deactivation cascading recursively to unreachable downstream blocks. This means you can inspect the execution log afterward and see exactly which path the model chose and which alternatives were pruned."
+ - q: "How does the Sim executor know when a block is ready to run without re-scanning all connections?"
+ a: "Each node tracks its own incoming edges in a set; when a dependency completes, one element is removed from that set, and the node becomes ready the moment the set hits zero. This replaced an earlier approach that rescanned the full connection array after every block execution, which degraded as the graph grew larger."
---
Modern workflows aren't just linear automations anymore. They involve a variety of APIs and services, loop over a model's output, pause for human decisions, and resume hours or days later exactly where they left off.
diff --git a/apps/sim/content/blog/mothership/index.mdx b/apps/sim/content/blog/mothership/index.mdx
index 1e978e5db87..937f2846e95 100644
--- a/apps/sim/content/blog/mothership/index.mdx
+++ b/apps/sim/content/blog/mothership/index.mdx
@@ -15,6 +15,19 @@ timeRequired: PT10M
canonical: https://www.sim.ai/blog/mothership
featured: true
draft: false
+faq:
+ - q: "What is Mothership in Sim?"
+ a: "Mothership is the control plane for a Sim workspace—an orchestrator with full context over your workflows, tables, knowledge bases, files, and connected integrations. It can build, test, and deploy workflows from a plain-English description and call any of your 100+ integration tools directly."
+ - q: "Can I trigger Mothership by email?"
+ a: "Yes. Enabling a Sim inbox lets Mothership read incoming emails, execute tasks using your integrations and data, and reply—turning your workspace into an email-driven automation layer."
+ - q: "What is Tables in Sim and how does it work with agents?"
+ a: "Tables gives your workspace a built-in database with typed columns (string, number, boolean, date, JSON) that agents can query, insert into, and update over time. It acts as persistent memory, with 10+ operations available as workflow blocks and direct management by Mothership."
+ - q: "How do Knowledge Base Connectors ground agent responses?"
+ a: "Connectors sync documents from sources like Google Docs, Notion, Confluence, Slack, GitHub, Jira, Linear, HubSpot, Salesforce, Zendesk, Dropbox, OneDrive, Gmail, Discord, and 15+ more, automatically chunking, embedding, and indexing them with incremental sync to stay current. This produces a vector-searchable knowledge base that any workflow block or Mothership can search directly, grounding responses in real business context instead of generic answers."
+ - q: "What's the difference between persistent and until_complete Scheduled Tasks?"
+ a: "Persistent tasks run indefinitely on a set schedule, while until_complete tasks poll repeatedly until a defined success condition is met and then stop automatically. Both support configurable max runs, 40+ timezones, and automatic disabling after 3 consecutive failures."
+ - q: "Can I upload a CSV file and turn it into a queryable table?"
+ a: "Yes. Files uploaded as CSV can be imported directly into Tables—Sim auto-detects delimiters, infers column types, and batch-inserts up to 1,000 rows, turning a raw export into a workflow-ready table in one click."
---
I often wonder why AI agents don't already do everything for me today. Why don't they take my meetings for me? Why can't they send follow-ups to customers? Why can't they track the progress of our product launches and just start solving problems for us?
diff --git a/apps/sim/content/blog/multiplayer/index.mdx b/apps/sim/content/blog/multiplayer/index.mdx
index e8259aef14f..f45096bc7a5 100644
--- a/apps/sim/content/blog/multiplayer/index.mdx
+++ b/apps/sim/content/blog/multiplayer/index.mdx
@@ -11,6 +11,19 @@ tags: [Multiplayer, Realtime, Collaboration, WebSockets, Architecture]
ogImage: /blog/multiplayer/cover.jpg
canonical: https://www.sim.ai/blog/multiplayer
draft: false
+faq:
+ - q: "Does Sim use CRDTs or operational transforms for multiplayer editing?"
+ a: "No. Sim uses a simpler last-writer-wins strategy with client-generated timestamps rather than Figma-style operational transforms. Workflow building has lower conflict density than text editing, since users typically work on different blocks or parts of the canvas, so the simpler approach is sufficient."
+ - q: "What happens if two users edit the same block at the same time in Sim?"
+ a: "Both clients render their local changes optimistically, then the server persists both updates based on client timestamps and broadcasts each in sequence. Clients receive the conflicting positions or values and converge to whichever carries the latest timestamp, with value conflicts on the same text field resolved by last-to-arrive within a 25ms server-side coalescing window."
+ - q: "What happens if a collaborator deletes a block I'm currently editing?"
+ a: "When the server broadcasts the deletion, your client immediately cancels all pending operations targeting that block, including subblock updates, and removes it from local state without sending those stale operations to the server or showing an error. This client-side cancellation avoids wasting network requests on operations that would fail anyway."
+ - q: "How does undo/redo work when multiple people are editing the same Sim workflow?"
+ a: "Undo/redo is a local, per-user stack: undoing an action generates an inverse operation (e.g., undo an add becomes a remove) that flows through the same operation queue as a normal edit, so collaborators see the change in real time. Undo stacks are per-user and only cover your own actions, and entries referencing entities another collaborator deleted are automatically pruned as invalid."
+ - q: "What happens if a client loses connection while editing a Sim workflow?"
+ a: "Failed operations retry with exponential backoff — structural changes get 3 attempts and text edits get 5. If all retries fail, the queue is cleared and the UI becomes read-only until the user manually refreshes."
+ - q: "Why does Sim separate blocks, edges, and subflows into different database tables?"
+ a: "Blocks, edges, and subflows have different update patterns and conflict characteristics, so storing them in separate normalized tables (workflow_blocks, workflow_edges, workflow_subflows) lets Sim make targeted updates, such as moving a block only touching its position fields, without loading or locking the entire workflow."
---
When we started building Sim, we noticed that AI workflow development looked a lot like the design process [Figma](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/) had already solved for. Product managers need to sketch out user-facing flows, engineers need to configure integrations and APIs, and domain experts need to validate business logic—often all at the same time. Traditional workflow builders force serial collaboration: one person edits, saves, exports, and notifies the next person. This creates unnecessary friction.
diff --git a/apps/sim/content/blog/series-a/index.mdx b/apps/sim/content/blog/series-a/index.mdx
index 4d75ed6408e..85e7d6d272e 100644
--- a/apps/sim/content/blog/series-a/index.mdx
+++ b/apps/sim/content/blog/series-a/index.mdx
@@ -17,6 +17,19 @@ canonical: https://www.sim.ai/blog/series-a
featured: true
draft: false
technical: false
+faq:
+ - q: "How much did Sim raise in its Series A?"
+ a: "Sim raised a $7M Series A. The round was led by Standard Capital, with participation from Perplexity Fund, SV Angel, YCombinator, and angels including Paul Graham, Paul Bucheit, Ali Rowghani, and Kaz Nejatian."
+ - q: "Who led Sim's Series A funding round?"
+ a: "Standard Capital led the round, joined by Perplexity Fund, SV Angel, YCombinator, and several notable angel investors."
+ - q: "What will Sim do with the new funding?"
+ a: "Sim is using the funding to double down on its mission: making it simple for teams to build, ship, and scale agentic workflows in production. Separately, Sim plans to keep investing in building the community around the platform."
+ - q: "How many developers and companies use Sim?"
+ a: "Sim has grown to 60,000+ developers on the platform and over 100 companies, ranging from startups to large enterprises, using it in production. The project has also processed more than 10M workflows and reached 18,000 GitHub stars."
+ - q: "Is Sim an open-source project?"
+ a: "Yes. Sim was built to be open source from day one, and that commitment has continued as the project has grown."
+ - q: "What is Sim's long-term vision for agentic workflows?"
+ a: "Sim aims to provide the infrastructure and UX that make agentic software practical at scale, from prototyping to production and from single-agent flows to complex multi-agent systems. Its goal is to be both easy to use and powerful enough to build complex agentic workflows, unlike frameworks that require heavy coding or platforms that limit what can be built."
---

diff --git a/apps/sim/content/blog/v0-5/index.mdx b/apps/sim/content/blog/v0-5/index.mdx
index 13604697eb3..e2297417528 100644
--- a/apps/sim/content/blog/v0-5/index.mdx
+++ b/apps/sim/content/blog/v0-5/index.mdx
@@ -15,6 +15,19 @@ timeRequired: PT8M
canonical: https://www.sim.ai/blog/v0-5
featured: true
draft: false
+faq:
+ - q: "What is new in Sim v0.5?"
+ a: "Sim v0.5 adds a context-aware Copilot, MCP server deployment for any workflow, full execution logs with cost attribution, realtime multi-user collaboration, workflow versioning with rollback, and 100+ integrations spanning 300+ actions."
+ - q: "How does Sim's Copilot differ from a general-purpose AI assistant?"
+ a: "Copilot is embedded in the Sim editor and has direct access to your workspace: workflows, block configurations, execution logs, connected credentials, and documentation. It uses hybrid retrieval over an index of your workspace to ground answers in your actual workflow state, and supports slash commands like /fast, /research, and /actions for specialized tasks."
+ - q: "Can I turn a Sim workflow into an MCP server?"
+ a: "Yes. Any workflow can be deployed as an MCP server, and it becomes a callable tool for MCP-compatible agents like Claude Desktop or Cursor. Sim auto-generates the tool definition and JSON schema from your Start block, uses Streamable HTTP transport, and supports authentication via API key headers or public access."
+ - q: "What kind of observability does Sim provide for workflow executions?"
+ a: "Every execution generates a full trace capturing each block's start/end time, inputs, outputs, and errors, plus token usage and cost per LLM call. The dashboard surfaces this as trace spans, cost attribution, error context with stack traces, filterable queries, and execution snapshots you can restore to see the exact workflow state at run time."
+ - q: "Does Sim support real-time collaboration on workflows?"
+ a: "Yes. Multiple users can edit the same workflow at the same time, with cursors, block additions, and configuration changes syncing live across all connected clients. The editor also adds full undo/redo history and copy-paste for individual blocks, groups, or entire subflows."
+ - q: "How many integrations does Sim v0.5 support, and how is versioning handled?"
+ a: "v0.5 adds 100+ integrations with 300+ actions covering CRMs, communication tools, databases, developer tools, and more. Every deployment also creates a new version, and you can roll back to any previous version with one click, with a full audit trail of who deployed what and when."
---
**Sim v0.5** is the next evolution of our agent workflow platform—built for teams shipping AI agents to production.
diff --git a/apps/sim/content/library/ai-agent-ideas/index.mdx b/apps/sim/content/library/ai-agent-ideas/index.mdx
index 575a830fc22..beb40e84f50 100644
--- a/apps/sim/content/library/ai-agent-ideas/index.mdx
+++ b/apps/sim/content/library/ai-agent-ideas/index.mdx
@@ -11,6 +11,17 @@ tags: [AI Agents, Use Cases, Automation, Sim]
ogImage: /library/ai-agent-ideas/cover.jpg
canonical: https://www.sim.ai/library/ai-agent-ideas
draft: false
+faq:
+ - q: "What are the best AI agent ideas for beginners?"
+ a: "Start with email triage, meeting follow-ups, or report generation. These three ideas have clear inputs (an email, a transcript, a data source) and clear outputs (a classified message, a list of action items, a formatted report), which makes them easy to validate. If the agent gets something wrong, you'll notice immediately and can adjust."
+ - q: "How long does it take to build an AI agent from scratch?"
+ a: "With a visual builder like Sim, a working prototype can be ready in under an hour. You describe the workflow to Chat or start from a blank canvas, connect your integrations, configure the AI model, and test. Custom multi-step agents with conditional logic and multiple data sources take longer, but you're still measuring build time in days, not months."
+ - q: "Do I need coding skills to build AI agents?"
+ a: "Not to get started. Sim's drag-and-drop canvas and Chat let you build and deploy agents without writing infrastructure code. You configure blocks visually, connect integrations, and define logic through the interface. For teams that want deeper customization, custom functions and API access are available, but they're optional."
+ - q: "What is the difference between an AI agent and a workflow automation like Zapier?"
+ a: "Zapier-style tools follow fixed if-then rules: when this trigger fires, do this action, every time, the same way. An AI agent reasons over context, evaluates information, and chooses between paths based on what it finds. It can handle ambiguous inputs, make decisions, and act across multiple systems in sequence. The core distinction is reasoning versus rule-following."
+ - q: "Which AI agent idea has the highest ROI for a small team?"
+ a: "Lead enrichment, email triage, and meeting follow-ups tend to deliver the highest time-reclaim per hour of build effort. Sales-related agents often show the fastest, most attributable returns because you can directly tie agent output, like qualified leads routed to reps, to pipeline and revenue metrics. For most small teams, starting with email triage or meeting follow-ups is the fastest path to visible results."
---
AI agents have moved from "we should explore this" to "this is running in production" faster than most teams anticipated. Customer service, eCommerce, and operations are already seeing real returns, and the pace of adoption across the enterprise isn't slowing down. The shift is real, and it's happening fast.
@@ -201,25 +212,3 @@ The gap between "AI agents sound useful" and "we have an agent running in produc
The teams seeing the best results in 2026 aren't building grand autonomous systems. They're prioritizing task-specific, governed AI agents that integrate with real business systems rather than broad autonomous experimentation. They're starting narrow, proving value, and expanding.
Pick the idea that matches your team's biggest pain point. Open Sim, build the workflow, and deploy your first agent today. You'll learn more in that first hour of building than in another month of reading about what's possible.
-
-## FAQ
-
-### What are the best AI agent ideas for beginners?
-
-Start with email triage, meeting follow-ups, or report generation. These three ideas have clear inputs (an email, a transcript, a data source) and clear outputs (a classified message, a list of action items, a formatted report), which makes them easy to validate. If the agent gets something wrong, you'll notice immediately and can adjust.
-
-### How long does it take to build an AI agent from scratch?
-
-With a visual builder like Sim, a working prototype can be ready in under an hour. You describe the workflow to Chat or start from a blank canvas, connect your integrations, configure the AI model, and test. Custom multi-step agents with conditional logic and multiple data sources take longer, but you're still measuring build time in days, not months.
-
-### Do I need coding skills to build AI agents?
-
-Not to get started. Sim's drag-and-drop canvas and Chat let you build and deploy agents without writing infrastructure code. You configure blocks visually, connect integrations, and define logic through the interface. For teams that want deeper customization, custom functions, and API access are available, but they're optional.
-
-### What is the difference between an AI agent and a workflow automation like Zapier?
-
-Zapier-style tools follow fixed if-then rules: when this trigger fires, do this action. Every time, the same way. An AI agent reasons over context, evaluates information, and chooses between paths based on what it finds. It can handle ambiguous inputs, make decisions, and act across multiple systems in sequence. The core distinction is reasoning versus rule-following.
-
-### Which AI agent idea has the highest ROI for a small team?
-
-Lead enrichment, email triage, and meeting follow-ups tend to deliver the highest time-reclaim per hour of build effort. Sales-related agents often show the fastest, most attributable returns because you can directly tie agent output (qualified leads routed to reps) to pipeline and revenue metrics. For most small teams, starting with email triage or meeting follow-ups is the fastest path to visible results.
diff --git a/apps/sim/content/library/ai-agent-vs-chatbot/index.mdx b/apps/sim/content/library/ai-agent-vs-chatbot/index.mdx
index 3467e946268..963e71ffd55 100644
--- a/apps/sim/content/library/ai-agent-vs-chatbot/index.mdx
+++ b/apps/sim/content/library/ai-agent-vs-chatbot/index.mdx
@@ -11,6 +11,17 @@ tags: [AI Agents, Chatbots, AI Workspace, Sim]
ogImage: /library/ai-agent-vs-chatbot/cover.jpg
canonical: https://www.sim.ai/library/ai-agent-vs-chatbot
draft: false
+faq:
+ - q: "What is the difference between an AI agent and a chatbot?"
+ a: "A chatbot is a reactive system that matches user input to predefined responses using rules or basic NLP; it can only answer within its scripted boundaries. An AI agent is an autonomous system that reasons toward a goal, accesses external tools and data, retains memory across interactions, and executes multi-step actions with minimal human direction."
+ - q: "Can a chatbot become an AI agent?"
+ a: "Adding an LLM like GPT to a chatbot doesn't make it an agent — it makes it a chatbot with better language generation. True AI agents require a fundamentally different architecture: persistent memory (short-term and long-term), tool access for taking real actions across systems, and goal-directed planning that lets the system break down objectives into steps and execute them."
+ - q: "Are AI agents more expensive than chatbots?"
+ a: "AI agents can require more setup: integration with APIs and databases, memory configuration, LLM costs, and orchestration logic. But new tools like Sim make it easy to deploy a first agent in minutes. Once live, agents reduce manual overhead at scale — less time spent on repetitive multi-step processes, and fewer errors in cross-system workflows. For teams drowning in manual tasks or coordination, the ROI on an agent proves itself within months."
+ - q: "What are the best use cases for AI agents in 2026?"
+ a: "Common enterprise use cases include end-to-end support automation (resolving tickets without human escalation), data enrichment (pulling and combining data across CRMs, third-party sources, and internal databases), compliance workflows (monitoring regulatory requirements and flagging issues across documents), multi-system reporting (aggregating data from analytics, billing, and project platforms into actionable summaries), and employee or customer onboarding (coordinating account provisioning, training schedules, and documentation across multiple tools)."
+ - q: "How do I know if my team is ready to build AI agents?"
+ a: "Check for four things: clear workflows you want to automate with defined steps rather than a vague AI goal, the right access credentials to the systems involved (CRM, payment gateway, databases, communication tools), a chosen platform or framework such as a code-first tool like LangChain or a visual builder like Sim, and someone who will own the agent rollout and keep monitoring performance and expanding scope over time."
---
You've deployed a chatbot. It answers the easy stuff fine: store hours, password resets, order status checks. Then a customer shows up with something slightly more complex: a refund tied to a promotional code, a shipping update across two orders, a billing question that requires pulling data from your CRM. The chatbot stalls. It loops. It punts to a human agent. Nearly one in five consumers who have used AI for customer service saw no benefit from the experience, according to the Qualtrics 2026 Customer Experience Trends Report.
@@ -201,25 +212,3 @@ If your workflow requires multi-step reasoning, cross-system coordination, or ac
AI agents are moving from experimental to expected across enterprise teams, and the adoption curve is steep. For your team, the question isn't whether to bring agents in; it's where to start.
Pick one workflow that's currently breaking down: reports that take hours to compile manually, and employee onboarding sequences that require five people to coordinate. Build an agent for that, prove ROI, then expand from there.
-
-## FAQ
-
-### What is the difference between an AI agent and a chatbot?
-
-A chatbot is a reactive system that matches user input to predefined responses using rules or basic NLP; it can only answer within its scripted boundaries. An AI agent is an autonomous system that reasons toward a goal, accesses external tools and data, retains memory across interactions, and executes multi-step actions with minimal human direction.
-
-### Can a chatbot become an AI agent?
-
-Adding an LLM like GPT to a chatbot doesn't make it an agent. It makes it a chatbot with better language generation. True AI agents require a fundamentally different architecture: persistent memory (short-term and long-term), tool access for taking real actions across systems, and goal-directed planning that lets the system break down objectives into steps and execute them.
-
-### Are AI agents more expensive than chatbots?
-
-AI agents can require more setup: integration with APIs and databases, memory configuration, LLM costs, and orchestration logic. But new tools like Sim make it easy to deploy your first agent in minutes. Once live, agents reduce manual overhead at scale: less time spent on repetitive multi-step processes, and fewer errors in cross-system workflows. For teams drowning in manual tasks or coordination, the ROI on an agent proves itself within months.
-
-### What are the best use cases for AI agents in 2026?
-
-Some common enterprise use cases right now include end-to-end support automation (resolving tickets without human escalation), data enrichment (pulling and combining data across CRMs, third-party sources, and internal databases), compliance workflows (monitoring regulatory requirements and flagging issues across documents), multi-system reporting (aggregating data from analytics, billing, and project platforms into actionable summaries), and employee or customer onboarding (coordinating account provisioning, training schedules, and documentation across multiple tools).
-
-### How do I know if my team is ready to build AI agents?
-
-Run through this checklist. Do you have clear workflows you want to automate: specific processes with defined steps, not vague "we want AI" goals? Do you have the right access credentials to the systems involved: CRM, payment gateway, databases, and communication tools? Have you chosen a platform or framework, whether that's a code-first tool like LangChain or a visual builder like Sim? And do you have someone who will own agent rollout, a person or team responsible for monitoring performance, adjusting workflows, and expanding scope over time? If so, you're ready to start building.
diff --git a/apps/sim/content/library/ai-agents-vs-rpa/index.mdx b/apps/sim/content/library/ai-agents-vs-rpa/index.mdx
index 9c5122f30fb..2c28017abce 100644
--- a/apps/sim/content/library/ai-agents-vs-rpa/index.mdx
+++ b/apps/sim/content/library/ai-agents-vs-rpa/index.mdx
@@ -11,6 +11,17 @@ tags: [AI Agents, RPA, Enterprise Automation, Sim]
ogImage: /library/ai-agents-vs-rpa/cover.jpg
canonical: https://www.sim.ai/library/ai-agents-vs-rpa
draft: false
+faq:
+ - q: "What is the main difference between AI agents and RPA?"
+ a: "RPA bots follow pre-programmed scripts to execute specific, rule-based tasks exactly the same way every time. AI agents use large language models to perceive inputs, reason about what needs to happen, and pursue goals by planning and adapting their steps."
+ - q: "Can AI agents replace enterprise RPA entirely?"
+ a: "RPA remains strong for deterministic, compliance-critical, high-volume structured tasks where you need identical execution every time with a fully auditable trail. AI agents add the most value on top of RPA, handling the exceptions, unstructured inputs, and judgment calls that RPA was never built for. Think of it as complementary, not competitive."
+ - q: "What kinds of processes should use RPA vs AI agents?"
+ a: "Look at three process characteristics: data structure, variability, and exception rate. If your inputs are structured and consistent, steps never change, and exceptions are rare, use RPA. If inputs vary in format, the process requires interpretation or multi-step reasoning, and exceptions are frequent, use AI agents."
+ - q: "How do you combine AI agents and RPA in the same workflow?"
+ a: "Use the brain-and-hands model: the AI agent orchestrates the workflow by reading inputs, making decisions, and routing actions, while RPA bots execute the deterministic steps inside legacy systems. For example, in a finance workflow, an AI agent interprets and validates a customer's service request, then triggers an RPA bot to execute the approved transaction in the ERP system."
+ - q: "How long does it take to implement AI agents compared to RPA?"
+ a: "RPA bots typically take one to four months to deploy for a well-scoped use case. AI agents used to take months because of the additional work required to define goals, set confidence thresholds, and test edge cases. Visual agent builder platforms like Sim can cut that timeline significantly by removing the need to write orchestration code from scratch, making hybrid deployments accessible to teams without deep ML engineering resources."
---
Picture this: your accounts payable team deployed an RPA bot two years ago to process vendor invoices. It ran flawlessly, pulling data from the same portal, matching line items, and pushing entries into the ERP system with zero human intervention. Then the vendor updated their web portal. New layout, new field names, new login flow. The bot broke overnight. Your team spent the next week re-scripting instead of building anything new.
@@ -211,25 +222,3 @@ This is also when governance frameworks need to mature:
The AI agents vs RPA question isn't really a versus at all. RPA gives you consistent, auditable execution on structured tasks and legacy systems. AI agents give you reasoning, adaptability, and the ability to handle the messy, variable work that RPA was never designed for. Trying to solve every automation problem with just one of these tools means you're either over-engineering simple tasks or leaving complex processes stuck in manual mode.
The practical path forward: audit where your current RPA bots hand off to humans, deploy AI agents at those specific seams, and build the integration layer that lets both technologies work as a single system. Start small, prove the hybrid model on one or two high-value workflows, and scale from there.
-
-## FAQ
-
-### What is the main difference between AI agents and RPA?
-
-RPA bots follow pre-programmed scripts to execute specific, rule-based tasks exactly the same way every time. AI agents use large language models to perceive inputs, reason about what needs to happen, and pursue goals by planning and adapting their steps.
-
-### Can AI agents replace enterprise RPA entirely?
-
-RPA remains strong for deterministic, compliance-critical, high-volume structured tasks where you need identical execution every time with a fully auditable trail. AI agents add the most value on top of RPA, handling the exceptions, unstructured inputs, and judgment calls that RPA was never built for. Think of it as complementary, not competitive.
-
-### What kinds of processes should use RPA vs AI agents?
-
-Look at three process characteristics: data structure, variability, and exception rate. If your inputs are structured and consistent, steps never change, and exceptions are rare, use RPA. If inputs vary in format, the process requires interpretation or multi-step reasoning, and exceptions are frequent, use AI agents.
-
-### How do you combine AI agents and RPA in the same workflow?
-
-Use the brain-and-hands model: the AI agent orchestrates the workflow by reading inputs, making decisions, and routing actions, while RPA bots execute the deterministic steps inside legacy systems. For example, in a finance workflow, an AI agent interprets and validates a customer's service request, then triggers an RPA bot to execute the approved transaction in the ERP system.
-
-### How long does it take to implement AI agents compared to RPA?
-
-RPA bots typically take one to four months to deploy for a well-scoped use case. AI agents used to take months because of the additional work required to define goals, set confidence thresholds, and test edge cases. Visual agent builder platforms like Sim can cut that timeline significantly by removing the need to write orchestration code from scratch, making hybrid deployments accessible to teams without deep ML engineering resources.
diff --git a/apps/sim/content/library/apache-2-0-vs-fair-code/index.mdx b/apps/sim/content/library/apache-2-0-vs-fair-code/index.mdx
new file mode 100644
index 00000000000..47f7f82b777
--- /dev/null
+++ b/apps/sim/content/library/apache-2-0-vs-fair-code/index.mdx
@@ -0,0 +1,131 @@
+---
+slug: apache-2-0-vs-fair-code
+title: "Apache 2.0 vs Fair-Code: Why Sim's License Beats n8n's for Self-Hosting"
+description: Apache 2.0 vs n8n's fair-code Sustainable Use License - what OSI open source actually means, what each license permits for self-hosting, embedding, and resale, and how Sim, n8n, Dify, and Zapier compare.
+date: 2026-07-14
+updated: 2026-07-14
+authors:
+ - andrew
+readingTime: 9
+tags: [Apache 2.0, Fair-Code, Open Source, Self-Hosting, n8n, Sim]
+ogImage: /library/apache-2-0-vs-fair-code/cover.jpg
+canonical: https://www.sim.ai/library/apache-2-0-vs-fair-code
+draft: false
+faq:
+ - q: "Is n8n open source?"
+ a: "Not in the strict OSI sense. n8n's core ships under the Sustainable Use License and is described as \"fair-code.\" The source is available and you can self-host it for internal use, but the license restricts offering it as a hosted service to third parties or building a competing product without a commercial agreement."
+ - q: "Is Dify open source?"
+ a: "Not in the strict OSI sense either. Dify's community edition ships under the Dify Open Source License, which follows Apache 2.0 for most terms but adds two conditions: you must retain Dify's logo and copyright notices in the frontend, and you need commercial authorization from Dify to run it as a public multi-tenant SaaS. Those additions are what an OSI-approved license would not permit, so Dify is source available with Apache-style terms rather than pure Apache 2.0."
+ - q: "What is the difference between Apache 2.0 and fair-code?"
+ a: "Apache 2.0 is an OSI-approved permissive license with no restriction on commercial use, self-hosting, or field of use, plus an explicit patent grant. Fair-code, such as the Sustainable Use License, makes source available but attaches usage restrictions an OSI license would not allow. Apache 2.0 is open source. Fair-code is source available."
+ - q: "Can I self-host an AI agent platform for free under Apache 2.0?"
+ a: "Yes. Apache 2.0 lets you run the software on your own infrastructure with no seat caps or usage metering, modify it, and use it commercially, as long as you preserve the license and copyright notices. Sim ships Docker, Kubernetes, and npx simstudio paths on those terms."
+ - q: "Can I embed Sim inside a commercial product I sell?"
+ a: "Yes. Apache 2.0 grants commercial use, sublicensing, and redistribution, so you can build a hosted product on the Sim core or ship it inside client deliverables. The same license applies whether you fork it, rename it, or offer it multi-tenant to your own customers."
+ - q: "Which open source AI agent builder should I use?"
+ a: "If a permissive, OSI-approved license and unrestricted self-hosting are priorities, Sim is built for that: a visual AI agent builder with Chat for natural-language building, Agent blocks, built-in Tables, Files, and Knowledge Bases, and bring-your-own-key model support. If you want a mature workflow tool for internal use and accept the Sustainable Use License terms, n8n is a strong option. Confirm the current LICENSE file before committing either way."
+---
+
+Apache 2.0 lets you use, modify, self-host, and redistribute Sim commercially with no field-of-use restriction and no per-seat gate. n8n's Sustainable Use License (its "fair-code" model) lets you self-host and use the software internally, but it bars you from reselling it or offering it as a competing hosted service. So n8n is source available, not open source in the strict OSI sense. Sim is Apache 2.0, an OSI-approved permissive license, which is the difference that matters if you plan to build on, embed, or resell the platform.
+
+## TL;DR
+
+- **Apache 2.0 grants unrestricted commercial use, sublicensing, redistribution, and an explicit patent grant.** The Sustainable Use License permits internal self-hosting but restricts multi-tenant hosting and commercial resale of the product itself.
+- **Sim ships Docker, Kubernetes, and `npx simstudio` self-host paths** with no seat, workflow, or execution caps beyond your own infrastructure.
+- **Choose n8n** when you need mature, deterministic node-based automation and its free community edition covers your internal workloads.
+- **Choose Sim** when you plan to embed, resell, or multi-tenant host the agent builder, or run Chat and Agent blocks inside regulated infrastructure without a vendor conversation.
+
+If "open source" is part of your decision, the license is the detail that decides more than any feature list. Here is what OSI open source actually means, what fair-code changes, and how the leading AI agent builders compare.
+
+## What "open source" actually means
+
+The term has a formal definition maintained by the Open Source Initiative. To qualify, a license must allow free use, modification, and redistribution with no restriction on the field of use, commercial use included. Apache 2.0, MIT, GPL, and MPL all clear that bar. Under any of them you can run the software for any purpose, including building a competing product, and no one can revoke that right later.
+
+"Source available" is a different thing. You can read the code and often modify it, but the license attaches conditions an OSI-approved license would not permit. The code sits on GitHub, which feels open, but the legal rights are narrower than the label suggests.
+
+## Fair-code and the Sustainable Use License
+
+"Fair-code" is a term n8n popularized. It is not an OSI category. n8n's core ships under the Sustainable Use License, which grants broad rights for internal business use and self-hosting but restricts using the software to offer a competing hosted service or to redistribute it as a commercial product.
+
+That model is legitimate and widely adopted. n8n uses it to stop cloud providers from wrapping the open code and reselling it at scale, which is a real commercial risk permissive licenses do nothing about. Calling fair-code a lesser license misreads it. It solves a different problem than Apache 2.0 does, and for a team automating its own operations, the internal-use grant covers everything they need. The distinction only turns decisive when your plans cross the line the license draws.
+
+## What Apache 2.0 unlocks that fair-code restricts
+
+Apache 2.0 permits four things the Sustainable Use License holds back, and each maps to a concrete plan.
+
+**Run it as a multi-tenant service.** Spin up one Sim deployment, put separate customer workspaces on it, charge for access, and Apache 2.0 permits that with no commercial conversation. Hosting the product as a paid multi-tenant service for other people is the exact use a fair-code license carves out.
+
+**Embed it inside a product you sell.** Ship an application where each customer's Agent blocks call your models and run your workflows behind your own UI. Apache 2.0 grants the commercial rights and the patent grant to do that, so the workflow engine becomes a component you distribute rather than a dependency you license separately.
+
+**Fork it and redistribute under a different name.** Take the source, rename it, modify the builder, and hand the result to your own users. Apache 2.0 asks only that you preserve the license and notices. Fair-code licenses typically stop you from redistributing a competing hosted version, which is where forks meant for resale hit the terms.
+
+**Run it in regulated or air-gapped infrastructure with no vendor approval.** If your environment sits behind a firewall with no outbound calls, you deploy the workflows and Agent blocks on your own hardware and never ask permission, because Apache 2.0 imposes no field-of-use restriction. Teams in finance, healthcare, and defense care about this because a licensing exception request is itself a compliance event.
+
+None of these rights come from a paid tier. They come from the license attached to the code, so they hold whether you run one Sim instance or a hundred.
+
+To be precise about where the line sits: Sim runs an open-core model, the same shape GitLab and Databricks use. The Apache 2.0 core is the whole workflow engine, Chat, Agent blocks, Tables, Files, and Knowledge Bases, and none of it is metered or seat-gated. The enterprise tier adds operational and compliance layers on top: SSO and SAML, SCIM provisioning, granular access control, an Admin API, whitelabeling, and dedicated support. That is a real distinction and worth knowing before you plan a deployment. It is also a different thing from a fair-code restriction. Open core decides which features ship in the paid product. Fair-code decides what you are legally permitted to do with the code you already have. You can hit an open-core feature boundary and still hold every right Apache 2.0 grants you.
+
+### What Apache 2.0 permits, in short
+
+- Use the software commercially, with no restriction on field of use.
+- Self-host on your own infrastructure with no seat caps, usage metering, or license gate.
+- Modify the source and ship your changes, privately or publicly.
+- Redistribute it, including as part of a larger commercial offering.
+- Build on it with patent protection, since Apache 2.0 includes an explicit patent grant that MIT and BSD do not.
+
+The only real obligations are to preserve copyright and license notices and to state significant changes. There is no "you may not compete with us" clause. That is the practical line between permissive open source and fair-code.
+
+## How does Sim's self-hosting work under Apache 2.0?
+
+Sim gives you three ways to run the platform on your own infrastructure, and each one hands you the full Apache 2.0 core.
+
+- **Docker.** Pull the images and run the stack with Docker Compose. Fits a single VM or a managed container service.
+- **Kubernetes.** Deploy with the provided manifests. Fits teams already running workloads on a cluster who want Sim to sit alongside them.
+- **`npx simstudio`.** Spin up a local instance in one command to prototype an Agent block or test a workflow.
+
+None of these paths caps how many users, workflows, or Agent block executions you run. The only ceiling is the compute, memory, and storage you provision. If you want a thousand people building with Chat and the visual builder, you size the database and runners to handle it, and nothing in the license stops you. No seat counter, no usage meter, no phone-home check that throttles a self-hosted instance. The software ships sensible burst-protection defaults, such as a per-pod cap on concurrent in-flight executions, but those are plain environment variables you tune to your own hardware, not license gates.
+
+The terms stay identical across all three paths. The core you get from `npx simstudio` on a laptop is the core you deploy to a production cluster. Sim does not gate self-hosting behind a paid tier and reserve the permissive license for a stripped-down community build. That consistency matters when you plan a migration: prototype locally, promote to staging, move to production, all without switching license models or discovering a feature was reserved for a hosted plan.
+
+## Where n8n's self-hosting still wins the job
+
+n8n runs deterministic, node-based automation better than almost anything else you can self-host, and licensing has nothing to do with why. It has spent years hardening a visual workflow engine around predictable triggers, retries, and error branches. When a job needs to fire on a webhook, hit six APIs in sequence, transform the payloads, and write to a database with exact control over failure behavior, n8n's node graph gives you that determinism without a fight.
+
+Its integration library is the second reason to reach for it. n8n ships hundreds of maintained nodes for specific SaaS products, and each one encodes the auth flow, pagination, and field mappings you would otherwise write by hand. If your job is stitching existing systems together rather than reasoning over unstructured input, that catalog saves real weeks.
+
+Its community edition is a genuine free self-hosting tier, not a crippled trial. You run the full engine on your own Docker host, keep data in your own Postgres, and never talk to a salesperson for internal automation. The Sustainable Use License permits exactly that, and the resale restrictions never come into play.
+
+Choose n8n when your workload is deterministic integration plumbing, your team thinks in flowcharts, and your requirements map cleanly onto pre-built nodes. Sim leans toward workflows where Agent blocks make decisions over messy input and an LLM sits in the loop rather than at the edges. Those are different jobs, and n8n is the stronger engine for the first one no matter which license sits underneath.
+
+## AI agent platform license comparison
+
+| Platform | License type | OSI-approved open source | Commercial self-hosting | Usage restrictions | Source available |
+| --- | --- | --- | --- | --- | --- |
+| Sim | Permissive (Apache 2.0) | Yes | Yes, no seat or usage cap | None | Yes |
+| n8n | Sustainable Use License (fair-code) | No | Internal use yes; hosting-as-a-service to third parties restricted | Cannot resell as a hosted service or build a competing product without a commercial license | Yes |
+| Dify | Dify Open Source License (Apache 2.0 plus added conditions) | No | Internal use yes; public multi-tenant SaaS requires authorization | Frontend logo and copyright notices must be retained; commercial authorization required to run a public multi-tenant SaaS | Yes |
+| Zapier | Proprietary / closed source | No | No self-hosting | Fully hosted SaaS only | No |
+
+Read each vendor's current LICENSE file before you rely on the summary above. Licenses change, and a table is a snapshot. The pattern that holds: a permissive license (Apache 2.0, MIT) gives the broadest rights, fair-code sits in the middle, and proprietary SaaS gives you no self-hosting rights at all.
+
+## Why licensing matters for AI agent platforms
+
+AI agents touch your most sensitive surfaces: customer data, internal APIs, credentials, production systems. The license you build on decides how much control you keep as usage grows.
+
+- **Data control and self-hosting.** A permissive license lets you run the platform inside your own VPC or on-prem, so agent executions and the data they read never leave your environment. This matters for regulated teams and anyone with data-residency requirements.
+- **No usage cliff.** Fair-code and source-available licenses often reserve the right to restrict specific commercial patterns. If your use case drifts toward offering the tool to your own customers, you can hit a clause that forces a commercial license. Apache 2.0 has no such cliff.
+- **Freedom to extend.** Agent platforms live or die on integrations and custom blocks. A permissive license lets you fork, patch, and ship extensions without a legal review of whether your change competes with the vendor.
+- **Longevity insurance.** If a permissively licensed project is abandoned or relicensed, the community can fork the last open version. Source-available projects are harder to rescue, because the license constrains what a fork may do.
+
+For technical builders and teams standardizing an agent stack, these are not abstractions. They are the reasons a self-hostable, permissively licensed platform lowers long-term risk.
+
+## Choosing a license model for your situation
+
+The right license depends on what you plan to do with the software, not on which engine has more nodes. Run through these before you commit.
+
+- **Building a hosted product on the workflow engine and charging others to use it?** Apache 2.0 is the only one of the two that permits this with no commercial agreement. Sim runs under the same terms whether you serve one team or a thousand.
+- **Embedding workflows into client deliverables or reselling under your own brand?** Same logic. Apache 2.0 grants sublicensing and redistribution, so you can ship Agent blocks inside a product you sell.
+- **Running in a regulated or air-gapped environment and want to skip the vendor conversation?** Apache 2.0 removes the field-of-use questions before they start. You own the terms once you have the code.
+- **Automating internal deterministic workflows and never reselling or multi-tenanting?** n8n's Sustainable Use License permits exactly that, and its community edition is the right starting point.
+
+For the Sim path, [start with `npx simstudio`](https://sim.ai) to run locally, then move to Docker or Kubernetes for production. For internal-automation-only, n8n's community edition covers the job without cost or friction.
diff --git a/apps/sim/content/library/best-zapier-alternatives/index.mdx b/apps/sim/content/library/best-zapier-alternatives/index.mdx
index 357095ce1a7..da1bb30a4d5 100644
--- a/apps/sim/content/library/best-zapier-alternatives/index.mdx
+++ b/apps/sim/content/library/best-zapier-alternatives/index.mdx
@@ -11,6 +11,21 @@ tags: [Zapier Alternatives, Workflow Automation, AI Agents, Sim]
ogImage: /library/best-zapier-alternatives/cover.jpg
canonical: https://www.sim.ai/library/best-zapier-alternatives
draft: false
+faq:
+ - q: "What is the best free alternative to Zapier?"
+ a: "It depends on whether you mean cloud-hosted or self-hosted. For cloud free tiers, Sim offers a free plan with no credit card required, and Make provides 1,000 operations per month on its free plan. For genuinely unlimited free automation, n8n (self-hosted) and Activepieces (self-hosted) both offer unrestricted execution at no cost, though they require you to provision and maintain your own server. The cloud free tiers are best for testing; the self-hosted options are viable for production if your team has the technical resources."
+ - q: "Is n8n better than Zapier?"
+ a: "For teams with developer resources, n8n wins on cost (free self-hosted with no execution limits) and data control (nothing leaves your infrastructure). Zapier wins on ease of use, integration breadth (7,000+ apps vs. n8n's smaller catalog), and polished onboarding. The best answer depends on whether your team has the expertise to manage a self-hosted instance. If you do, n8n can save thousands annually. If you don't, the time cost of maintaining n8n may exceed the subscription savings."
+ - q: "What is the cheapest Zapier alternative?"
+ a: "For zero-cost automation, n8n and Activepieces self-hosted options are free with no execution limits. Among paid hosted platforms, Pabbly Connect starts at $16/month with annual billing and includes 12,000 tasks with no charges for internal steps. Pabbly also offers lifetime pricing from $249 one-time, making it the cheapest long-term option for teams that want hosted automation without managing their own servers."
+ - q: "Can I migrate my Zaps to another platform?"
+ a: "There is no automated migration tool between platforms. You need to rebuild workflows manually. Simple two- to three-step Zaps typically take 10 - 15 minutes to recreate on any alternative platform. Complex multi-branch workflows with conditional logic, custom formatting, and multiple API connections can take 30 - 60 minutes each. Most Zapier Zaps can be recreated in Make in 15 - 30 minutes because the concepts are similar, and a full migration typically takes one to two days for a moderate-sized automation library."
+ - q: "What is the difference between Zapier and an AI agent workflow platform?"
+ a: "Zapier follows a trigger-action model: an event happens in App A, and data moves to App B. The logic is predefined, deterministic, and sequential. An AI agent workflow platform like Sim adds a fundamentally different capability: blocks in your workflow where an AI model reasons about the data, makes a decision, and chooses the next step. Instead of 'when a new email arrives, add a row to a spreadsheet,' you build workflows like 'when a new email arrives, an AI agent reads the content, classifies the intent, decides whether to respond, escalate, or archive, and then executes the appropriate path.' The distinction is between routing data and making decisions about data."
+ - q: "Does Sim charge per task like Zapier does?"
+ a: "No. Sim uses a credit-based model instead of per-task billing, and it supports bring-your-own API keys so you can avoid markup on model usage entirely. A free plan is available with no credit card required."
+ - q: "Is there a Zapier alternative that supports human approval steps in an automation?"
+ a: "Relay.app is purpose-built for this — it pauses a workflow, notifies the right person, collects their input, and continues, rather than treating every step as fully automated. It's best suited to RevOps, HR, legal, and finance workflows where a judgment call needs a human in the loop before the automation proceeds."
---
You're here because the invoice surprised you.
@@ -31,7 +46,7 @@ Whether you're hitting the complexity ceiling, need self-hosting for compliance,
- **Workato:** Enterprise-grade integration with strong governance, audit logging, and support SLAs. Priced accordingly, starting around $1,000/month.
-- **Activepieces:** Open-source with an MIT license and a cleaner, more approachable UI than n8n. Growing integration library (280+) with a free self-hosted tier.
+- **Activepieces:** Open-source with an MIT license and a cleaner, more approachable UI than n8n. Growing integration library (750+) with a free self-hosted tier.
- **Microsoft Power Automate:** Deep native integration with Microsoft 365, Azure, and Dynamics 365, often already included in your existing license. Best for organizations standardized on the Microsoft stack.
@@ -129,7 +144,7 @@ Activepieces occupies a useful niche: open-source automation that doesn't requir
The interface is cleaner and more approachable than n8n's, which makes Activepieces the better open-source pick for teams that want the benefits of open source (data control, no vendor lock-in, community-driven development) without the technical overhead of managing a self-hosted n8n instance.
-The integration library is growing but still smaller than the established players, with roughly 280+ pieces as of 2026. That's a trade-off worth acknowledging upfront: if your workflow depends on a niche app, check compatibility before committing. For common SaaS tools (Slack, Google Workspace, Notion, GitHub, CRMs), coverage is solid and expanding steadily.
+The integration library is growing but still smaller than the established players, with roughly 750+ pieces as of 2026. That's a trade-off worth acknowledging upfront: if your workflow depends on a niche app, check compatibility before committing. For common SaaS tools (Slack, Google Workspace, Notion, GitHub, CRMs), coverage is solid and expanding steadily.
**Best for:** Teams that want open-source flexibility with a friendlier interface than n8n, especially non-technical users who value data control but don't want to manage complex server infrastructure.
@@ -212,25 +227,3 @@ Key insights from this table:
The best Zapier alternatives in 2026 are split into three categories. If your primary goal is cost reduction, Make and Pabbly Connect deliver the most dramatic savings with different trade-offs: Make gives you visual complexity at lower per-operation pricing, while Pabbly's flat rate and lifetime deal make costs fully predictable. If your goal is data control and compliance, n8n (self-hosted), Activepieces, and Sim all offer self-hosting options that keep your data on your own infrastructure. And if your workflows have outgrown trigger-action logic entirely and you need AI agents that reason, decide, and act across your stack, Sim is the platform built from the ground up for that shift.
No single tool replaces Zapier for every team. Start by identifying the specific constraint that brought you here, whether it's pricing, complexity, data control, or AI capabilities, and match it against the routing table above. Most teams that switch successfully do so because they understand what they actually need rather than chasing the tool with the longest feature list.
-
-## FAQ
-
-### What is the best free alternative to Zapier?
-
-It depends on whether you mean cloud-hosted or self-hosted. For cloud free tiers, Sim offers a free plan with no credit card required, and Make provides 1,000 operations per month on its free plan. For genuinely unlimited free automation, n8n (self-hosted) and Activepieces (self-hosted) both offer unrestricted execution at no cost, though they require you to provision and maintain your own server. The cloud free tiers are best for testing; the self-hosted options are viable for production if your team has the technical resources.
-
-### Is n8n better than Zapier?
-
-For teams with developer resources, n8n wins on cost (free self-hosted with no execution limits) and data control (nothing leaves your infrastructure). Zapier wins on ease of use, integration breadth (7,000+ apps vs. n8n's smaller catalog), and polished onboarding. The best answer depends on whether your team has the expertise to manage a self-hosted instance. If you do, n8n can save thousands annually. If you don't, the time cost of maintaining n8n may exceed the subscription savings.
-
-### What is the cheapest Zapier alternative?
-
-For zero-cost automation, n8n and Activepieces self-hosted options are free with no execution limits. Among paid hosted platforms, Pabbly Connect starts at $16/month with annual billing and includes 12,000 tasks with no charges for internal steps. Pabbly also offers lifetime pricing from $249 one-time, making it the cheapest long-term option for teams that want hosted automation without managing their own servers.
-
-### Can I migrate my Zaps to another platform?
-
-There is no automated migration tool between platforms. You need to rebuild workflows manually. Simple two- to three-step Zaps typically take 10 - 15 minutes to recreate on any alternative platform. Complex multi-branch workflows with conditional logic, custom formatting, and multiple API connections can take 30 - 60 minutes each. Most Zapier Zaps can be recreated in Make in 15 - 30 minutes because the concepts are similar, and a full migration typically takes one to two days for a moderate-sized automation library.
-
-### What is the difference between Zapier and an AI agent workflow platform?
-
-Zapier follows a trigger-action model: an event happens in App A, and data moves to App B. The logic is predefined, deterministic, and sequential. An AI agent workflow platform like Sim adds a fundamentally different capability: blocks in your workflow where an AI model reasons about the data, makes a decision, and chooses the next step. Instead of "when a new email arrives, add a row to a spreadsheet," you build workflows like "when a new email arrives, an AI agent reads the content, classifies the intent, decides whether to respond, escalate, or archive, and then executes the appropriate path." The distinction is between routing data and making decisions about data.
diff --git a/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx b/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx
index 5bf70e1c60a..dad63c030d6 100644
--- a/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx
+++ b/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx
@@ -11,6 +11,15 @@ tags: [AI Agents, Tutorial, No-Code, Sim, Workflow Automation]
ogImage: /library/how-to-create-an-ai-agent/cover.jpg
canonical: https://www.sim.ai/library/how-to-create-an-ai-agent
draft: false
+faq:
+ - q: "What is an AI agent, and how is it different from a chatbot?"
+ a: "An AI agent perceives input, reasons over it, uses tools to take real-world actions (sending emails, updating databases, calling APIs), and evaluates results; often looping through multiple steps autonomously. A chatbot only generates text responses to direct prompts. The core distinction is that agents act; chatbots reply."
+ - q: "Can I build an AI agent for free?"
+ a: "Yes. Sim's free plan requires no credit card and includes execution credits for testing and development, access to the full visual workflow builder, and 1,000+ integrations. You can build, test, and deploy a working agent without spending anything."
+ - q: "How long does it take to build an AI agent with Sim?"
+ a: "A simple agent, like the people research example in Sim's getting-started tutorial, takes about 10 minutes from account creation to a working deployment. Production-grade agents with multiple tools, conditional logic, and error handling take longer, but the visual builder's feedback loop (edit, run, read the trace, refine) is significantly faster than iterating on a code framework where every change requires redeployment."
+ - q: "Do I need to know how to code to build an AI agent?"
+ a: "No. Sim's visual builder and Chat let you create, configure, and deploy agents entirely without code. You drag blocks, write system prompts in plain English, and connect integrations through the UI. That said, Sim also supports custom functions, a Python SDK, and full API access for developers who want deeper control or need to embed agents into existing codebases."
---
Every tutorial on how to build AI agents seems to start in the same place: pick a framework, install dependencies, configure your environment, write boilerplate, debug cryptic errors, and if you're lucky, get something running a few hours later. For teams that don't live in Python every day, that first hour can feel like the entire project.
@@ -191,21 +200,3 @@ Learning how to build AI agents doesn't require a computer science degree, a com
The pattern is straightforward: define a narrow task, open a workflow, add an Agent block with the right LLM and a tight system prompt, connect two or three tools, set a trigger, and iterate using execution logs. Start with the narrowest possible version of the idea, get it working reliably, then expand from there.
Trusted by over 100,000 builders at startups and Fortune 500 companies, Sim offers a free plan with no credit card required. Open it, build something, and see what an agent can do for your workflow before the week is out.
-
-## FAQ
-
-### What is an AI agent, and how is it different from a chatbot?
-
-An AI agent perceives input, reasons over it, uses tools to take real-world actions (sending emails, updating databases, calling APIs), and evaluates results; often looping through multiple steps autonomously. A chatbot only generates text responses to direct prompts. The core distinction is that agents act; chatbots reply.
-
-### Can I build an AI agent for free?
-
-Yes. Sim's free plan requires no credit card and includes execution credits for testing and development, access to the full visual workflow builder, and 1,000+ integrations. You can build, test, and deploy a working agent without spending anything.
-
-### How long does it take to build an AI agent with Sim?
-
-A simple agent, like the people research example in Sim's getting-started tutorial, takes about 10 minutes from account creation to a working deployment. Production-grade agents with multiple tools, conditional logic, and error handling take longer, but the visual builder's feedback loop (edit, run, read the trace, refine) is significantly faster than iterating on a code framework where every change requires redeployment.
-
-### Do I need to know how to code to build an AI agent?
-
-No. Sim's visual builder and Chat let you create, configure, and deploy agents entirely without code. You drag blocks, write system prompts in plain English, and connect integrations through the UI. That said, Sim also supports custom functions, a Python SDK, and full API access for developers who want deeper control or need to embed agents into existing codebases.
diff --git a/apps/sim/content/library/langgraph-alternatives/index.mdx b/apps/sim/content/library/langgraph-alternatives/index.mdx
index c2fd7cba659..de93b905b3e 100644
--- a/apps/sim/content/library/langgraph-alternatives/index.mdx
+++ b/apps/sim/content/library/langgraph-alternatives/index.mdx
@@ -11,6 +11,17 @@ tags: [LangGraph Alternatives, AI Agents, Agent Frameworks, Sim]
ogImage: /library/langgraph-alternatives/cover.jpg
canonical: https://www.sim.ai/library/langgraph-alternatives
draft: false
+faq:
+ - q: "What is the main difference between LangGraph and its alternatives?"
+ a: "LangGraph uses an explicit graph paradigm where you define nodes, edges, and state reducers to control every transition in an agent workflow. Most code-first alternatives replace this with different models: CrewAI uses role-based task delegation, AutoGen uses multi-turn conversation, and Google ADK uses hierarchical agent trees. Workspace platforms like Sim and Dify take a fundamentally different approach by handling deployment, integrations, and collaboration as built-in features rather than asking you to build them yourself. The split is really two decisions: which architectural model fits your agent logic, and whether you want to own the production infrastructure or have it handled for you."
+ - q: "Can I migrate an existing LangGraph workflow to one of these alternatives?"
+ a: "The agent logic (what each step does and the business rules governing flow) can transfer to any alternative. The graph definitions themselves don't port directly because each framework uses a different abstraction model. For code-first frameworks, expect to rewrite the orchestration layer while preserving your prompt engineering and tool implementations. For workspace platforms like Sim or Dify, you'll rebuild the workflow in a visual or conversational interface, which is often faster than the original code-based build but requires rethinking how you express branching and state management."
+ - q: "Which LangGraph alternative is best for non-technical team members?"
+ a: "Workspace platforms with visual builders are the clear answer. Sim's drag-and-drop visual builder and Chat let non-engineers describe what they want in natural language and see it built visually. Dify's visual workflow builder offers a similar low-code experience for RAG-heavy applications. Code-first frameworks like CrewAI, AutoGen, or Google ADK are not the right fit for non-engineers since they require Python or TypeScript proficiency and comfort with command-line tooling."
+ - q: "Is LangGraph still worth learning in 2026?"
+ a: "Yes, if you genuinely require stateful graph control with human-in-the-loop approvals and your industry demands auditable, deterministic execution paths. Financial services, healthcare, and legal tech teams building compliance-sensitive agent workflows will find real value in LangGraph's explicit state management model. It is not worth learning if your goal is fast production deployment for business workflow automation, since the engineering overhead of building deployment, collaboration, and integration infrastructure on top of LangGraph is significant, and workspace platforms now handle that layer natively."
+ - q: "How does Sim compare to LangGraph for enterprise use?"
+ a: "Sim and LangGraph serve enterprise teams from opposite directions. Sim ships deployment infrastructure (cloud-hosted with automatic scaling or self-hosted via Docker/Kubernetes), SOC2 and HIPAA compliance, real-time team collaboration with permission controls, 1,000+ pre-built integrations, and per-model cost tracking as built-in features. LangGraph offers deep audit trails and deterministic control over individual state transitions, which matter for regulated industry workflows requiring explicit approval chains. If your compliance team needs to trace every state transition, LangGraph wins. If your team needs agents running in production next month with 20 integrations and five people collaborating, Sim closes that gap faster."
---
Your LangGraph prototype works. Agents handle branching logic, state persists across turns, and the demo goes well. Then you try to ship it: deployment means building your own serving layer, debugging parallel execution traces turns into a guessing game, and wiring 50 integrations means 50 custom connectors. Getting it to run in production for your team, rather than just on your machine, is easier said than done.
@@ -179,29 +190,3 @@ LangGraph is a strong tool for a specific set of problems. If your team requires
For Python teams that want a different architectural model but still want to own their stack, CrewAI, Google ADK, and the OpenAI Agents SDK each offer compelling trade-offs depending on your cloud provider and use case. TypeScript teams have Mastra. Teams invested in Azure should watch the Microsoft Agent Framework closely.
For teams where the real bottleneck is getting agents into production, connected to business tools, and maintained by more than one person, a workspace platform like Sim removes the infrastructure burden so you can focus on the agent logic itself. You can [start building in Sim](https://sim.ai) and see how visual, conversational, and API-driven agent building compares to graph definitions in code.
-
-## FAQ
-
-### What is the main difference between LangGraph and its alternatives?
-
-LangGraph uses an explicit graph paradigm where you define nodes, edges, and state reducers to control every transition in an agent workflow. Most code-first alternatives replace this with different models: CrewAI uses role-based task delegation, AutoGen uses multi-turn conversation, and Google ADK uses hierarchical agent trees. Workspace platforms like Sim and Dify take a fundamentally different approach by handling deployment, integrations, and collaboration as built-in features rather than asking you to build them yourself. The split is really two decisions: which architectural model fits your agent logic, and whether you want to own the production infrastructure or have it handled for you.
-
-### Can I migrate an existing LangGraph workflow to one of these alternatives?
-
-The agent logic (what each step does and the business rules governing flow) can transfer to any alternative. The graph definitions themselves don't port directly because each framework uses a different abstraction model. For code-first frameworks, expect to rewrite the orchestration layer while preserving your prompt engineering and tool implementations. For workspace platforms like Sim or Dify, you'll rebuild the workflow in a visual or conversational interface, which is often faster than the original code-based build but requires rethinking how you express branching and state management.
-
-### Which LangGraph alternative is best for non-technical team members?
-
-Workspace platforms with visual builders are the clear answer. Sim's drag-and-drop visual builder and Chat let non-engineers describe what they want in natural language and see it built visually. Dify's visual workflow builder offers a similar low-code experience for RAG-heavy applications. Code-first frameworks like CrewAI, AutoGen, or Google ADK are not the right fit for non-engineers since they require Python or TypeScript proficiency and comfort with command-line tooling.
-
-### Is LangGraph still worth learning in 2026?
-
-Yes, if you genuinely require stateful graph control with human-in-the-loop approvals and your industry demands auditable, deterministic execution paths. Financial services, healthcare, and legal tech teams building compliance-sensitive agent workflows will find real value in LangGraph's explicit state management model.
-
-It is not worth learning if your goal is fast production deployment for business workflow automation. The engineering overhead of building deployment, collaboration, and integration infrastructure on top of LangGraph is significant, and workspace platforms now handle that layer natively.
-
-### How does Sim compare to LangGraph for enterprise use?
-
-Sim and LangGraph serve enterprise teams from opposite directions. Sim ships deployment infrastructure (cloud-hosted with automatic scaling or self-hosted via Docker/Kubernetes), SOC2 and HIPAA compliance, real-time team collaboration with permission controls, 1,000+ pre-built integrations, and per-model cost tracking as built-in features. LangGraph offers deep audit trails and deterministic control over individual state transitions, which matter for regulated industry workflows requiring explicit approval chains.
-
-The practical question is whether your enterprise bottleneck is graph-level control or production infrastructure. If your compliance team needs to trace every state transition, LangGraph wins. If your team needs agents running in production next month with 20 integrations and five people collaborating, Sim closes that gap faster.
diff --git a/apps/sim/content/library/n8n-alternatives/index.mdx b/apps/sim/content/library/n8n-alternatives/index.mdx
index 32090f395fa..26067b4f59f 100644
--- a/apps/sim/content/library/n8n-alternatives/index.mdx
+++ b/apps/sim/content/library/n8n-alternatives/index.mdx
@@ -11,6 +11,17 @@ tags: [n8n Alternatives, Workflow Automation, AI Agents, Sim]
ogImage: /library/n8n-alternatives/cover.jpg
canonical: https://www.sim.ai/library/n8n-alternatives
draft: false
+faq:
+ - q: "What is the best free alternative to n8n?"
+ a: "It depends on your use case. For open-source self-hosting with no execution limits and no commercial restrictions, Activepieces under the MIT license is the strongest free option. For AI agent workflows, Sim's free plan gives you access to multi-model orchestration and visual workflow building without a credit card. For visual SaaS automation, Make's free tier provides 1,000 credits per month to test basic scenarios."
+ - q: "Which n8n alternative is best for AI agent workflows?"
+ a: "Sim is the best general-purpose choice for visual, multi-model agent building with native memory, MCP support, and collaboration features. Gumloop is strong for non-developer teams that want an AI-native canvas for LLM-powered workflows. Botpress is the best pick specifically for conversational agents with built-in RAG, goal tracking, and multi-turn context. Choose based on whether your agents are general-purpose, LLM-workflow-focused, or conversation-focused."
+ - q: "Is Make better than n8n?"
+ a: "Neither is universally better. Make wins on debugging experience, accessibility for non-developers, and managed infrastructure that eliminates self-hosting overhead. n8n wins on extensibility, developer control, self-hosting flexibility, and community ecosystem depth. If your team is primarily non-technical and values visual debugging, Make is the better fit. If your team is developer-heavy and values code-level customization, n8n still has the edge."
+ - q: "What is the best open-source n8n alternative?"
+ a: "Activepieces offers the cleanest MIT license with a growing integration library of 750+ pieces and active community contribution. Sim provides open-source AI agent workflows with Docker and Kubernetes deployment for teams that need agentic capabilities. Node-RED remains the standard for IoT and hardware automation contexts where n8n's web-focused architecture is overkill. Match the tool to your use case rather than defaulting to the one with the most GitHub stars."
+ - q: "How hard is it to migrate from n8n to another tool?"
+ a: "It depends on which tool you are migrating to. Moving to a platform with a similar trigger-action paradigm (like Activepieces) is the closest format match and typically takes a few weeks of focused engineering for a mid-size portfolio. Before committing, calculate the full cost of migration, including potential productivity dips during transition. For most teams, the investment pays off within two to three quarters if the new platform eliminates major pain points and bottlenecks."
---
This guide covers 10 n8n alternatives for two distinct groups: teams that want simpler, managed SaaS automation without the self-hosting overhead, and teams building genuine AI agent workflows that need native multi-model orchestration, memory, and agentic reasoning.
@@ -131,7 +142,7 @@ Activepieces is the cleanest open-source alternative to n8n if licensing terms m
**Strengths:**
- **MIT license with no strings attached:** The codebase lives on GitHub under the MIT license, the most permissive of open-source licenses, giving organizations complete freedom to self-host, modify, fork, and extend the platform without any licensing fees or usage restrictions.
-- **Growing integration and MCP ecosystem:** All 280+ pieces are available as MCP servers that you can use with LLMs on Claude Desktop, Cursor, or Windsurf. The community contributes actively, with 60% of the pieces contributed by the community.
+- **Growing integration and MCP ecosystem:** All 750+ pieces are available as MCP servers that you can use with LLMs on Claude Desktop, Cursor, or Windsurf. The community contributes actively, with 60% of the pieces contributed by the community.
- **Clean, accessible UI:** Activepieces has the most polished UI of any open-source automation platform, making it accessible to non-technical users while still supporting code steps for custom logic.
- **Flat-rate cloud pricing:** Cloud pricing starts free with 10 free active flows, then $5 per active flow per month with unlimited runs. No per-execution billing surprises.
@@ -260,25 +271,3 @@ If you're leaving because self-hosting is consuming too much engineering time, M
If licensing restrictions concern you, Activepieces offers the cleanest MIT-licensed alternative with a growing integration ecosystem.
Don't try to evaluate all 10 tools on this list. Identify which of the four archetypes from the framework section fits your team, narrow the list to two or three candidates, and build a real workflow in each. The free tiers across Sim, Make, Zapier, Activepieces, and Pipedream allow you to do this without any upfront spend.
-
-## FAQ
-
-### What is the best free alternative to n8n?
-
-It depends on your use case. For open-source self-hosting with no execution limits and no commercial restrictions, Activepieces under the MIT license is the strongest free option. For AI agent workflows, Sim's free plan gives you access to multi-model orchestration and visual workflow building without a credit card. For visual SaaS automation, Make's free tier provides 1,000 credits per month to test basic scenarios.
-
-### Which n8n alternative is best for AI agent workflows?
-
-Sim is the best general-purpose choice for visual, multi-model agent building with native memory, MCP support, and collaboration features. Gumloop is strong for non-developer teams that want an AI-native canvas for LLM-powered workflows. Botpress is the best pick specifically for conversational agents with built-in RAG, goal tracking, and multi-turn context. Choose based on whether your agents are general-purpose, LLM-workflow-focused, or conversation-focused.
-
-### Is Make better than n8n?
-
-Neither is universally better. Make wins on debugging experience, accessibility for non-developers, and managed infrastructure that eliminates self-hosting overhead. n8n wins on extensibility, developer control, self-hosting flexibility, and community ecosystem depth. If your team is primarily non-technical and values visual debugging, Make is the better fit. If your team is developer-heavy and values code-level customization, n8n still has the edge.
-
-### What is the best open-source n8n alternative?
-
-Activepieces offers the cleanest MIT license with a growing integration library of 280+ pieces and active community contribution. Sim provides open-source AI agent workflows with Docker and Kubernetes deployment for teams that need agentic capabilities. Node-RED remains the standard for IoT and hardware automation contexts where n8n's web-focused architecture is overkill. Match the tool to your use case rather than defaulting to the one with the most GitHub stars.
-
-### How hard is it to migrate from n8n to another tool?
-
-It depends on which tool you are migrating to. Moving to a platform with a similar trigger-action paradigm (like Activepieces) is the closest format match and typically takes a few weeks of focused engineering for a mid-size portfolio. Before committing, calculate the full cost of migration, including potential productivity dips during transition. For most teams, the investment pays off within two to three quarters if the new platform eliminates major pain points and bottlenecks.
diff --git a/apps/sim/content/library/open-source-ai-agent-platforms/index.mdx b/apps/sim/content/library/open-source-ai-agent-platforms/index.mdx
index cb69ca1d9e3..80196709e36 100644
--- a/apps/sim/content/library/open-source-ai-agent-platforms/index.mdx
+++ b/apps/sim/content/library/open-source-ai-agent-platforms/index.mdx
@@ -11,6 +11,17 @@ tags: [Open Source, AI Agents, LangGraph, CrewAI, Dify, Sim]
ogImage: /library/open-source-ai-agent-platforms/cover.jpg
canonical: https://www.sim.ai/library/open-source-ai-agent-platforms
draft: false
+faq:
+ - q: "What is the difference between an AI agent framework and an AI agent platform?"
+ a: "An AI agent framework is a code library that provides primitives for building agents: tool use, multi-step reasoning, memory, and orchestration. You write code and own everything else. An AI agent platform bundles those primitives with deployment infrastructure, observability, collaboration features, and often a visual interface. The practical difference is how much your team builds versus how much comes out of the box."
+ - q: "Can I self-host all of these open-source AI agent platforms?"
+ a: "Most, but not all, support full self-hosting. LangGraph, CrewAI, Dify, and Sim can all be self-hosted via Docker or Kubernetes. AutoGen is now in maintenance mode and will not receive new features, but remains self-hostable. n8n supports self-hosting under its Sustainable Use License, which has specific commercial-use restrictions worth reviewing. Always check the license terms, since some platforms label enterprise features like RBAC, SSO, and advanced observability as paid add-ons even when the core is open source."
+ - q: "Which open-source AI agent platform is best for non-developers?"
+ a: "Visual builders like Dify and workspace platforms like Sim are the best starting points for non-developers. Both offer drag-and-drop interfaces that don't require writing code. Code-first frameworks like LangGraph, CrewAI, and AutoGen are a poor fit without engineering support since they require Python proficiency and comfort with infrastructure management. If your team is mixed (some developers, some not), a workspace like Sim lets both groups contribute in the same environment."
+ - q: "How does LangGraph compare to CrewAI for production use?"
+ a: "LangGraph gives you explicit, stateful control over every decision branch in your agent's workflow, making it the stronger choice for complex conditional logic. Crews provide autonomous agent collaboration ideal for tasks requiring flexible decision-making, while Flows offer precise, event-driven control ideal for managing detailed execution paths and secure state management. The real differentiator in production is the deployment layer: both frameworks leave production infrastructure, monitoring, and team collaboration as exercises for the builder, so your choice may hinge on which ecosystem your team prefers to invest in."
+ - q: "What should I look for in an open-source AI agent platform before committing?"
+ a: "Evaluate six things: license type (MIT, Apache 2.0, or a custom license with restrictions), self-hosting support (Docker/Kubernetes readiness and local model compatibility via Ollama), observability (built-in logging and tracing versus requiring a paid add-on like LangSmith), LLM flexibility (multi-provider support so you're not locked into one model vendor), community activity (commit frequency, issue response time, contributor count), and enterprise feature gating (whether RBAC, SSO, and audit logs require a paid tier). That last point matters most: an open-source label doesn't guarantee the features you need in production are in the free tier."
---
This guide splits open-source AI agent platforms into three clear categories, compares the leading options within each, and offers a decision framework based on your team's situation rather than by feature count.
@@ -179,34 +190,3 @@ Visual builders like Dify and n8n lower the barrier to entry but trade away arch
Start with the decision paths above. Identify which category fits your team, then evaluate within that category. Trying to compare a Python framework against a visual workspace on the same checklist is how teams end up six months into a tool that doesn't fit.
If your team needs collaboration, multi-model support, and a visual builder with production-grade deployment, [explore Sim](https://sim.ai) and see whether the workspace model matches how your team actually works.
-
-## FAQ
-
-### What is the difference between an AI agent framework and an AI agent platform?
-
-An AI agent framework is a code library that provides primitives for building agents: tool use, multi-step reasoning, memory, and orchestration. You write code and own everything else. An AI agent platform bundles those primitives with deployment infrastructure, observability, collaboration features, and often a visual interface. The practical difference is how much your team builds versus how much comes out of the box.
-
-### Can I self-host all of these open-source AI agent platforms?
-
-Most, but not all, support full self-hosting. LangGraph, CrewAI, Dify, and Sim can all be self-hosted via Docker or Kubernetes. AutoGen is now in maintenance mode and will not receive new features, but remains self-hostable. n8n supports self-hosting under its Sustainable Use License, which has specific commercial-use restrictions worth reviewing. Always check the license terms. Some platforms label enterprise features (RBAC, SSO, advanced observability) as paid add-ons even when the core is open source.
-
-### Which open-source AI agent platform is best for non-developers?
-
-Visual builders like Dify and workspace platforms like Sim are the best starting points for non-developers. Both offer drag-and-drop interfaces that don't require writing code. Code-first frameworks like LangGraph, CrewAI, and AutoGen are a poor fit without engineering support since they require Python proficiency and comfort with infrastructure management. If your team is mixed (some developers, some not), a workspace like Sim lets both groups contribute in the same environment.
-
-### How does LangGraph compare to CrewAI for production use?
-
-LangGraph gives you explicit, stateful control over every decision branch in your agent's workflow, making it the stronger choice for complex conditional logic. Crews provide autonomous agent collaboration ideal for tasks requiring flexible decision-making, while Flows offer precise, event-driven control ideal for managing detailed execution paths and secure state management. The real differentiator in production is the deployment layer: both frameworks leave production infrastructure, monitoring, and team collaboration as exercises for the builder, so your choice may hinge on which ecosystem (LangChain vs. CrewAI Enterprise) your team prefers to invest in.
-
-### What should I look for in an open-source AI agent platform before committing?
-
-Six things to evaluate before committing:
-
-1. **License type** (MIT, Apache 2.0, or a custom license with restrictions)
-2. **Self-hosting support** (Docker/Kubernetes readiness and local model compatibility via Ollama)
-3. **Observability** (built-in logging and tracing vs. requiring a paid add-on like LangSmith)
-4. **LLM flexibility** (multi-provider support so you're not locked into one model vendor)
-5. **Community activity** (commit frequency, issue response time, contributor count)
-6. **Enterprise feature gating** (whether RBAC, SSO, and audit logs require a paid tier)
-
-The last point is especially important: an open-source label doesn't guarantee that the features you need in production are in the free tier.
diff --git a/apps/sim/content/library/openai-vs-n8n-vs-sim/index.mdx b/apps/sim/content/library/openai-vs-n8n-vs-sim/index.mdx
index 49f47b4daae..d25bbaaf1de 100644
--- a/apps/sim/content/library/openai-vs-n8n-vs-sim/index.mdx
+++ b/apps/sim/content/library/openai-vs-n8n-vs-sim/index.mdx
@@ -11,6 +11,19 @@ tags: [AI Agents, Workflow Automation, OpenAI AgentKit, n8n, Sim, MCP]
ogImage: /library/openai-vs-n8n-vs-sim/cover.jpg
canonical: https://www.sim.ai/library/openai-vs-n8n-vs-sim
draft: false
+faq:
+ - q: "Can OpenAI AgentKit run models from providers other than OpenAI?"
+ a: "No. AgentKit only runs OpenAI models, while Sim connects to multiple AI providers including OpenAI, Anthropic, Google, Groq, Cerebras, and local Ollama models."
+ - q: "Is n8n fully open source?"
+ a: "No. n8n is fair-code licensed with some restrictions, not fully open source. Sim, by contrast, is Apache 2.0 licensed and fully open source."
+ - q: "Does OpenAI AgentKit provide execution logs for debugging agents in production?"
+ a: "No, AgentKit has no execution logs or detailed monitoring for debugging and observability. Sim provides enterprise-grade logging with execution IDs, block-level timing, token/cost tracking, and error traces."
+ - q: "Can n8n workflows be built programmatically instead of through the visual builder?"
+ a: "No, n8n has no SDK for building workflows programmatically and is limited to its visual builder only."
+ - q: "How many ways can you trigger a workflow in each platform?"
+ a: "AgentKit offers no way to trigger workflows via external integrations. n8n supports webhooks, scheduled executions, and manual triggers. Sim supports chat interfaces, REST APIs, webhooks, scheduled jobs, and external events from integrated services like Slack and GitHub."
+ - q: "Which platform has more built-in integrations, n8n or Sim?"
+ a: "n8n has a larger integration library overall, but Sim ships 80+ built-in integrations spanning AI providers, communication tools, productivity apps, and developer tools, plus MCP protocol support for custom integrations to fill any gaps."
---
When building AI agent workflows, developers often evaluate multiple platforms to find the right fit for their needs. Three platforms frequently come up in these discussions: OpenAI's new AgentKit, the established workflow automation tool n8n, and Sim, a purpose-built AI agent workflow builder. While all three enable AI agent development, they take fundamentally different approaches, each with distinct strengths and ideal use cases.
@@ -103,7 +116,7 @@ n8n is best suited for:
While n8n is excellent for traditional automation, it has some limitations for AI agent development:
- No SDK to build workflows programmatically—limited to visual builder only
-- Not fully open source but fair-use licensed, with some restrictions
+- Not fully open source but fair-code licensed, with some restrictions
- Free trial limited to 14 days, after which paid plans are required
- Limited/complex parallel execution handling
@@ -191,7 +204,7 @@ While all three platforms enable AI agent development, they excel in different a
**Best for:** Traditional workflow automation with some AI enhancement. Excellent when your primary need is connecting business tools and services, with AI as a complementary feature rather than the core focus.
-**Limitations:** No SDK for programmatic workflow building, fair-use licensing (not fully open source), 14-day trial limitation, and AI agent capabilities are newer and less mature compared to its traditional automation features.
+**Limitations:** No SDK for programmatic workflow building, fair-code licensing (not fully open source), 14-day trial limitation, and AI agent capabilities are newer and less mature compared to its traditional automation features.
### Sim
diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts
index c73e56aa60f..5b9831a3f4b 100644
--- a/apps/sim/hooks/queries/workspace.ts
+++ b/apps/sim/hooks/queries/workspace.ts
@@ -8,14 +8,12 @@ import {
deleteWorkspaceContract,
getWorkspaceContract,
getWorkspaceMembersContract,
- getWorkspaceOwnerBillingContract,
getWorkspacePermissionsContract,
listWorkspacesContract,
updateWorkspaceContract,
type Workspace,
type WorkspaceCreationPolicy,
type WorkspaceMember,
- type WorkspaceOwnerBilling,
type WorkspacePermissions,
type WorkspaceQueryScope,
type WorkspacesResponse,
@@ -35,7 +33,6 @@ export const workspaceKeys = {
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
members: (id: string) => [...workspaceKeys.detail(id), 'members'] as const,
- ownerBilling: (id: string) => [...workspaceKeys.detail(id), 'ownerBilling'] as const,
adminLists: () => [...workspaceKeys.all, 'adminList'] as const,
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
}
@@ -117,36 +114,6 @@ export function useWorkspaceCreationPolicy(enabled = true) {
})
}
-async function fetchWorkspaceOwnerBilling(
- workspaceId: string,
- signal?: AbortSignal
-): Promise
{
- return requestJson(getWorkspaceOwnerBillingContract, {
- params: { id: workspaceId },
- signal,
- })
-}
-
-/**
- * Subscription access state of the workspace's billed account (its owner's
- * rolled-up plan) — the workspace-scoped counterpart to `useSubscriptionData`.
- * Feed the result to `getSubscriptionAccessState` to gate workspace features on
- * the owner's plan rather than the viewer's, so a free member of a paid workspace
- * isn't gated.
- *
- * `staleTime: 0` so consumers (e.g. the deploy modal) refetch on mount: a plan
- * change happens outside this query's invalidation graph, and the cached value is
- * shown during the background refetch (no flash), so gates self-heal on reopen.
- */
-export function useWorkspaceOwnerBilling(workspaceId?: string) {
- return useQuery({
- queryKey: workspaceKeys.ownerBilling(workspaceId ?? ''),
- queryFn: ({ signal }) => fetchWorkspaceOwnerBilling(workspaceId as string, signal),
- enabled: Boolean(workspaceId),
- staleTime: 0,
- })
-}
-
type CreateWorkspaceParams = Pick, 'name'>
/**
diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts
index ecd15b68afb..22793dd27d5 100644
--- a/apps/sim/lib/api/contracts/credentials.ts
+++ b/apps/sim/lib/api/contracts/credentials.ts
@@ -1,10 +1,7 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
-import {
- ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
- type OAuthProvider,
- SLACK_CUSTOM_BOT_PROVIDER_ID,
-} from '@/lib/oauth/types'
+import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields'
+import type { OAuthProvider } from '@/lib/oauth/types'
const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/
@@ -161,46 +158,14 @@ export const createCredentialBodySchema = z
}
if (data.type === 'service_account') {
- if (data.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
- if (!data.apiToken) {
+ for (const field of getServiceAccountRequiredFields(data.providerId)) {
+ if (!data[field]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: 'apiToken is required for Atlassian service account credentials',
- path: ['apiToken'],
+ message: `${field} is required for ${data.providerId ?? 'service account'} credentials`,
+ path: [field],
})
}
- if (!data.domain) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'domain is required for Atlassian service account credentials',
- path: ['domain'],
- })
- }
- return
- }
- if (data.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
- if (!data.signingSecret) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'signingSecret is required for a custom Slack bot credential',
- path: ['signingSecret'],
- })
- }
- if (!data.botToken) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'botToken is required for a custom Slack bot credential',
- path: ['botToken'],
- })
- }
- return
- }
- if (!data.serviceAccountJson) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'serviceAccountJson is required for service account credentials',
- path: ['serviceAccountJson'],
- })
}
return
}
diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts
index a6460ce27d8..47567a624ed 100644
--- a/apps/sim/lib/api/contracts/workspaces.ts
+++ b/apps/sim/lib/api/contracts/workspaces.ts
@@ -208,16 +208,6 @@ export const workspaceOwnerBillingSchema = z.object({
export type WorkspaceOwnerBilling = z.output
-export const getWorkspaceOwnerBillingContract = defineRouteContract({
- method: 'GET',
- path: '/api/workspaces/[id]/owner-billing',
- params: workspaceParamsSchema,
- response: {
- mode: 'json',
- schema: workspaceOwnerBillingSchema,
- },
-})
-
export const workspaceHostContextSchema = z.object({
workspace: z.object({
id: nonEmptyIdSchema,
diff --git a/apps/sim/lib/billing/core/api-access.test.ts b/apps/sim/lib/billing/core/api-access.test.ts
deleted file mode 100644
index a93fa6b2d9e..00000000000
--- a/apps/sim/lib/billing/core/api-access.test.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * @vitest-environment node
- */
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const { mockGetHighestPrioritySubscription, mockResolveWorkspaceBillingPayer, billingState } =
- vi.hoisted(() => ({
- mockGetHighestPrioritySubscription: vi.fn(),
- mockResolveWorkspaceBillingPayer: vi.fn(),
- billingState: { isBillingEnabled: true, isFreeApiDeploymentGateEnabled: true },
- }))
-
-vi.mock('@/lib/core/config/env-flags', () => ({
- get isBillingEnabled() {
- return billingState.isBillingEnabled
- },
- get isFreeApiDeploymentGateEnabled() {
- return billingState.isFreeApiDeploymentGateEnabled
- },
-}))
-
-vi.mock('@/lib/billing/core/subscription', () => ({
- getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
-}))
-
-vi.mock('@/lib/billing/core/billing-attribution', () => ({
- resolveWorkspaceBillingPayer: mockResolveWorkspaceBillingPayer,
-}))
-
-import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
-
-describe('isWorkspaceApiExecutionEntitled', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- billingState.isBillingEnabled = true
- billingState.isFreeApiDeploymentGateEnabled = true
- })
-
- it('is false when the exact workspace payer is free', async () => {
- mockResolveWorkspaceBillingPayer.mockResolvedValue({
- billedAccountUserId: 'owner-1',
- organizationId: 'org-1',
- payerSubscription: { plan: 'free' },
- })
- expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false)
- expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
- })
-
- it('is true when the exact workspace payer is paid', async () => {
- mockResolveWorkspaceBillingPayer.mockResolvedValue({
- billedAccountUserId: 'owner-1',
- organizationId: 'org-1',
- payerSubscription: { plan: 'team_6000' },
- })
- expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true)
- expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
- })
-
- it('does not substitute another subscription held by the billed account owner', async () => {
- mockResolveWorkspaceBillingPayer.mockResolvedValue({
- billedAccountUserId: 'owner-1',
- organizationId: 'free-org',
- payerSubscription: null,
- })
- mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'enterprise' })
-
- expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false)
- expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
- })
-
- it('is false when the workspace has no resolvable payer', async () => {
- mockResolveWorkspaceBillingPayer.mockResolvedValue(null)
- expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false)
- })
-
- it('skips the billed-account lookup on self-hosted', async () => {
- billingState.isBillingEnabled = false
- expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true)
- expect(mockResolveWorkspaceBillingPayer).not.toHaveBeenCalled()
- })
-
- it('skips the lookup (gate off) when the feature flag is disabled', async () => {
- billingState.isFreeApiDeploymentGateEnabled = false
- expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true)
- expect(mockResolveWorkspaceBillingPayer).not.toHaveBeenCalled()
- })
-})
diff --git a/apps/sim/lib/billing/core/api-access.ts b/apps/sim/lib/billing/core/api-access.ts
deleted file mode 100644
index af96119817c..00000000000
--- a/apps/sim/lib/billing/core/api-access.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribution'
-import { isPaid } from '@/lib/billing/plan-helpers'
-import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags'
-
-/** The programmatic-execution paywall is active only when billing is enforced AND the gate flag is on. */
-function isApiExecutionGateActive(): boolean {
- return isBillingEnabled && isFreeApiDeploymentGateEnabled
-}
-
-/**
- * Message for the 402 returned when a free-plan account attempts programmatic
- * workflow execution (API key, public API, or MCP server).
- */
-export const API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE =
- 'Programmatic workflow execution requires a paid plan. Upgrade to Pro or higher to use the API.'
-
-/**
- * Whether workflows in `workspaceId` may run programmatically, gated on the
- * workspace-selected payer's exact subscription. Always allowed when billing
- * enforcement is off (self-hosted / `BILLING_ENABLED` unset); short-circuits
- * before any DB lookup.
- */
-export async function isWorkspaceApiExecutionEntitled(
- workspaceId: string | undefined
-): Promise {
- if (!isApiExecutionGateActive() || !workspaceId) return true
-
- const payer = await resolveWorkspaceBillingPayer(workspaceId, { onMissing: 'return-null' })
- return isPaid(payer?.payerSubscription?.plan)
-}
diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts
index 8c5c8b36d45..68cfa2fea66 100644
--- a/apps/sim/lib/billing/index.ts
+++ b/apps/sim/lib/billing/index.ts
@@ -4,7 +4,6 @@
*/
export * from '@/lib/billing/calculations/usage-monitor'
-export * from '@/lib/billing/core/api-access'
export * from '@/lib/billing/core/billing'
export * from '@/lib/billing/core/organization'
export * from '@/lib/billing/core/subscription'
diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts
index 7364bbc1f87..f143602dcd2 100644
--- a/apps/sim/lib/copilot/tools/client/store-utils.ts
+++ b/apps/sim/lib/copilot/tools/client/store-utils.ts
@@ -6,7 +6,7 @@ import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types'
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
-import { decodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
+import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
/** Respond tools are internal handoff tools shown with a friendly generic label. */
const HIDDEN_TOOL_SUFFIX = '_respond'
@@ -82,20 +82,6 @@ function formatReadingLabel(target: string | undefined, state: ClientToolCallSta
}
}
-/**
- * VFS paths store each segment percent-encoded (see {@link encodeVfsSegment}), so
- * a read on "My Report.txt" arrives as "files/My%20Report.txt". Decode for
- * display so the user sees the real file name. Falls back to the raw segment when
- * it is not valid encoding (e.g. a literal "%" that was never encoded).
- */
-function decodeVfsSegmentSafe(segment: string): string {
- try {
- return decodeVfsSegment(segment)
- } catch {
- return segment
- }
-}
-
function describeReadTarget(path: string | undefined): string | undefined {
if (!path) return undefined
diff --git a/apps/sim/lib/copilot/vfs/path-utils.ts b/apps/sim/lib/copilot/vfs/path-utils.ts
index 6f88e9507d9..daeb5c0a631 100644
--- a/apps/sim/lib/copilot/vfs/path-utils.ts
+++ b/apps/sim/lib/copilot/vfs/path-utils.ts
@@ -34,6 +34,18 @@ export function decodeVfsSegment(segment: string): string {
}
}
+/**
+ * Decodes a VFS path segment for display, falling back to the raw segment when
+ * it is not valid encoding (e.g. a literal "%" that was never encoded).
+ */
+export function decodeVfsSegmentSafe(segment: string): string {
+ try {
+ return decodeVfsSegment(segment)
+ } catch {
+ return segment
+ }
+}
+
export function encodeVfsPathSegments(segments: string[]): string {
return segments.map(encodeVfsSegment).join('/')
}
diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts
index 2df2aeae735..eaeeaabcac8 100644
--- a/apps/sim/lib/core/config/env-flags.ts
+++ b/apps/sim/lib/core/config/env-flags.ts
@@ -59,14 +59,6 @@ export const isBillingEnabled =
? isTruthy(env.BILLING_ENABLED)
: isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
-/**
- * Block free-plan accounts from programmatic workflow execution (API key, public
- * API, MCP server, generic webhooks, cross-origin chat embeds).
- * Gated behind {@link isBillingEnabled}; off by default so the paywall can ship
- * dark and be enabled per-deployment once verified.
- */
-export const isFreeApiDeploymentGateEnabled = isTruthy(env.FREE_API_DEPLOYMENT_GATE_ENABLED)
-
/**
* Is email verification enabled
*/
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index 6fcd4dd7c75..785237b0bb8 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -87,7 +87,6 @@ export const env = createEnv({
BILLING_CONCURRENCY_LIMIT_TEAM: z.string().optional(), // In-flight executions per Max-tier billing account (Max and Max for Teams)
BILLING_CONCURRENCY_LIMIT_ENTERPRISE: z.string().optional(), // In-flight executions per Enterprise billing account (metadata-overridable)
BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking
- FREE_API_DEPLOYMENT_GATE_ENABLED: z.boolean().optional(), // Block free-plan accounts from programmatic execution (API/MCP/A2A/generic webhooks/chat embeds). Requires BILLING_ENABLED. Off by default for dark rollout
TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap
PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI
PII_GRANULAR_REDACTION: z.boolean().optional(), // Expose the execution-altering PII redaction stages (redact workflow input + block outputs in-flight) in the Data Retention config; layered on top of PII_REDACTION
diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts
index 968e0a2fb14..b2812a7e6b6 100644
--- a/apps/sim/lib/credentials/orchestration/index.ts
+++ b/apps/sim/lib/credentials/orchestration/index.ts
@@ -17,6 +17,7 @@ import {
ServiceAccountSecretError,
verifyAndBuildServiceAccountSecret,
} from '@/lib/credentials/service-account-secret'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('CredentialOrchestration')
@@ -116,7 +117,8 @@ export async function performUpdateCredential(
updates.encryptedServiceAccountKey = encrypted
}
- // Reconnect: rotate a Slack/Atlassian service-account secret in place. The
+ // Reconnect: rotate a service-account secret (Slack, Atlassian, or any
+ // token-paste provider) in place. The
// secret is re-verified against the provider and re-encrypted; the display
// name is preserved (the user may have renamed it).
const hasRotationSecret =
@@ -152,6 +154,14 @@ export async function performUpdateCredential(
providerErrorCode: error.code,
}
}
+ if (error instanceof TokenServiceAccountValidationError) {
+ return {
+ success: false,
+ error: error.code,
+ errorCode: 'validation',
+ providerErrorCode: error.code,
+ }
+ }
throw error
}
}
diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts
new file mode 100644
index 00000000000..ea505ddd9c4
--- /dev/null
+++ b/apps/sim/lib/credentials/service-account-fields.ts
@@ -0,0 +1,47 @@
+import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-service-accounts/descriptors'
+import {
+ ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
+ SLACK_CUSTOM_BOT_PROVIDER_ID,
+} from '@/lib/oauth/types'
+
+/** Every secret field a service-account credential create/reconnect can carry. */
+export type ServiceAccountFieldId =
+ | 'apiToken'
+ | 'domain'
+ | 'serviceAccountJson'
+ | 'signingSecret'
+ | 'botToken'
+
+/**
+ * Required create-body fields per service-account provider — the client-safe
+ * source of truth consumed by the `createCredentialBodySchema` superRefine.
+ * (Server-side builders re-derive their own requirements: token-paste
+ * providers from descriptor fields, bespoke providers inline.) Token-paste
+ * providers contribute their entries from
+ * `TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS`; the three bespoke providers are
+ * declared here.
+ */
+export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = {
+ [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'],
+ [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: ['apiToken', 'domain'],
+ [SLACK_CUSTOM_BOT_PROVIDER_ID]: ['signingSecret', 'botToken'],
+ ...TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS,
+}
+
+/**
+ * Legacy Google creates may omit `providerId` entirely (the original
+ * service-account flow predates multi-provider support), so an unknown or
+ * missing provider falls back to requiring the Google JSON key.
+ */
+export const FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS: readonly ServiceAccountFieldId[] = [
+ 'serviceAccountJson',
+]
+
+export function getServiceAccountRequiredFields(
+ providerId: string | null | undefined
+): readonly ServiceAccountFieldId[] {
+ return providerId && Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, providerId)
+ ? SERVICE_ACCOUNT_REQUIRED_FIELDS[providerId]
+ : FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS
+}
diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts
index 42b1be22b6c..ea33f8e2ac2 100644
--- a/apps/sim/lib/credentials/service-account-secret.test.ts
+++ b/apps/sim/lib/credentials/service-account-secret.test.ts
@@ -123,4 +123,27 @@ describe('verifyAndBuildServiceAccountSecret', () => {
expect(result.displayName).toBe('svc@proj.iam')
expect(result.encryptedServiceAccountKey).toBe(json)
})
+
+ it('accepts a legacy Google create with an empty providerId', async () => {
+ const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' })
+ const result = await verifyAndBuildServiceAccountSecret('', { serviceAccountJson: json })
+ expect(result.providerId).toBe('google-service-account')
+ })
+
+ it('rejects an unknown non-empty providerId instead of persisting it as Google', async () => {
+ const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' })
+ await expect(
+ verifyAndBuildServiceAccountSecret('hubspot-service-acount-typo', {
+ serviceAccountJson: json,
+ })
+ ).rejects.toThrow('Unsupported service-account provider')
+ })
+
+ it('rejects prototype-chain providerIds with a validation error, not a TypeError', async () => {
+ for (const providerId of ['__proto__', 'constructor', 'toString']) {
+ await expect(
+ verifyAndBuildServiceAccountSecret(providerId, { serviceAccountJson: '{}' })
+ ).rejects.toThrow('Unsupported service-account provider')
+ }
+ })
})
diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts
index 579fae92531..a14f0d4b5ca 100644
--- a/apps/sim/lib/credentials/service-account-secret.ts
+++ b/apps/sim/lib/credentials/service-account-secret.ts
@@ -6,9 +6,19 @@ import {
normalizeAtlassianDomain,
validateAtlassianServiceAccount,
} from '@/lib/credentials/atlassian-service-account'
+import {
+ getTokenServiceAccountDescriptor,
+ isTokenServiceAccountProviderId,
+ TOKEN_SERVICE_ACCOUNT_SECRET_TYPE,
+} from '@/lib/credentials/token-service-accounts/descriptors'
+import {
+ getTokenServiceAccountValidator,
+ type TokenServiceAccountSecretBlob,
+} from '@/lib/credentials/token-service-accounts/server'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
+ GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
SLACK_CUSTOM_BOT_SECRET_TYPE,
} from '@/lib/oauth/types'
@@ -42,84 +52,90 @@ export class ServiceAccountSecretError extends Error {
}
/**
- * Verifies a service-account secret against its provider, derives the display
- * name, and returns the encrypted blob ready to persist. Shared by credential
- * create (POST) and in-place reconnect (PUT) so both paths verify + encrypt
- * identically. Throws {@link ServiceAccountSecretError} on missing fields or a
- * failed provider verification (callers map it to a 400).
+ * Builds an Atlassian service-account secret (scoped API token + site domain).
*/
-export async function verifyAndBuildServiceAccountSecret(
- providerId: string,
+async function buildAtlassianServiceAccountSecret(
fields: ServiceAccountSecretFields
): Promise {
- if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
- const { apiToken, domain } = fields
- if (!apiToken || !domain) {
- throw new ServiceAccountSecretError(
- 'apiToken and domain are required for Atlassian service account credentials'
- )
- }
- const normalizedDomain = normalizeAtlassianDomain(domain)
- const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain)
- const blob = JSON.stringify({
- type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
- apiToken,
- domain: normalizedDomain,
- cloudId: validation.cloudId,
- atlassianAccountId: validation.accountId,
- })
- const { encrypted } = await encryptSecret(blob)
- return {
- providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
- encryptedServiceAccountKey: encrypted,
- displayName: validation.displayName,
- auditMetadata: {
- atlassianDomain: normalizedDomain,
- atlassianCloudId: validation.cloudId,
- },
- }
+ const { apiToken, domain } = fields
+ if (!apiToken || !domain) {
+ throw new ServiceAccountSecretError(
+ 'apiToken and domain are required for Atlassian service account credentials'
+ )
}
+ const normalizedDomain = normalizeAtlassianDomain(domain)
+ const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain)
+ const blob = JSON.stringify({
+ type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
+ apiToken,
+ domain: normalizedDomain,
+ cloudId: validation.cloudId,
+ atlassianAccountId: validation.accountId,
+ })
+ const { encrypted } = await encryptSecret(blob)
+ return {
+ providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ encryptedServiceAccountKey: encrypted,
+ displayName: validation.displayName,
+ auditMetadata: {
+ atlassianDomain: normalizedDomain,
+ atlassianCloudId: validation.cloudId,
+ },
+ }
+}
- if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
- const { signingSecret, botToken } = fields
- if (!signingSecret || !botToken) {
- throw new ServiceAccountSecretError(
- 'signingSecret and botToken are required for a custom Slack bot credential'
- )
- }
- // Verify the token and derive the workspace/team identity (never trusted
- // from the client) via auth.test.
- let teamId: string
- let botUserId: string | undefined
- let teamName: string | undefined
- try {
- const auth = await fetchSlackTeamId(botToken)
- teamId = auth.teamId
- botUserId = auth.userId
- teamName = auth.teamName
- } catch (error) {
- throw new ServiceAccountSecretError(
- `Could not verify the Slack bot token: ${getErrorMessage(error)}`
- )
- }
- const blob = JSON.stringify({
- type: SLACK_CUSTOM_BOT_SECRET_TYPE,
- signingSecret,
- botToken,
- teamId,
- botUserId,
- teamName,
- })
- const { encrypted } = await encryptSecret(blob)
- return {
- providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
- encryptedServiceAccountKey: encrypted,
- displayName: teamName || 'Slack bot',
- auditMetadata: { slackTeamId: teamId },
- botUserId,
- }
+/**
+ * Builds a custom Slack bot secret. The workspace/team identity is derived via
+ * `auth.test` and never trusted from the client.
+ */
+async function buildSlackCustomBotSecret(
+ fields: ServiceAccountSecretFields
+): Promise {
+ const { signingSecret, botToken } = fields
+ if (!signingSecret || !botToken) {
+ throw new ServiceAccountSecretError(
+ 'signingSecret and botToken are required for a custom Slack bot credential'
+ )
+ }
+ let teamId: string
+ let botUserId: string | undefined
+ let teamName: string | undefined
+ try {
+ const auth = await fetchSlackTeamId(botToken)
+ teamId = auth.teamId
+ botUserId = auth.userId
+ teamName = auth.teamName
+ } catch (error) {
+ throw new ServiceAccountSecretError(
+ `Could not verify the Slack bot token: ${getErrorMessage(error)}`
+ )
+ }
+ const blob = JSON.stringify({
+ type: SLACK_CUSTOM_BOT_SECRET_TYPE,
+ signingSecret,
+ botToken,
+ teamId,
+ botUserId,
+ teamName,
+ })
+ const { encrypted } = await encryptSecret(blob)
+ return {
+ providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
+ encryptedServiceAccountKey: encrypted,
+ displayName: teamName || 'Slack bot',
+ auditMetadata: { slackTeamId: teamId },
+ botUserId,
}
+}
+/**
+ * Builds a Google service-account secret from a pasted JSON key. Also the
+ * fallback for creates without a `providerId` — the original service-account
+ * flow predates multi-provider support.
+ */
+async function buildGoogleServiceAccountSecret(
+ fields: ServiceAccountSecretFields
+): Promise {
const { serviceAccountJson } = fields
if (!serviceAccountJson) {
throw new ServiceAccountSecretError(
@@ -134,9 +150,96 @@ export async function verifyAndBuildServiceAccountSecret(
}
const { encrypted } = await encryptSecret(serviceAccountJson)
return {
- providerId: 'google-service-account',
+ providerId: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
encryptedServiceAccountKey: encrypted,
displayName: jsonParseResult.data.client_email,
auditMetadata: {},
}
}
+
+/**
+ * Builds a token-paste service-account secret for any provider registered in
+ * `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`: verifies the pasted token via the
+ * provider's registered validator and persists it (plus any normalized domain
+ * and non-secret metadata) in the encrypted blob.
+ */
+async function buildTokenServiceAccountSecret(
+ providerId: string,
+ fields: ServiceAccountSecretFields
+): Promise {
+ const descriptor = getTokenServiceAccountDescriptor(providerId)
+ const validator = getTokenServiceAccountValidator(providerId)
+ if (!descriptor || !validator) {
+ throw new ServiceAccountSecretError(
+ `No validator registered for service-account provider ${providerId}`
+ )
+ }
+ const apiToken = fields.apiToken?.trim()
+ const domain = fields.domain?.trim()
+ const requiresDomain = descriptor.fields.some((field) => field.id === 'domain')
+ if (!apiToken || (requiresDomain && !domain)) {
+ const required = descriptor.fields.map((field) => field.id).join(' and ')
+ throw new ServiceAccountSecretError(
+ `${required} ${descriptor.fields.length > 1 ? 'are' : 'is'} required for ${descriptor.serviceLabel} service account credentials`
+ )
+ }
+ const validation = await validator({ apiToken, domain })
+ const blob: TokenServiceAccountSecretBlob = {
+ type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE,
+ providerId,
+ apiToken,
+ ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}),
+ ...(validation.storedMetadata ? { metadata: validation.storedMetadata } : {}),
+ }
+ const { encrypted } = await encryptSecret(JSON.stringify(blob))
+ return {
+ providerId,
+ encryptedServiceAccountKey: encrypted,
+ displayName: validation.displayName,
+ auditMetadata: validation.auditMetadata,
+ }
+}
+
+type ServiceAccountSecretBuilder = (
+ fields: ServiceAccountSecretFields
+) => Promise
+
+/**
+ * Builder registry for the bespoke service-account providers. Token-paste
+ * providers resolve through `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS` instead of
+ * individual entries here.
+ */
+const SERVICE_ACCOUNT_SECRET_BUILDERS: Record = {
+ [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: buildAtlassianServiceAccountSecret,
+ [SLACK_CUSTOM_BOT_PROVIDER_ID]: buildSlackCustomBotSecret,
+ [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: buildGoogleServiceAccountSecret,
+}
+
+/**
+ * Verifies a service-account secret against its provider, derives the display
+ * name, and returns the encrypted blob ready to persist. Shared by credential
+ * create (POST) and in-place reconnect (PUT) so both paths verify + encrypt
+ * identically. Dispatches through the builder registry (bespoke providers)
+ * or the token-paste registry; an unknown/missing provider falls back to the
+ * Google JSON-key builder for legacy creates. Throws
+ * {@link ServiceAccountSecretError} on missing fields or a failed provider
+ * verification (callers map it to a 400).
+ */
+export async function verifyAndBuildServiceAccountSecret(
+ providerId: string,
+ fields: ServiceAccountSecretFields
+): Promise {
+ const builder = Object.hasOwn(SERVICE_ACCOUNT_SECRET_BUILDERS, providerId)
+ ? SERVICE_ACCOUNT_SECRET_BUILDERS[providerId]
+ : undefined
+ if (builder) return builder(fields)
+ if (isTokenServiceAccountProviderId(providerId)) {
+ return buildTokenServiceAccountSecret(providerId, fields)
+ }
+ if (!providerId) {
+ // Legacy Google creates omit providerId entirely (the original flow
+ // predates multi-provider support).
+ return buildGoogleServiceAccountSecret(fields)
+ }
+ throw new ServiceAccountSecretError(`Unsupported service-account provider: ${providerId}`)
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts
new file mode 100644
index 00000000000..eac00def403
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts
@@ -0,0 +1,334 @@
+/**
+ * Client-safe descriptors for token-paste service-account providers.
+ *
+ * A token service account is a `service_account`-type credential where a
+ * workspace admin pastes a long-lived provider token (private-app token, PAT,
+ * API key, …) instead of running an OAuth flow — mirroring the Atlassian
+ * service-account pattern: the token is verified once server-side, encrypted,
+ * and returned as the access token at execution time with no exchange or
+ * refresh. This module holds only UI/contract metadata (field lists, labels,
+ * docs links); server-side verification lives in
+ * `@/lib/credentials/token-service-accounts/server`.
+ */
+
+/** Discriminator stored inside every encrypted token service-account secret blob. */
+export const TOKEN_SERVICE_ACCOUNT_SECRET_TYPE = 'token_service_account' as const
+
+/** Contract field ids a token service-account modal may collect. */
+export type TokenServiceAccountFieldId = 'apiToken' | 'domain'
+
+export interface TokenServiceAccountField {
+ id: TokenServiceAccountFieldId
+ label: string
+ placeholder: string
+ /** Rendered with SecretInput and never echoed back. */
+ secret: boolean
+ /** Soft-format hint shown while the current value doesn't match `hintPattern`. */
+ hintPattern?: RegExp
+ hintMessage?: string
+}
+
+export interface TokenServiceAccountDescriptor {
+ /** Stable credential `providerId` (`-service-account`). */
+ providerId: string
+ /** Human service label used in modal copy and error messages (e.g. "HubSpot"). */
+ serviceLabel: string
+ /** Vendor noun for the pasted secret (e.g. "private app access token"). */
+ tokenNoun: string
+ /**
+ * Short vendor-accurate noun for connect-surface labels ("Add {connectNoun}").
+ * These providers don't have literal "service accounts" — the UI uses the
+ * vendor's own vocabulary for the credential.
+ */
+ connectNoun: string
+ fields: TokenServiceAccountField[]
+ /** Sim setup guide, docked bottom-left of the connect modal. */
+ docsUrl: string
+ /** Optional one-line caveat rendered under the token field. */
+ helpText?: string
+}
+
+export const HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID = 'hubspot-service-account' as const
+export const AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID = 'airtable-service-account' as const
+export const NOTION_SERVICE_ACCOUNT_PROVIDER_ID = 'notion-service-account' as const
+export const ASANA_SERVICE_ACCOUNT_PROVIDER_ID = 'asana-service-account' as const
+export const ATTIO_SERVICE_ACCOUNT_PROVIDER_ID = 'attio-service-account' as const
+export const LINEAR_SERVICE_ACCOUNT_PROVIDER_ID = 'linear-service-account' as const
+export const MONDAY_SERVICE_ACCOUNT_PROVIDER_ID = 'monday-service-account' as const
+export const SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID = 'shopify-service-account' as const
+export const WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID = 'webflow-service-account' as const
+export const TRELLO_SERVICE_ACCOUNT_PROVIDER_ID = 'trello-service-account' as const
+export const CALCOM_SERVICE_ACCOUNT_PROVIDER_ID = 'calcom-service-account' as const
+export const WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID = 'wealthbox-service-account' as const
+
+const SHOPIFY_DOMAIN_HINT_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i
+
+export type TokenServiceAccountProviderId =
+ | typeof HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof NOTION_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof ASANA_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof ATTIO_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof LINEAR_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof MONDAY_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof TRELLO_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof CALCOM_SERVICE_ACCOUNT_PROVIDER_ID
+ | typeof WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID
+
+export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
+ TokenServiceAccountProviderId,
+ TokenServiceAccountDescriptor
+> = {
+ [HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'HubSpot',
+ tokenNoun: 'private app access token',
+ connectNoun: 'private app token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Private app access token',
+ placeholder: 'pat-na1-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ secret: true,
+ hintPattern: /^pat-/i,
+ hintMessage: 'HubSpot private app tokens usually start with pat-.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/hubspot-service-account',
+ helpText:
+ 'Tokens are tied to the super admin who created the private app; if that user is removed from the portal, some calls may start failing.',
+ },
+ [AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Airtable',
+ tokenNoun: 'personal access token',
+ connectNoun: 'personal access token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Personal access token',
+ placeholder: 'pat...',
+ secret: true,
+ hintPattern: /^pat/i,
+ hintMessage: 'Airtable personal access tokens usually start with pat.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/airtable-service-account',
+ helpText:
+ 'Enterprise Scale service-account tokens work here too — they use the same format as personal access tokens.',
+ },
+ [NOTION_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: NOTION_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Notion',
+ tokenNoun: 'internal integration secret',
+ connectNoun: 'integration secret',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Internal integration secret',
+ placeholder: 'ntn_...',
+ secret: true,
+ hintPattern: /^(ntn_|secret_)/,
+ hintMessage: 'Notion integration secrets usually start with ntn_.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/notion-service-account',
+ helpText:
+ 'Newer Notion UIs label the secret "installation access token". Remember to connect the integration to the pages and databases it should access — a valid secret with no page connections can read nothing.',
+ },
+ [ASANA_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: ASANA_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Asana',
+ tokenNoun: 'access token',
+ connectNoun: 'access token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Access token',
+ placeholder: 'Paste a service account token or personal access token',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/asana-service-account',
+ helpText:
+ 'Enterprise service account tokens and personal access tokens both work — they use the same format.',
+ },
+ [ATTIO_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: ATTIO_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Attio',
+ tokenNoun: 'API key',
+ connectNoun: 'API key',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API key',
+ placeholder: 'Paste workspace API key',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/attio-service-account',
+ helpText:
+ 'Check the scopes granted to the key in Attio — tools whose scopes are missing will fail at run time.',
+ },
+ [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: LINEAR_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Linear',
+ tokenNoun: 'API key',
+ connectNoun: 'API key',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API key',
+ placeholder: 'lin_api_...',
+ secret: true,
+ hintPattern: /^lin_api_/,
+ hintMessage: 'Linear personal API keys start with lin_api_.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/linear-service-account',
+ },
+ [MONDAY_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: MONDAY_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'monday.com',
+ tokenNoun: 'API token',
+ connectNoun: 'API token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API token',
+ placeholder: 'Paste personal API token',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/monday-service-account',
+ helpText:
+ 'monday.com issues one API token per user — regenerating it in monday breaks every integration using the old token.',
+ },
+ [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Shopify',
+ tokenNoun: 'Admin API access token',
+ connectNoun: 'admin API token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Admin API access token',
+ placeholder: 'shpat_...',
+ secret: true,
+ hintPattern: /^shpat_/,
+ hintMessage: 'Shopify Admin API access tokens usually start with shpat_.',
+ },
+ {
+ id: 'domain',
+ label: 'Store domain',
+ placeholder: 'your-store.myshopify.com',
+ secret: false,
+ hintPattern: SHOPIFY_DOMAIN_HINT_REGEX,
+ hintMessage: 'Shopify store domains look like your-store.myshopify.com.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account',
+ helpText:
+ 'Legacy admin-created custom apps reveal the shpat_ token once; new Dev Dashboard apps issue tokens via OAuth, not a UI reveal. The token is store-bound and does not expire.',
+ },
+ [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Webflow',
+ tokenNoun: 'site API token',
+ connectNoun: 'site token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Site API token',
+ placeholder: 'Paste site API token',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/webflow-service-account',
+ helpText:
+ 'Create the token with at least the sites:read and CMS read/write scopes. Site tokens expire after 365 days without API activity, and each token grants access to a single site.',
+ },
+ [TRELLO_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: TRELLO_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Trello',
+ tokenNoun: 'API token',
+ connectNoun: 'API token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API token',
+ placeholder: 'ATTA...',
+ secret: true,
+ hintPattern: /^ATTA/,
+ hintMessage: 'Trello API tokens usually start with ATTA.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/trello-service-account',
+ helpText:
+ "Generate the token with the setup guide's authorize link (expiration=never) so it works with Sim and doesn't expire.",
+ },
+ [CALCOM_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: CALCOM_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Cal.com',
+ tokenNoun: 'API key',
+ connectNoun: 'API key',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API key',
+ placeholder: 'cal_live_...',
+ secret: true,
+ hintPattern: /^cal_/,
+ hintMessage: 'Cal.com API keys usually start with cal_.',
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/calcom-service-account',
+ helpText: 'Choose a non-expiring key (or note the expiry date) when creating it in Cal.com.',
+ },
+ [WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID]: {
+ providerId: WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID,
+ serviceLabel: 'Wealthbox',
+ tokenNoun: 'API access token',
+ connectNoun: 'access token',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'API access token',
+ placeholder: 'Paste API access token',
+ secret: true,
+ },
+ ],
+ docsUrl: 'https://docs.sim.ai/integrations/wealthbox-service-account',
+ helpText:
+ 'Trial accounts cannot use the Wealthbox API; contact Wealthbox support if API Access is missing from your Settings.',
+ },
+}
+
+/**
+ * Required contract fields per token service-account provider, consumed by the
+ * `createCredentialBodySchema` superRefine so validation errors name the exact
+ * missing field. Derived from each descriptor's field list.
+ */
+export const TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS: Record =
+ Object.fromEntries(
+ Object.values(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS).map((descriptor) => [
+ descriptor.providerId,
+ descriptor.fields.map((field) => field.id),
+ ])
+ )
+
+export function isTokenServiceAccountProviderId(
+ value: string | null | undefined
+): value is TokenServiceAccountProviderId {
+ return Boolean(value && Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, value))
+}
+
+export function getTokenServiceAccountDescriptor(
+ providerId: string | null | undefined
+): TokenServiceAccountDescriptor | undefined {
+ return isTokenServiceAccountProviderId(providerId)
+ ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[providerId]
+ : undefined
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.test.ts b/apps/sim/lib/credentials/token-service-accounts/errors.test.ts
new file mode 100644
index 00000000000..2292b102dad
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/errors.test.ts
@@ -0,0 +1,137 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+
+const mockFetch = vi.fn()
+
+const PROVIDER_URL = 'https://api.example-provider.com/v1/self'
+
+async function expectValidationError(
+ promise: Promise
+): Promise {
+ const error = await promise.then(
+ () => {
+ throw new Error('expected a TokenServiceAccountValidationError to be thrown')
+ },
+ (e: unknown) => e
+ )
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ return error as TokenServiceAccountValidationError
+}
+
+describe('token service-account error helpers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ describe('fetchProvider', () => {
+ it('maps a rejected fetch to provider_unavailable 502', async () => {
+ mockFetch.mockRejectedValue(new TypeError('fetch failed'))
+
+ const error = await expectValidationError(fetchProvider(PROVIDER_URL, {}, 'self'))
+
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ expect(error.logDetail).toEqual({ step: 'self', reason: 'network error reaching provider' })
+ })
+
+ it('never includes the URL in logDetail', async () => {
+ mockFetch.mockRejectedValue(new Error(`ECONNREFUSED ${PROVIDER_URL}`))
+
+ const error = await expectValidationError(fetchProvider(PROVIDER_URL, {}, 'self'))
+
+ expect(JSON.stringify(error.logDetail)).not.toContain(PROVIDER_URL)
+ expect(JSON.stringify(error.logDetail)).not.toContain('example-provider')
+ })
+
+ it('returns the response untouched when fetch resolves', async () => {
+ const res = new Response('ok', { status: 200 })
+ mockFetch.mockResolvedValue(res)
+
+ await expect(fetchProvider(PROVIDER_URL, {}, 'self')).resolves.toBe(res)
+ })
+ })
+
+ describe('parseProviderJson', () => {
+ it('maps a non-JSON body to provider_unavailable 502', async () => {
+ const res = new Response('502 Bad Gateway', { status: 200 })
+
+ const error = await expectValidationError(parseProviderJson(res, 'self'))
+
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ expect(error.logDetail).toEqual({
+ step: 'self',
+ reason: 'provider returned a non-JSON response body',
+ })
+ })
+
+ it('returns the parsed body for valid JSON', async () => {
+ const res = new Response(JSON.stringify({ id: 'acct-1' }), { status: 200 })
+
+ await expect(parseProviderJson(res, 'self')).resolves.toEqual({ id: 'acct-1' })
+ })
+ })
+
+ describe('throwForProviderResponse', () => {
+ it.each([401, 403])(
+ 'maps %i to invalid_credentials with the response status',
+ async (status) => {
+ const res = new Response('denied', { status })
+
+ const error = await expectValidationError(throwForProviderResponse(res, 'self'))
+
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(status)
+ }
+ )
+
+ it.each([429, 500, 503])(
+ 'maps %i to provider_unavailable with the response status',
+ async (status) => {
+ const res = new Response('provider trouble', { status })
+
+ const error = await expectValidationError(throwForProviderResponse(res, 'self'))
+
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(status)
+ }
+ )
+
+ it('returns without throwing on a 2xx response', async () => {
+ const res = new Response('ok', { status: 200 })
+
+ await expect(throwForProviderResponse(res, 'self')).resolves.toBeUndefined()
+ })
+ })
+
+ describe('readProviderErrorSnippet', () => {
+ it('truncates the body to 500 characters', async () => {
+ const res = new Response('x'.repeat(2000), { status: 500 })
+
+ const snippet = await readProviderErrorSnippet(res)
+
+ expect(snippet).toBe(`${'x'.repeat(500)}...`)
+ })
+
+ it('never throws on an unreadable body', async () => {
+ const res = new Response('gone', { status: 500 })
+ vi.spyOn(res, 'text').mockRejectedValue(new Error('body stream already read'))
+
+ await expect(readProviderErrorSnippet(res)).resolves.toBe('')
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts
new file mode 100644
index 00000000000..1c8e1114531
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts
@@ -0,0 +1,102 @@
+import { truncate } from '@sim/utils/string'
+
+/**
+ * Discrete validation failure codes returned to the client for token
+ * service-account credentials. The UI maps each code to a human message; raw
+ * provider response bodies stay in server logs.
+ */
+export type TokenServiceAccountValidationCode =
+ | 'invalid_credentials'
+ | 'site_not_found'
+ | 'provider_unavailable'
+
+export class TokenServiceAccountValidationError extends Error {
+ constructor(
+ public readonly code: TokenServiceAccountValidationCode,
+ public readonly status: number,
+ public readonly logDetail?: Record
+ ) {
+ super(code)
+ this.name = 'TokenServiceAccountValidationError'
+ }
+}
+
+const ERROR_SNIPPET_MAX_LENGTH = 500
+
+/**
+ * Fetches a provider verification endpoint, mapping network-level failures
+ * (DNS, TLS, connection reset) to `provider_unavailable` so they never escape
+ * as raw undici errors — whose `cause` can carry connection details — and are
+ * never blamed on the pasted token.
+ */
+const PROVIDER_FETCH_TIMEOUT_MS = 10_000
+
+export async function fetchProvider(
+ url: string,
+ init: RequestInit,
+ step: string
+): Promise {
+ try {
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS) })
+ } catch {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step,
+ reason: 'network error reaching provider',
+ })
+ }
+}
+
+/**
+ * Parses a provider response body as JSON, mapping malformed bodies (proxy
+ * error pages, truncated responses) to `provider_unavailable` instead of an
+ * unhandled SyntaxError that would surface as a generic 500.
+ */
+export async function parseProviderJson(res: Response, step: string): Promise {
+ try {
+ return (await res.json()) as T
+ } catch {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step,
+ reason: 'provider returned a non-JSON response body',
+ })
+ }
+}
+
+/**
+ * Reads a bounded snippet of a provider error body for server logs. Never
+ * throws — an unreadable body logs as an empty string.
+ */
+export async function readProviderErrorSnippet(res: Response): Promise {
+ try {
+ return truncate(await res.text(), ERROR_SNIPPET_MAX_LENGTH)
+ } catch {
+ return ''
+ }
+}
+
+/**
+ * Maps a failed provider verification response to the standard error split:
+ * 401/403 mean the pasted token was rejected (`invalid_credentials`), anything
+ * else non-2xx means the provider couldn't be reached or misbehaved
+ * (`provider_unavailable`) — never blame the token for a provider outage.
+ */
+export async function throwForProviderResponse(
+ res: Response,
+ step: string,
+ context: Record = {}
+): Promise {
+ if (res.ok) return
+ const body = await readProviderErrorSnippet(res)
+ if (res.status === 401 || res.status === 403) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step,
+ body,
+ ...context,
+ })
+ }
+ throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
+ step,
+ body,
+ ...context,
+ })
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts
new file mode 100644
index 00000000000..c0a2ed16d1d
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/server.ts
@@ -0,0 +1,113 @@
+import {
+ AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID,
+ ASANA_SERVICE_ACCOUNT_PROVIDER_ID,
+ ATTIO_SERVICE_ACCOUNT_PROVIDER_ID,
+ CALCOM_SERVICE_ACCOUNT_PROVIDER_ID,
+ HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID,
+ isTokenServiceAccountProviderId,
+ LINEAR_SERVICE_ACCOUNT_PROVIDER_ID,
+ MONDAY_SERVICE_ACCOUNT_PROVIDER_ID,
+ NOTION_SERVICE_ACCOUNT_PROVIDER_ID,
+ SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID,
+ TOKEN_SERVICE_ACCOUNT_SECRET_TYPE,
+ type TokenServiceAccountProviderId,
+ TRELLO_SERVICE_ACCOUNT_PROVIDER_ID,
+ WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID,
+ WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID,
+} from '@/lib/credentials/token-service-accounts/descriptors'
+import { validateAirtableServiceAccount } from '@/lib/credentials/token-service-accounts/validators/airtable'
+import { validateAsanaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/asana'
+import { validateAttioServiceAccount } from '@/lib/credentials/token-service-accounts/validators/attio'
+import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-accounts/validators/calcom'
+import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot'
+import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear'
+import { validateMondayServiceAccount } from '@/lib/credentials/token-service-accounts/validators/monday'
+import { validateNotionServiceAccount } from '@/lib/credentials/token-service-accounts/validators/notion'
+import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify'
+import { validateTrelloServiceAccount } from '@/lib/credentials/token-service-accounts/validators/trello'
+import { validateWealthboxServiceAccount } from '@/lib/credentials/token-service-accounts/validators/wealthbox'
+import { validateWebflowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/webflow'
+
+/** Raw fields a token service-account validator receives (already trimmed). */
+export interface TokenServiceAccountFields {
+ apiToken: string
+ domain?: string
+}
+
+/** Result of a successful provider verification. */
+export interface TokenServiceAccountValidationResult {
+ /** Default display name when the user didn't provide one. */
+ displayName: string
+ /** Non-secret identifiers recorded in the audit log (e.g. portal/workspace id). */
+ auditMetadata: Record
+ /**
+ * Non-secret metadata persisted inside the encrypted blob alongside the
+ * token (e.g. normalized store domain, portal id) for later debugging.
+ */
+ storedMetadata?: Record
+ /** Normalized domain to persist instead of the raw user input (when collected). */
+ normalizedDomain?: string
+}
+
+export type TokenServiceAccountValidator = (
+ fields: TokenServiceAccountFields
+) => Promise
+
+/**
+ * Server-side verification registry for token service-account providers. Keys
+ * must stay in lockstep with `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS` — a
+ * descriptor without a validator fails loudly at create time.
+ */
+const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record<
+ TokenServiceAccountProviderId,
+ TokenServiceAccountValidator
+> = {
+ [HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID]: validateHubspotServiceAccount,
+ [AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID]: validateAirtableServiceAccount,
+ [NOTION_SERVICE_ACCOUNT_PROVIDER_ID]: validateNotionServiceAccount,
+ [ASANA_SERVICE_ACCOUNT_PROVIDER_ID]: validateAsanaServiceAccount,
+ [ATTIO_SERVICE_ACCOUNT_PROVIDER_ID]: validateAttioServiceAccount,
+ [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: validateLinearServiceAccount,
+ [MONDAY_SERVICE_ACCOUNT_PROVIDER_ID]: validateMondayServiceAccount,
+ [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: validateShopifyServiceAccount,
+ [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: validateWebflowServiceAccount,
+ [TRELLO_SERVICE_ACCOUNT_PROVIDER_ID]: validateTrelloServiceAccount,
+ [CALCOM_SERVICE_ACCOUNT_PROVIDER_ID]: validateCalcomServiceAccount,
+ [WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID]: validateWealthboxServiceAccount,
+}
+
+export function getTokenServiceAccountValidator(
+ providerId: string
+): TokenServiceAccountValidator | undefined {
+ return isTokenServiceAccountProviderId(providerId)
+ ? TOKEN_SERVICE_ACCOUNT_VALIDATORS[providerId]
+ : undefined
+}
+
+/**
+ * Shape of the decrypted secret blob persisted for token service accounts.
+ * `providerId` is stored inside the blob so a mismatched credential row fails
+ * loudly at resolution time instead of returning another provider's token.
+ */
+export interface TokenServiceAccountSecretBlob {
+ type: typeof TOKEN_SERVICE_ACCOUNT_SECRET_TYPE
+ providerId: string
+ apiToken: string
+ domain?: string
+ metadata?: Record
+}
+
+export function parseTokenServiceAccountSecretBlob(
+ decrypted: string,
+ expectedProviderId: string
+): TokenServiceAccountSecretBlob {
+ const parsed = JSON.parse(decrypted) as TokenServiceAccountSecretBlob
+ if (
+ parsed.type !== TOKEN_SERVICE_ACCOUNT_SECRET_TYPE ||
+ parsed.providerId !== expectedProviderId ||
+ !parsed.apiToken
+ ) {
+ throw new Error('Stored token service-account secret is malformed')
+ }
+ return parsed
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts
new file mode 100644
index 00000000000..b1f4797b509
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.test.ts
@@ -0,0 +1,91 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateAirtableServiceAccount } from '@/lib/credentials/token-service-accounts/validators/airtable'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateAirtableServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns email display name and metadata on success with email', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ id: 'usrABC123',
+ email: 'svc@example.com',
+ scopes: ['data.records:read'],
+ })
+ )
+
+ const result = await validateAirtableServiceAccount({ apiToken: 'pat123.secret' })
+
+ expect(result).toEqual({
+ displayName: 'svc@example.com',
+ auditMetadata: { airtableUserId: 'usrABC123' },
+ storedMetadata: { userId: 'usrABC123', scopes: 'data.records:read' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.airtable.com/v0/meta/whoami', {
+ headers: {
+ Authorization: 'Bearer pat123.secret',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('falls back to id display name when email is absent', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { id: 'usrXYZ789' }))
+
+ const result = await validateAirtableServiceAccount({ apiToken: 'pat456.secret' })
+
+ expect(result.displayName).toBe('Airtable user usrXYZ789')
+ expect(result.auditMetadata).toEqual({ airtableUserId: 'usrXYZ789' })
+ expect(result.storedMetadata).toEqual({ userId: 'usrXYZ789' })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('', { status: 401 }))
+
+ const error = await validateAirtableServiceAccount({ apiToken: 'bad' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('throws provider_unavailable when a 200 body is missing id', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { email: 'svc@example.com' }))
+
+ const error = await validateAirtableServiceAccount({ apiToken: 'pat' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('server error', { status: 500 }))
+
+ const error = await validateAirtableServiceAccount({ apiToken: 'pat' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts
new file mode 100644
index 00000000000..ccab65691eb
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/airtable.ts
@@ -0,0 +1,60 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+/**
+ * Validates an Airtable personal access token (including Enterprise
+ * service-account PATs) by calling the `whoami` meta endpoint, which requires
+ * no scopes. Airtable documents no body shape for 401 responses, so failures
+ * key off the status code alone: 401/403 mean the token was rejected, any
+ * other non-2xx means Airtable is unavailable.
+ *
+ * `email` is present only when the token has the `user.email:read` scope, and
+ * `scopes` is returned only for OAuth access tokens (never PATs) — both are
+ * optional and their absence is not a failure.
+ */
+export async function validateAirtableServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const whoamiRes = await fetchProvider(
+ 'https://api.airtable.com/v0/meta/whoami',
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'whoami'
+ )
+ await throwForProviderResponse(whoamiRes, 'whoami')
+
+ const whoami = await parseProviderJson<{
+ id?: string
+ email?: string
+ scopes?: string[]
+ }>(whoamiRes, 'whoami')
+ if (!whoami.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'whoami',
+ reason: 'missing id in response',
+ })
+ }
+
+ const storedMetadata: Record = { userId: whoami.id }
+ if (whoami.scopes) {
+ storedMetadata.scopes = whoami.scopes.join(' ')
+ }
+
+ return {
+ displayName: whoami.email ?? `Airtable user ${whoami.id}`,
+ auditMetadata: { airtableUserId: whoami.id },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts
new file mode 100644
index 00000000000..6e950a55002
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.test.ts
@@ -0,0 +1,107 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateAsanaServiceAccount } from '@/lib/credentials/token-service-accounts/validators/asana'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateAsanaServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ mockFetch.mockReset()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns display name and metadata on success', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, {
+ data: { gid: '12345', name: 'Sim Integration', email: 'bot@example.com' },
+ })
+ )
+
+ const result = await validateAsanaServiceAccount({ apiToken: 'token-1' })
+
+ expect(result).toEqual({
+ displayName: 'Sim Integration',
+ auditMetadata: { asanaUserGid: '12345' },
+ storedMetadata: { userGid: '12345', email: 'bot@example.com' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://app.asana.com/api/1.0/users/me?opt_fields=gid,name,email',
+ {
+ headers: {
+ Authorization: 'Bearer token-1',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ }
+ )
+ })
+
+ it('falls back to email then gid when name is missing', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, { data: { gid: '999', email: 'fallback@example.com' } })
+ )
+
+ const withEmail = await validateAsanaServiceAccount({ apiToken: 'token-2' })
+ expect(withEmail.displayName).toBe('fallback@example.com')
+
+ mockFetch.mockResolvedValue(jsonResponse(200, { data: { gid: '999' } }))
+
+ const gidOnly = await validateAsanaServiceAccount({ apiToken: 'token-2' })
+ expect(gidOnly.displayName).toBe('Asana user 999')
+ expect(gidOnly.storedMetadata).toEqual({ userGid: '999' })
+ })
+
+ it('maps 401 to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, { errors: [{ message: 'Not Authorized' }] }))
+
+ const error = await validateAsanaServiceAccount({ apiToken: 'bad-token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('maps 500 to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(500, { errors: [{ message: 'Server error' }] }))
+
+ const error = await validateAsanaServiceAccount({ apiToken: 'token-3' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('maps a non-JSON 200 response to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(new Response('gateway', { status: 200 }))
+
+ const error = await validateAsanaServiceAccount({ apiToken: 'token-5' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('maps a 200 response missing data.gid to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(200, { data: { name: 'No Gid' } }))
+
+ const error = await validateAsanaServiceAccount({ apiToken: 'token-4' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts
new file mode 100644
index 00000000000..c138258ee35
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/asana.ts
@@ -0,0 +1,62 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const ASANA_ME_URL = 'https://app.asana.com/api/1.0/users/me?opt_fields=gid,name,email'
+
+interface AsanaMeResponse {
+ data?: {
+ gid?: string
+ name?: string
+ email?: string
+ }
+}
+
+/**
+ * Validates an Asana service-account (or personal access) token by calling
+ * `GET /users/me`. Asana tokens are documented as opaque, so no format checks
+ * are performed — the live API call is the only gate. 401/403 mean the token
+ * was rejected; any other non-2xx means Asana is unavailable.
+ */
+export async function validateAsanaServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ ASANA_ME_URL,
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'users_me'
+ )
+ await throwForProviderResponse(res, 'users_me')
+
+ const body = await parseProviderJson(res, 'users_me')
+ const gid = body.data?.gid
+ if (!gid) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'users_me',
+ reason: 'missing data.gid in response',
+ })
+ }
+
+ const name = body.data?.name
+ const email = body.data?.email
+ const storedMetadata: Record = { userGid: gid }
+ if (email) storedMetadata.email = email
+
+ return {
+ displayName: name || email || `Asana user ${gid}`,
+ auditMetadata: { asanaUserGid: gid },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts
new file mode 100644
index 00000000000..7c193f063e5
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts
@@ -0,0 +1,125 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateAttioServiceAccount } from '@/lib/credentials/token-service-accounts/validators/attio'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+async function expectValidationError(
+ promise: Promise,
+ code: string
+): Promise {
+ const error = await promise.then(
+ () => {
+ throw new Error('expected validation to throw')
+ },
+ (e: unknown) => e
+ )
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect((error as TokenServiceAccountValidationError).code).toBe(code)
+ return error as TokenServiceAccountValidationError
+}
+
+describe('validateAttioServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns workspace info on an active token', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse({
+ active: true,
+ workspace_id: 'ws-123',
+ workspace_name: 'Acme CRM',
+ workspace_slug: 'acme-crm',
+ })
+ )
+
+ const result = await validateAttioServiceAccount({ apiToken: 'attio-token' })
+
+ expect(mockFetch).toHaveBeenCalledWith('https://api.attio.com/v2/self', {
+ headers: {
+ Authorization: 'Bearer attio-token',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ expect(result).toEqual({
+ displayName: 'Acme CRM',
+ auditMetadata: { attioWorkspaceId: 'ws-123' },
+ storedMetadata: { workspaceId: 'ws-123', workspaceSlug: 'acme-crm' },
+ })
+ })
+
+ it('maps a 401 to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(jsonResponse({ error: 'unauthorized' }, 401))
+
+ await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'bad-token' }),
+ 'invalid_credentials'
+ )
+ })
+
+ it('maps a 400 (malformed token not recognised) to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse({ status_code: 400, type: 'invalid_request_error' }, 400)
+ )
+
+ const error = await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'not-a-real-key' }),
+ 'invalid_credentials'
+ )
+ expect(error.status).toBe(400)
+ })
+
+ it('maps a 500 to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse({ error: 'boom' }, 500))
+
+ await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'attio-token' }),
+ 'provider_unavailable'
+ )
+ })
+
+ it('maps a malformed 200 (missing workspace fields) to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse({ unexpected: 'shape' }))
+
+ await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'attio-token' }),
+ 'provider_unavailable'
+ )
+ })
+
+ it('maps a JSON-valid but non-object 200 body (null) to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(null))
+
+ const error = await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'attio-token' }),
+ 'provider_unavailable'
+ )
+ expect(error.logDetail).toEqual({ step: 'self', reason: 'non-object response body' })
+ })
+
+ it('maps a revoked token (active === false) to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(jsonResponse({ active: false }))
+
+ await expectValidationError(
+ validateAttioServiceAccount({ apiToken: 'revoked-token' }),
+ 'invalid_credentials'
+ )
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts
new file mode 100644
index 00000000000..1969439c4ac
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.ts
@@ -0,0 +1,82 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const ATTIO_SELF_URL = 'https://api.attio.com/v2/self'
+
+interface AttioSelfResponse {
+ active?: boolean
+ workspace_id?: string
+ workspace_name?: string
+ workspace_slug?: string
+}
+
+/**
+ * Validates an Attio workspace access token against the identify endpoint
+ * (`GET /v2/self`). Attio returns a minimal `{ active: false }` body for a
+ * revoked/deleted token, so a 2xx alone is never trusted — the body must
+ * assert `active === true` and carry the workspace identifiers. Attio replies
+ * HTTP 400 ("Token was not recognised") for a malformed pasted key; since the
+ * request carries no user input besides the bearer token, a 400 here is
+ * treated as `invalid_credentials` rather than a provider fault.
+ */
+export async function validateAttioServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ ATTIO_SELF_URL,
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'self'
+ )
+ if (res.status === 400) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'self',
+ reason: 'token was not recognised (HTTP 400)',
+ })
+ }
+ await throwForProviderResponse(res, 'self')
+
+ const self = await parseProviderJson(res, 'self')
+ if (typeof self !== 'object' || self === null) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'self',
+ reason: 'non-object response body',
+ })
+ }
+
+ if (self.active === false) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'self',
+ reason: 'token is revoked (active === false)',
+ })
+ }
+ if (self.active !== true || !self.workspace_id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'self',
+ reason: 'response missing active flag or workspace_id',
+ })
+ }
+
+ const storedMetadata: Record = { workspaceId: self.workspace_id }
+ if (self.workspace_slug) {
+ storedMetadata.workspaceSlug = self.workspace_slug
+ }
+
+ return {
+ displayName: self.workspace_name || 'Attio workspace',
+ auditMetadata: { attioWorkspaceId: self.workspace_id },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts
new file mode 100644
index 00000000000..cbd22a5b1a2
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.test.ts
@@ -0,0 +1,95 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateCalcomServiceAccount } from '@/lib/credentials/token-service-accounts/validators/calcom'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateCalcomServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ mockFetch.mockReset()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns display name and metadata on success', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, {
+ status: 'success',
+ data: { id: 42, username: 'sim-bot', email: 'bot@example.com' },
+ })
+ )
+
+ const result = await validateCalcomServiceAccount({ apiToken: 'cal_live_token' })
+
+ expect(result).toEqual({
+ displayName: 'sim-bot',
+ auditMetadata: { calcomUserId: '42' },
+ storedMetadata: { userId: '42', email: 'bot@example.com' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.cal.com/v2/me', {
+ headers: {
+ Authorization: 'Bearer cal_live_token',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('maps 401 to invalid_credentials', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, { status: 'error' }))
+
+ const error = await validateCalcomServiceAccount({ apiToken: 'cal_bad' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('maps 500 to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(500, { message: 'Server error' }))
+
+ const error = await validateCalcomServiceAccount({ apiToken: 'cal_token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('maps a 200 response with a non-JSON body to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(
+ new Response('gateway timeout', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
+ )
+
+ const error = await validateCalcomServiceAccount({ apiToken: 'cal_token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('maps a 200 response with a non-success envelope to provider_unavailable', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(200, { status: 'error', data: { id: 42 } }))
+
+ const error = await validateCalcomServiceAccount({ apiToken: 'cal_token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts
new file mode 100644
index 00000000000..c536bb43b1e
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/calcom.ts
@@ -0,0 +1,65 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const CALCOM_ME_URL = 'https://api.cal.com/v2/me'
+
+interface CalcomMeResponse {
+ status?: string
+ data?: {
+ id?: number
+ username?: string
+ email?: string
+ }
+}
+
+/**
+ * Validates a Cal.com API key by calling `GET /v2/me`. Cal.com accepts an API
+ * key in the same `Authorization: Bearer` slot the tools use for OAuth tokens,
+ * and this endpoint needs no `cal-api-version` header (unlike the per-resource
+ * tool endpoints). 401/403 mean the key was rejected; any other non-2xx — or a
+ * 200 without the standard `{ status: 'success', data }` envelope — means
+ * Cal.com is unavailable.
+ */
+export async function validateCalcomServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ CALCOM_ME_URL,
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'me'
+ )
+ await throwForProviderResponse(res, 'me')
+
+ const body = await parseProviderJson(res, 'me')
+ if (body.status !== 'success' || body.data?.id === undefined) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'me',
+ reason: 'unexpected response envelope from /v2/me',
+ })
+ }
+
+ const userId = String(body.data.id)
+ const username = body.data.username
+ const email = body.data.email
+ const storedMetadata: Record = { userId }
+ if (email) storedMetadata.email = email
+
+ return {
+ displayName: username || email || 'Cal.com account',
+ auditMetadata: { calcomUserId: userId },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts
new file mode 100644
index 00000000000..1259b11ac0a
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts
@@ -0,0 +1,174 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-accounts/validators/hubspot'
+
+const TOKEN_INFO_URL = 'https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info'
+const ACCOUNT_INFO_URL = 'https://api.hubapi.com/account-info/v3/details'
+
+const FIELDS = { apiToken: 'pat-na1-aaaa-bbbb' }
+
+function jsonResponse(status: number, body: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ } as unknown as Response
+}
+
+function htmlResponse(status: number, body: string): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => {
+ throw new SyntaxError('Unexpected token < in JSON')
+ },
+ text: async () => body,
+ } as unknown as Response
+}
+
+const mockFetch = vi.fn()
+
+function expectPrimaryCall(): void {
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe(TOKEN_INFO_URL)
+ expect(init.method).toBe('POST')
+ expect(init.headers.Authorization).toBe('Bearer pat-na1-aaaa-bbbb')
+ expect(init.headers['Content-Type']).toBe('application/json')
+ expect(JSON.parse(init.body)).toEqual({ tokenKey: 'pat-na1-aaaa-bbbb' })
+}
+
+function expectFallbackCall(): void {
+ const [url, init] = mockFetch.mock.calls[1]
+ expect(url).toBe(ACCOUNT_INFO_URL)
+ expect(init.method).toBeUndefined()
+ expect(init.headers.Authorization).toBe('Bearer pat-na1-aaaa-bbbb')
+ expect(init.headers.Accept).toBe('application/json')
+}
+
+describe('validateHubspotServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns displayName and metadata on primary access-token-info success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ userId: 111,
+ hubId: 12345,
+ appId: 222,
+ scopes: ['tickets'],
+ })
+ )
+
+ const result = await validateHubspotServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'HubSpot portal 12345',
+ auditMetadata: { hubspotHubId: '12345' },
+ storedMetadata: { hubId: '12345', appId: '222', userId: '111' },
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ expectPrimaryCall()
+ })
+
+ it('falls back to account-info on primary 404 and succeeds with portalId', async () => {
+ mockFetch
+ .mockResolvedValueOnce(htmlResponse(404, '404 Not Found'))
+ .mockResolvedValueOnce(jsonResponse(200, { portalId: 123, uiDomain: 'app.hubspot.com' }))
+
+ const result = await validateHubspotServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'HubSpot portal 123',
+ auditMetadata: { hubspotHubId: '123' },
+ storedMetadata: { hubId: '123' },
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expectPrimaryCall()
+ expectFallbackCall()
+ })
+
+ it('throws invalid_credentials when primary 404 and fallback returns 401', async () => {
+ mockFetch
+ .mockResolvedValueOnce(htmlResponse(404, '404 Not Found'))
+ .mockResolvedValueOnce(
+ jsonResponse(401, { status: 'error', category: 'INVALID_AUTHENTICATION' })
+ )
+
+ await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expectPrimaryCall()
+ expectFallbackCall()
+ })
+
+ it('treats primary 400 with fallback 403 as a live token without account-info access', async () => {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse(400, { status: 'error', message: 'bad request' }))
+ .mockResolvedValueOnce(jsonResponse(403, { status: 'error', category: 'MISSING_SCOPES' }))
+
+ const result = await validateHubspotServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'HubSpot private app',
+ auditMetadata: {},
+ storedMetadata: {},
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ expectPrimaryCall()
+ expectFallbackCall()
+ })
+
+ it('throws invalid_credentials on primary 401 without calling the fallback', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(401, { status: 'error', category: 'INVALID_AUTHENTICATION' })
+ )
+
+ await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws provider_unavailable on primary 503', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(503, { message: 'unavailable' }))
+
+ await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 503,
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws provider_unavailable on primary success body missing hubId', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { unexpected: true }))
+
+ await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts
new file mode 100644
index 00000000000..457710e32c8
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts
@@ -0,0 +1,123 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const TOKEN_INFO_URL = 'https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info'
+const ACCOUNT_INFO_URL = 'https://api.hubapi.com/account-info/v3/details'
+
+interface HubspotTokenInfo {
+ hubId?: number
+ appId?: number
+ userId?: number
+ scopes?: string[]
+}
+
+interface HubspotAccountInfo {
+ portalId?: number
+}
+
+/**
+ * Fallback verification via the Account Information API. Live probing shows
+ * the documented access-token-info route returns a bare 404 for invalid
+ * tokens, which is ambiguous (invalid token vs. missing route), while this
+ * regular API route answers with a proper JSON 401 for bad tokens. A 200
+ * proves the token is live; 401 means it was rejected; 403 means the token
+ * authenticated but the app lacks account-info access — still a live token.
+ */
+async function verifyViaAccountInfo(
+ accessToken: string
+): Promise {
+ const res = await fetchProvider(
+ ACCOUNT_INFO_URL,
+ { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } },
+ 'account_info'
+ )
+ if (res.status === 403) {
+ return {
+ displayName: 'HubSpot private app',
+ auditMetadata: {},
+ storedMetadata: {},
+ }
+ }
+ await throwForProviderResponse(res, 'account_info')
+
+ const info = await parseProviderJson(res, 'account_info')
+ const hubId = typeof info?.portalId === 'number' ? String(info.portalId) : undefined
+ return {
+ displayName: hubId ? `HubSpot portal ${hubId}` : 'HubSpot private app',
+ auditMetadata: hubId ? { hubspotHubId: hubId } : {},
+ storedMetadata: hubId ? { hubId } : {},
+ }
+}
+
+/**
+ * Validates a HubSpot private app access token.
+ *
+ * Primary path: the documented access-token-info endpoint, sending both the
+ * JSON body (`tokenKey`) and the `Authorization: Bearer` header — the header
+ * is optional for NA (`pat-na1`) tokens but required for EU (`pat-eu1`)
+ * tokens, so one code path covers both regions.
+ *
+ * Live probing shows that endpoint returns a bare 404 (HTML) when the token
+ * is not recognized, so a 404 falls back to the Account Information API,
+ * which distinguishes a rejected token (JSON 401) from a live one (200/403).
+ *
+ * The display name is always `HubSpot portal {hubId}` — the account-info
+ * `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), not a
+ * portal-specific name, so it is not used.
+ */
+export async function validateHubspotServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const accessToken = fields.apiToken
+
+ const res = await fetchProvider(
+ TOKEN_INFO_URL,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({ tokenKey: accessToken }),
+ },
+ 'access_token_info'
+ )
+ if (res.status === 404 || res.status === 400 || res.status === 403) {
+ // Ambiguous: HubSpot 404s unrecognized tokens on this route (and 400s
+ // malformed ones); a 403 here is unexpected since the route needs no
+ // scopes, so it also defers to the fallback rather than blaming the
+ // token. Resolve via a regular API route with JSON errors.
+ await readProviderErrorSnippet(res)
+ return verifyViaAccountInfo(accessToken)
+ }
+ await throwForProviderResponse(res, 'access_token_info')
+
+ const tokenInfo = await parseProviderJson(res, 'access_token_info')
+ if (typeof tokenInfo?.hubId !== 'number') {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'access_token_info',
+ reason: 'missing hubId in response',
+ })
+ }
+
+ const hubId = String(tokenInfo.hubId)
+
+ const storedMetadata: Record = { hubId }
+ if (typeof tokenInfo.appId === 'number') storedMetadata.appId = String(tokenInfo.appId)
+ if (typeof tokenInfo.userId === 'number') storedMetadata.userId = String(tokenInfo.userId)
+
+ return {
+ displayName: `HubSpot portal ${hubId}`,
+ auditMetadata: { hubspotHubId: hubId },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts
new file mode 100644
index 00000000000..3007d161370
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts
@@ -0,0 +1,182 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateLinearServiceAccount } from '@/lib/credentials/token-service-accounts/validators/linear'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateLinearServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ })
+
+ it('returns viewer and organization metadata on success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: {
+ viewer: { id: 'viewer-1', name: 'Jane Ops', email: 'jane@acme.com' },
+ organization: { id: 'org-1', name: 'Acme' },
+ },
+ })
+ )
+
+ const result = await validateLinearServiceAccount({ apiToken: 'lin_api_abc' })
+
+ expect(result).toEqual({
+ displayName: 'Acme',
+ auditMetadata: { linearOrganizationId: 'org-1' },
+ storedMetadata: { viewerId: 'viewer-1', organizationId: 'org-1' },
+ })
+
+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
+ expect(url).toBe('https://api.linear.app/graphql')
+ expect(init.method).toBe('POST')
+ const headers = init.headers as Record
+ expect(headers.Authorization).toBe(linearAuthorizationHeader('lin_api_abc'))
+ expect(headers.Authorization).toBe('lin_api_abc')
+ expect(headers.Authorization).not.toContain('Bearer ')
+ expect(JSON.parse(init.body as string)).toEqual({
+ query: '{ viewer { id name email } organization { id name } }',
+ })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(401, {
+ errors: [
+ { message: 'Authentication required', extensions: { type: 'authentication_error' } },
+ ],
+ })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_bad' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws invalid_credentials on 200 with authentication errors', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [{ message: 'Not authorized', extensions: { code: 'authentication_error' } }],
+ })
+ )
+
+ await expect(
+ validateLinearServiceAccount({ apiToken: 'lin_api_revoked' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 200,
+ })
+ })
+
+ it('throws provider_unavailable on 400 with a rate-limit body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, {
+ errors: [{ message: 'Rate limit exceeded', extensions: { code: 'RATELIMITED' } }],
+ })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 400,
+ })
+ })
+
+ it('throws invalid_credentials on 400 with an authentication body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, {
+ errors: [
+ { message: 'Authentication required', extensions: { code: 'authentication_error' } },
+ ],
+ })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_bad' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 400,
+ })
+ })
+
+ it('throws provider_unavailable on an ambiguous 400 body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(400, { errors: [{ message: 'Malformed request' }] })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 400,
+ })
+ })
+
+ it('throws invalid_credentials on 200 with a forbidden error', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [{ message: 'You do not have access', extensions: { type: 'Forbidden' } }],
+ })
+ )
+
+ await expect(
+ validateLinearServiceAccount({ apiToken: 'lin_api_scoped' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 200,
+ })
+ })
+
+ it('throws provider_unavailable on a non-JSON 200 body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('proxy error', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
+ )
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable when fetch rejects with a network error', async () => {
+ mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'))
+
+ await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(500, { errors: [{ message: 'Internal error' }] }))
+
+ const error = await validateLinearServiceAccount({ apiToken: 'lin_api_abc' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts
new file mode 100644
index 00000000000..ea4e297f374
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts
@@ -0,0 +1,142 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
+
+const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'
+
+const VIEWER_QUERY = '{ viewer { id name email } organization { id name } }'
+
+interface LinearGraphQLError {
+ message?: string
+ extensions?: {
+ code?: string
+ type?: string
+ }
+}
+
+interface LinearViewerResponse {
+ data?: {
+ viewer?: {
+ id?: string
+ name?: string | null
+ email?: string | null
+ }
+ organization?: {
+ id?: string
+ name?: string | null
+ }
+ }
+ errors?: LinearGraphQLError[]
+}
+
+/**
+ * Linear does not officially document the HTTP status or extension code for a
+ * rejected key (community reports both 400 and 401, lowercase
+ * `authentication_error` in either `extensions.code` or `extensions.type`),
+ * so match "authentication" case-insensitively across message and extensions.
+ * Forbidden-type errors (a permissions-scoped key that cannot read `viewer`)
+ * are also treated as credential failures — such a key is unusable for Sim
+ * tools.
+ */
+function hasCredentialError(errors: LinearGraphQLError[] | undefined): boolean {
+ if (!errors) return false
+ return errors.some((error) => {
+ const haystack = [error.message, error.extensions?.code, error.extensions?.type]
+ return haystack.some(
+ (value) =>
+ typeof value === 'string' && /authentication|forbidden|not.?authorized/i.test(value)
+ )
+ })
+}
+
+/**
+ * Validates a Linear personal API key by running the `viewer` query against
+ * the fixed GraphQL endpoint. The Authorization header is built with the same
+ * helper the runtime tools use (`linearAuthorizationHeader`): personal
+ * `lin_api_` keys go bare, anything else gets the `Bearer` prefix — so
+ * validation exercises the exact header shape tools will send. GraphQL can
+ * return transport 200 with an `errors` array, so both the HTTP status and
+ * the body are inspected. Linear also returns HTTP 400 for rate/complexity
+ * limits (`RATELIMITED`), so a 400 only maps to `invalid_credentials` when
+ * the body carries authentication evidence.
+ */
+export async function validateLinearServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ LINEAR_GRAPHQL_URL,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: linearAuthorizationHeader(fields.apiToken),
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ query: VIEWER_QUERY }),
+ },
+ 'viewer'
+ )
+
+ if (!res.ok) {
+ const body = await readProviderErrorSnippet(res)
+ if (res.status === 401 || res.status === 403) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'viewer',
+ body,
+ })
+ }
+ if (res.status === 400) {
+ if (!/ratelimit|complexity/i.test(body) && /authenticat/i.test(body)) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'viewer',
+ body,
+ })
+ }
+ throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
+ step: 'viewer',
+ body,
+ })
+ }
+ throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
+ step: 'viewer',
+ body,
+ })
+ }
+
+ const payload = await parseProviderJson(res, 'viewer')
+ if (hasCredentialError(payload.errors)) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'viewer',
+ reason: 'GraphQL authentication/authorization error in 200 response',
+ })
+ }
+
+ const viewer = payload.data?.viewer
+ if (!viewer?.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'viewer',
+ reason: 'missing viewer in response',
+ })
+ }
+
+ const organization = payload.data?.organization
+ const storedMetadata: Record = { viewerId: viewer.id }
+ const auditMetadata: Record = {}
+ if (organization?.id) {
+ storedMetadata.organizationId = organization.id
+ auditMetadata.linearOrganizationId = organization.id
+ }
+
+ return {
+ displayName: organization?.name || viewer.name || viewer.email || 'Linear workspace',
+ auditMetadata,
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts
new file mode 100644
index 00000000000..e96e7006b72
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts
@@ -0,0 +1,164 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateMondayServiceAccount } from '@/lib/credentials/token-service-accounts/validators/monday'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateMondayServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns account display name and metadata on success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: {
+ me: { id: '12345', name: 'Jane Ops', email: 'jane@example.com' },
+ account: { id: 987, name: 'Acme', slug: 'acme' },
+ },
+ })
+ )
+
+ const result = await validateMondayServiceAccount({ apiToken: 'eyJtoken' })
+
+ expect(result).toEqual({
+ displayName: 'Acme',
+ auditMetadata: { mondayAccountId: '987' },
+ storedMetadata: { accountId: '987', accountSlug: 'acme', userId: '12345' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.monday.com/v2', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: 'eyJtoken',
+ 'API-Version': '2026-04',
+ },
+ body: JSON.stringify({
+ query: 'query { me { id name email } account { id name slug } }',
+ }),
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }))
+
+ const error = await validateMondayServiceAccount({ apiToken: 'bad' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('throws invalid_credentials on 200 with errors array', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, { errors: [{ message: 'Not Authenticated' }] })
+ )
+
+ const error = await validateMondayServiceAccount({ apiToken: 'stale' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(200)
+ })
+
+ it('throws invalid_credentials on 200 with top-level error_message', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { error_message: 'Not Authenticated' }))
+
+ const error = await validateMondayServiceAccount({ apiToken: 'stale' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(200)
+ })
+
+ it('throws provider_unavailable on 200 with INTERNAL_SERVER_ERROR extensions', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [
+ {
+ message: 'Internal server error',
+ extensions: { code: 'INTERNAL_SERVER_ERROR', status_code: 500 },
+ },
+ ],
+ })
+ )
+
+ const error = await validateMondayServiceAccount({ apiToken: 'tok' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('throws provider_unavailable when the provider-side error is not first in the array', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [
+ { message: 'Field deprecation warning' },
+ {
+ message: 'Rate limit exceeded',
+ extensions: { code: 'RATE_LIMIT_EXCEEDED', status_code: 429 },
+ },
+ ],
+ })
+ )
+
+ const error = await validateMondayServiceAccount({ apiToken: 'tok' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('omits mondayAccountId from auditMetadata when account is absent', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: { me: { id: '12345', name: 'Jane Ops', email: 'jane@example.com' } },
+ })
+ )
+
+ const result = await validateMondayServiceAccount({ apiToken: 'eyJtoken' })
+
+ expect(result.auditMetadata).toEqual({})
+ expect(result.auditMetadata).not.toHaveProperty('mondayAccountId')
+ expect(result.displayName).toBe('Jane Ops')
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('server error', { status: 500 }))
+
+ const error = await validateMondayServiceAccount({ apiToken: 'tok' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('accepts a valid token when warnings accompany successful me data', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: { me: { id: 77, name: 'Bot User' }, account: { id: 5, name: 'Acme', slug: 'acme' } },
+ errors: [{ message: 'Deprecated field usage', extensions: { code: 'DEPRECATED' } }],
+ })
+ )
+ const result = await validateMondayServiceAccount({ apiToken: 'token' })
+ expect(result.displayName).toBe('Acme')
+ expect(result.storedMetadata?.userId).toBe('77')
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts
new file mode 100644
index 00000000000..9ff853d71ff
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.ts
@@ -0,0 +1,132 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+import { MONDAY_API_URL, mondayHeaders } from '@/tools/monday/utils'
+
+const VALIDATION_QUERY = 'query { me { id name email } account { id name slug } }'
+
+interface MondayValidationBody {
+ data?: {
+ me?: { id?: string | number; name?: string; email?: string }
+ account?: { id?: string | number; name?: string; slug?: string }
+ }
+ errors?: Array<{
+ message?: string
+ extensions?: { code?: string; status_code?: number }
+ }>
+ error_message?: string
+}
+
+const SERVER_SIDE_ERROR_CODE = /internal|server_error|unavailable|rate_limit|complexity/i
+
+/**
+ * Detects a monday GraphQL error that signals a provider-side failure rather
+ * than a rejected token. monday marks these via `extensions.status_code`
+ * (>= 500) or an `extensions.code` such as `INTERNAL_SERVER_ERROR`,
+ * `RATE_LIMIT_EXCEEDED`, or a complexity budget error, per the monday API
+ * error documentation. All entries are scanned — the provider-side error is
+ * not guaranteed to be first in the array.
+ */
+function isProviderSideError(body: MondayValidationBody): boolean {
+ if (!Array.isArray(body.errors)) return false
+ return body.errors.some((error) => {
+ const extensions = error?.extensions
+ if (!extensions) return false
+ if (typeof extensions.status_code === 'number' && extensions.status_code >= 500) return true
+ return typeof extensions.code === 'string' && SERVER_SIDE_ERROR_CODE.test(extensions.code)
+ })
+}
+
+/**
+ * Mirrors `extractMondayError` from `@/tools/monday/utils`: monday returns
+ * HTTP 200 for application-level failures, signalled by a GraphQL `errors`
+ * array or a top-level `error_message` field.
+ */
+function extractBodyError(body: MondayValidationBody): string | null {
+ if (Array.isArray(body.errors) && body.errors.length > 0) {
+ const messages = body.errors.map((e) => e.message).filter(Boolean)
+ return messages.length > 0 ? messages.join('; ') : 'Unknown Monday.com API error'
+ }
+ if (body.error_message) {
+ return body.error_message
+ }
+ return null
+}
+
+/**
+ * Validates a monday.com personal API token by running a `me`/`account`
+ * GraphQL query. monday tools send the token as a bare `Authorization`
+ * header (no `Bearer` prefix) with a pinned `API-Version`, so validation
+ * reuses `mondayHeaders` to exercise exactly the shape tools use.
+ *
+ * monday returns HTTP 200 for application-level errors, so a 200 response
+ * is only a success once the body carries no `errors` array or
+ * `error_message` and `data.me.id` is present.
+ */
+export async function validateMondayServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ MONDAY_API_URL,
+ {
+ method: 'POST',
+ headers: mondayHeaders(fields.apiToken),
+ body: JSON.stringify({ query: VALIDATION_QUERY }),
+ },
+ 'me'
+ )
+ await throwForProviderResponse(res, 'me')
+
+ const body = await parseProviderJson(res, 'me')
+
+ const me = body.data?.me
+ const account = body.data?.account
+
+ // monday can attach warning-class GraphQL errors (e.g. deprecations) to an
+ // otherwise successful response — a present `me.id` proves the token
+ // authenticated, so errors are only classified when the data is missing.
+ const bodyError = me?.id ? undefined : extractBodyError(body)
+ if (bodyError) {
+ if (isProviderSideError(body)) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'me',
+ body: bodyError,
+ })
+ }
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'me',
+ body: bodyError,
+ })
+ }
+
+ if (!me?.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'me',
+ reason: 'missing me.id in response',
+ })
+ }
+
+ const userId = String(me.id)
+ const accountId = account?.id != null ? String(account.id) : ''
+ const storedMetadata: Record = { accountId, userId }
+ if (account?.slug) {
+ storedMetadata.accountSlug = account.slug
+ }
+ const auditMetadata: Record = {}
+ if (accountId) {
+ auditMetadata.mondayAccountId = accountId
+ }
+
+ return {
+ displayName: account?.name || me.name || me.email || `monday user ${userId}`,
+ auditMetadata,
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts
new file mode 100644
index 00000000000..fad5f227b30
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.test.ts
@@ -0,0 +1,105 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateNotionServiceAccount } from '@/lib/credentials/token-service-accounts/validators/notion'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateNotionServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ })
+
+ it('returns bot metadata on success with a name', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ object: 'user',
+ id: 'bot-123',
+ type: 'bot',
+ name: 'Ops Integration',
+ bot: { workspace_name: 'Acme Workspace' },
+ })
+ )
+
+ const result = await validateNotionServiceAccount({ apiToken: 'ntn_abc' })
+
+ expect(result).toEqual({
+ displayName: 'Ops Integration',
+ auditMetadata: { notionBotId: 'bot-123' },
+ storedMetadata: { botId: 'bot-123', workspaceName: 'Acme Workspace' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.notion.com/v1/users/me', {
+ headers: {
+ Authorization: 'Bearer ntn_abc',
+ 'Notion-Version': '2022-06-28',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('falls back to workspace name when name is empty', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ object: 'user',
+ id: 'bot-456',
+ type: 'bot',
+ name: '',
+ bot: { workspace_name: 'Acme Workspace' },
+ })
+ )
+
+ const result = await validateNotionServiceAccount({ apiToken: 'secret_legacy' })
+
+ expect(result.displayName).toBe('Acme Workspace')
+ expect(result.auditMetadata).toEqual({ notionBotId: 'bot-456' })
+ expect(result.storedMetadata).toEqual({
+ botId: 'bot-456',
+ workspaceName: 'Acme Workspace',
+ })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(401, { object: 'error', code: 'unauthorized' }))
+
+ await expect(validateNotionServiceAccount({ apiToken: 'ntn_bad' })).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws provider_unavailable when a 200 body is not JSON', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('gateway', { status: 200 }))
+
+ const error = await validateNotionServiceAccount({ apiToken: 'ntn_abc' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('throws provider_unavailable on 502', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(502, { object: 'error' }))
+
+ const error = await validateNotionServiceAccount({ apiToken: 'ntn_abc' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts
new file mode 100644
index 00000000000..4321ba5f3c4
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/notion.ts
@@ -0,0 +1,69 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+/**
+ * Pinned to the same API version Sim's Notion tools send
+ * (`apps/sim/tools/notion/read.ts`) so validation and execution exercise the
+ * same API surface.
+ */
+const NOTION_VERSION = '2022-06-28'
+
+interface NotionBotUser {
+ id?: string
+ name?: string | null
+ bot?: {
+ workspace_name?: string | null
+ }
+}
+
+/**
+ * Validates a Notion internal integration secret by calling
+ * `GET /v1/users/me`, which is documented to succeed with any capability
+ * level. Tokens are treated as opaque strings — Notion explicitly advises
+ * against prefix/regex validation (`ntn_` and legacy `secret_` are both
+ * valid).
+ */
+export async function validateNotionServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const res = await fetchProvider(
+ 'https://api.notion.com/v1/users/me',
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ 'Notion-Version': NOTION_VERSION,
+ Accept: 'application/json',
+ },
+ },
+ 'users_me'
+ )
+ await throwForProviderResponse(res, 'users_me')
+
+ const me = await parseProviderJson(res, 'users_me')
+ if (!me.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'users_me',
+ reason: 'missing id in response',
+ })
+ }
+
+ const workspaceName = me.bot?.workspace_name || undefined
+ const storedMetadata: Record = { botId: me.id }
+ if (workspaceName) {
+ storedMetadata.workspaceName = workspaceName
+ }
+
+ return {
+ displayName: me.name || workspaceName || 'Notion integration',
+ auditMetadata: { notionBotId: me.id },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts
new file mode 100644
index 00000000000..f8784e9bb5d
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts
@@ -0,0 +1,174 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateShopifyServiceAccount', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ })
+
+ it('returns shop metadata and the normalized domain on success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: {
+ shop: {
+ name: 'Acme Store',
+ myshopifyDomain: 'acme-store.myshopify.com',
+ },
+ },
+ })
+ )
+
+ const result = await validateShopifyServiceAccount({
+ apiToken: 'shpat_abc',
+ domain: 'https://Acme-Store.myshopify.com/',
+ })
+
+ expect(result).toEqual({
+ displayName: 'Acme Store',
+ auditMetadata: { shopifyShopDomain: 'acme-store.myshopify.com' },
+ storedMetadata: { shopDomain: 'acme-store.myshopify.com', shopName: 'Acme Store' },
+ normalizedDomain: 'acme-store.myshopify.com',
+ })
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://acme-store.myshopify.com/admin/api/2024-10/graphql.json',
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Shopify-Access-Token': 'shpat_abc',
+ },
+ body: JSON.stringify({ query: '{ shop { name myshopifyDomain } }' }),
+ signal: expect.any(AbortSignal),
+ }
+ )
+ })
+
+ it.each(['evil.com', 'localhost', 'sub.myshopify.com.evil.com'])(
+ 'rejects non-Shopify host %s without fetching',
+ async (domain) => {
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'site_not_found',
+ status: 400,
+ })
+ expect(mockFetch).not.toHaveBeenCalled()
+ }
+ )
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(401, { errors: 'Invalid API key' }))
+
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_bad', domain: 'acme.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws site_not_found on 404', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(404, { errors: 'Not Found' }))
+
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'no-shop.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'site_not_found',
+ status: 404,
+ })
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(500, { errors: 'Internal Server Error' }))
+
+ const error = await validateShopifyServiceAccount({
+ apiToken: 'shpat_abc',
+ domain: 'acme.myshopify.com',
+ }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('throws provider_unavailable when a 200 body carries GraphQL errors', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { errors: [{ message: 'Internal error' }] }))
+
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'acme.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable when a 200 body is not JSON', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('gateway error', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
+ )
+
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_abc', domain: 'acme.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('maps an auth-shaped GraphQL error in a 200 response to invalid_credentials', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ errors: [
+ { message: 'Invalid API key or access token (unrecognized login or wrong password)' },
+ ],
+ })
+ )
+ await expect(
+ validateShopifyServiceAccount({ apiToken: 'shpat_bad', domain: 'my-store.myshopify.com' })
+ ).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('normalizes a pasted admin URL down to the bare store host', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ data: { shop: { name: 'Probe', myshopifyDomain: 'my-store.myshopify.com' } },
+ })
+ )
+ const result = await validateShopifyServiceAccount({
+ apiToken: 'shpat_ok',
+ domain: 'https://my-store.myshopify.com/admin/settings?x=1',
+ })
+ expect(result.normalizedDomain).toBe('my-store.myshopify.com')
+ expect(mockFetch.mock.calls[0][0]).toContain('https://my-store.myshopify.com/admin/api')
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts
new file mode 100644
index 00000000000..4f71977b37a
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts
@@ -0,0 +1,141 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+/**
+ * Pinned to match the Admin API version hardcoded in every Sim Shopify tool
+ * (`apps/sim/tools/shopify/*`) — bump both together. Note 2024-10 is retired;
+ * Shopify silently serves the oldest supported version for retired versions,
+ * so validation and tool runtime still hit the same effective API.
+ */
+const SHOPIFY_API_VERSION = '2024-10'
+
+/**
+ * SSRF guard: the Admin API must target the permanent `*.myshopify.com`
+ * host — exactly one label before `myshopify.com`, so lookalike hosts such as
+ * `sub.myshopify.com.evil.com` are rejected before any outbound fetch.
+ */
+const SHOPIFY_HOST_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/
+
+const SHOP_QUERY = '{ shop { name myshopifyDomain } }'
+
+interface ShopifyGraphqlError {
+ message?: string
+ extensions?: { code?: string }
+}
+
+interface ShopifyShopResponse {
+ data?: {
+ shop?: {
+ name?: string
+ myshopifyDomain?: string
+ }
+ }
+ errors?: ShopifyGraphqlError[] | unknown
+}
+
+/**
+ * Shopify can reject invalid or revoked `shpat_` tokens with HTTP 200 and a
+ * GraphQL error body instead of a 401, so auth-shaped GraphQL errors must map
+ * to `invalid_credentials` rather than a provider outage.
+ */
+function hasShopifyAuthError(errors: unknown): boolean {
+ if (!Array.isArray(errors)) return false
+ return errors.some((error: ShopifyGraphqlError) => {
+ const haystack = `${error?.message ?? ''} ${error?.extensions?.code ?? ''}`
+ return /access.?denied|unauthorized|invalid api key or access token|401/i.test(haystack)
+ })
+}
+
+/** Strips the protocol and trailing slashes and lowercases the store domain. */
+function normalizeShopifyDomain(rawDomain: string): string {
+ return rawDomain
+ .trim()
+ .replace(/^https?:\/\//i, '')
+ .replace(/[/?#].*$/, '')
+ .toLowerCase()
+}
+
+/**
+ * Validates a Shopify custom-app Admin API access token by running the
+ * scope-free `shop` query against the store's GraphQL Admin API — the exact
+ * URL and header shape every Sim Shopify tool uses. Unknown shops return 404
+ * (wildcard DNS means the host always resolves), which maps to
+ * `site_not_found`.
+ */
+export async function validateShopifyServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const domain = normalizeShopifyDomain(fields.domain ?? '')
+ if (!SHOPIFY_HOST_REGEX.test(domain)) {
+ throw new TokenServiceAccountValidationError('site_not_found', 400, {
+ step: 'host_validation',
+ domain,
+ reason: 'host is not a Shopify store domain (expected *.myshopify.com)',
+ })
+ }
+
+ const res = await fetchProvider(
+ `https://${domain}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Shopify-Access-Token': fields.apiToken,
+ },
+ body: JSON.stringify({ query: SHOP_QUERY }),
+ },
+ 'shop_query'
+ )
+
+ if (res.status === 404) {
+ throw new TokenServiceAccountValidationError('site_not_found', 404, {
+ step: 'shop_query',
+ domain,
+ body: await readProviderErrorSnippet(res),
+ })
+ }
+ await throwForProviderResponse(res, 'shop_query', { domain })
+
+ const payload = await parseProviderJson(res, 'shop_query')
+
+ const shop = payload.data?.shop
+ if (hasShopifyAuthError(payload.errors)) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', 401, {
+ step: 'shop_query',
+ domain,
+ reason: 'auth-shaped GraphQL error in 200 response',
+ })
+ }
+ if (payload.errors || !shop) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'shop_query',
+ domain,
+ reason: payload.errors ? 'GraphQL errors in response' : 'missing shop in response',
+ })
+ }
+
+ const shopName = typeof shop.name === 'string' && shop.name ? shop.name : undefined
+ const apiDomain =
+ typeof shop.myshopifyDomain === 'string'
+ ? normalizeShopifyDomain(shop.myshopifyDomain)
+ : undefined
+ const canonicalDomain = apiDomain && SHOPIFY_HOST_REGEX.test(apiDomain) ? apiDomain : domain
+ const storedMetadata: Record = { shopDomain: canonicalDomain }
+ if (shopName) storedMetadata.shopName = shopName
+
+ return {
+ displayName: shopName ?? canonicalDomain,
+ auditMetadata: { shopifyShopDomain: canonicalDomain },
+ storedMetadata,
+ normalizedDomain: canonicalDomain,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts
new file mode 100644
index 00000000000..5e78de2f1d6
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.test.ts
@@ -0,0 +1,142 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockEnv } = vi.hoisted(() => ({
+ mockEnv: { TRELLO_API_KEY: undefined as string | undefined },
+}))
+
+vi.mock('@/lib/core/config/env', () => ({
+ env: mockEnv,
+}))
+
+import { validateTrelloServiceAccount } from '@/lib/credentials/token-service-accounts/validators/trello'
+
+const FIELDS = { apiToken: 'ATTA0a1b2c3d' }
+
+function jsonResponse(status: number, body: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ } as unknown as Response
+}
+
+const mockFetch = vi.fn()
+
+describe('validateTrelloServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ mockEnv.TRELLO_API_KEY = 'sim-api-key'
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns displayName and metadata on success', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, { id: 'abc123', fullName: 'Sim Bot', username: 'simbot' })
+ )
+
+ const result = await validateTrelloServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'Sim Bot',
+ auditMetadata: { trelloMemberId: 'abc123' },
+ storedMetadata: { memberId: 'abc123', username: 'simbot' },
+ })
+
+ const [url] = mockFetch.mock.calls[0]
+ const parsed = new URL(url)
+ expect(parsed.origin + parsed.pathname).toBe('https://api.trello.com/1/members/me')
+ expect(parsed.searchParams.get('key')).toBe('sim-api-key')
+ expect(parsed.searchParams.get('token')).toBe('ATTA0a1b2c3d')
+ expect(parsed.searchParams.get('fields')).toBe('id,fullName,username')
+ })
+
+ it('throws invalid_credentials on 401 with an invalid token body', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, 'invalid token'))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws provider_unavailable on 401 with an invalid key body', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, 'invalid key'))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 401,
+ logDetail: { step: 'members_me', reason: 'Trello rejected the server API key' },
+ })
+ })
+
+ it('throws invalid_credentials on 401 with any other body', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, 'unauthorized'))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws provider_unavailable on a non-JSON 200 body', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: '',
+ json: async () => {
+ throw new SyntaxError('Unexpected token < in JSON')
+ },
+ text: async () => 'proxy error',
+ } as unknown as Response)
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(500, { message: 'unavailable' }))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 500,
+ })
+ })
+
+ it('throws provider_unavailable without fetching when the API key is not configured', async () => {
+ mockEnv.TRELLO_API_KEY = undefined
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 500,
+ logDetail: { reason: 'Trello API key is not configured' },
+ })
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ it('throws provider_unavailable on missing id in success body', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(200, { fullName: 'Sim Bot' }))
+
+ await expect(validateTrelloServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 502,
+ })
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts
new file mode 100644
index 00000000000..209a43e8b2e
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/trello.ts
@@ -0,0 +1,92 @@
+import { env } from '@/lib/core/config/env'
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const MEMBERS_ME_URL = 'https://api.trello.com/1/members/me'
+
+interface TrelloMember {
+ id?: string
+ fullName?: string
+ username?: string
+}
+
+/**
+ * Validates a Trello member token by calling `/1/members/me` with the token
+ * paired against Sim's own `TRELLO_API_KEY` — Trello binds tokens to the API
+ * key that authorized them, so a token minted under any other key 401s here
+ * exactly as it would at execution time. Both secrets travel as URL query
+ * params, so no request URL is ever included in error `logDetail` (only the
+ * step name and a body snippet, which never echoes the credentials). A 401 is
+ * disambiguated by body: Trello's `invalid key` means Sim's own key was
+ * rejected (`provider_unavailable`), while `invalid token` — or any other 401
+ * body — blames the pasted token (`invalid_credentials`).
+ */
+export async function validateTrelloServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const apiKey = env.TRELLO_API_KEY
+ if (!apiKey) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 500, {
+ reason: 'Trello API key is not configured',
+ })
+ }
+
+ const url = new URL(MEMBERS_ME_URL)
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', fields.apiToken)
+ url.searchParams.set('fields', 'id,fullName,username')
+
+ const res = await fetchProvider(
+ url.toString(),
+ { headers: { Accept: 'application/json' } },
+ 'members_me'
+ )
+ if (res.status === 401) {
+ const body = await readProviderErrorSnippet(res)
+ if (body.includes('invalid token')) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', 401, {
+ step: 'members_me',
+ body,
+ })
+ }
+ if (body.includes('invalid key')) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 401, {
+ step: 'members_me',
+ reason: 'Trello rejected the server API key',
+ })
+ }
+ throw new TokenServiceAccountValidationError('invalid_credentials', 401, {
+ step: 'members_me',
+ body,
+ })
+ }
+ await throwForProviderResponse(res, 'members_me')
+
+ const member = await parseProviderJson(res, 'members_me')
+ if (typeof member?.id !== 'string' || !member.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'members_me',
+ reason: 'missing id in response',
+ })
+ }
+
+ const storedMetadata: Record = { memberId: member.id }
+ if (typeof member.username === 'string' && member.username) {
+ storedMetadata.username = member.username
+ }
+
+ return {
+ displayName: member.fullName || member.username || `Trello member ${member.id}`,
+ auditMetadata: { trelloMemberId: member.id },
+ storedMetadata,
+ }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts
new file mode 100644
index 00000000000..50f8739d879
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.test.ts
@@ -0,0 +1,110 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { validateWealthboxServiceAccount } from '@/lib/credentials/token-service-accounts/validators/wealthbox'
+
+const ME_URL = 'https://api.crmworkspace.com/v1/me'
+
+const FIELDS = { apiToken: '12345678901234567890123456789012' }
+
+function jsonResponse(status: number, body: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: '',
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ } as unknown as Response
+}
+
+const mockFetch = vi.fn()
+
+describe('validateWealthboxServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns displayName and metadata on Bearer success', async () => {
+ mockFetch.mockResolvedValue(
+ jsonResponse(200, {
+ name: 'Bill Jones',
+ email: 'bill@example.com',
+ current_user: { id: 42, email: 'bill@example.com', name: 'Bill Jones' },
+ })
+ )
+
+ const result = await validateWealthboxServiceAccount(FIELDS)
+
+ expect(result).toEqual({
+ displayName: 'Bill Jones',
+ auditMetadata: { wealthboxUserId: '42' },
+ storedMetadata: { userId: '42', email: 'bill@example.com' },
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe(ME_URL)
+ expect(init.headers.Authorization).toBe(`Bearer ${FIELDS.apiToken}`)
+ })
+
+ it('throws invalid_credentials when Bearer 401s but ACCESS_TOKEN succeeds', async () => {
+ mockFetch.mockImplementation(async (_url: string, init?: RequestInit) => {
+ const headers = (init?.headers ?? {}) as Record
+ if (headers.Authorization) return jsonResponse(401, { error: 'No valid API key provided' })
+ if (headers.ACCESS_TOKEN) return jsonResponse(200, { name: 'Bill Jones' })
+ throw new Error('unexpected fetch headers')
+ })
+
+ await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ logDetail: expect.objectContaining({
+ reason: 'token accepted only via ACCESS_TOKEN header — not compatible with Sim tools',
+ }),
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+ })
+
+ it('throws invalid_credentials when both Bearer and ACCESS_TOKEN 401', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(401, { error: 'No valid API key provided' }))
+
+ await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 401,
+ })
+ })
+
+ it('throws invalid_credentials on 402 (expired Wealthbox trial)', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(402, { error: 'Wealthbox trial account has expired' }))
+
+ await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'invalid_credentials',
+ status: 402,
+ logDetail: { step: 'me', reason: 'wealthbox trial expired (402)' },
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws provider_unavailable on 503', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(503, { message: 'unavailable' }))
+
+ await expect(validateWealthboxServiceAccount(FIELDS)).rejects.toMatchObject({
+ name: 'TokenServiceAccountValidationError',
+ code: 'provider_unavailable',
+ status: 503,
+ })
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts
new file mode 100644
index 00000000000..94e85821f33
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/wealthbox.ts
@@ -0,0 +1,105 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ readProviderErrorSnippet,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+const ME_URL = 'https://api.crmworkspace.com/v1/me'
+
+interface WealthboxMeResponse {
+ name?: string
+ email?: string
+ current_user?: {
+ id?: number
+ email?: string
+ name?: string
+ }
+}
+
+/**
+ * Best-effort probe with the officially documented `ACCESS_TOKEN` header.
+ * Used only to distinguish "bogus token" from "real token that Wealthbox
+ * refuses over Bearer" in server logs — never to accept the credential,
+ * because Sim's Wealthbox tools send `Authorization: Bearer` at runtime.
+ */
+async function probeAccessTokenHeader(apiToken: string): Promise {
+ try {
+ const res = await fetchProvider(
+ ME_URL,
+ { headers: { ACCESS_TOKEN: apiToken, Accept: 'application/json' } },
+ 'me-access-token-probe'
+ )
+ return res.ok
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Validates a Wealthbox personal API access token by calling `GET /v1/me`
+ * with `Authorization: Bearer` — the exact header shape Sim's Wealthbox tools
+ * use — so a passing validation empirically proves the token works with the
+ * existing tool code. If Bearer is rejected but the documented `ACCESS_TOKEN`
+ * header succeeds, the token is real yet unusable by Sim's tools, so it is
+ * still rejected as `invalid_credentials` with a distinguishing log detail.
+ *
+ * A 402 is documented by Wealthbox as "Wealthbox trial account has expired"
+ * and maps to `invalid_credentials` — retrying later would never succeed, so
+ * the user is prompted to check their Wealthbox account instead.
+ */
+export async function validateWealthboxServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const apiToken = fields.apiToken
+
+ const res = await fetchProvider(
+ ME_URL,
+ { headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' } },
+ 'me'
+ )
+
+ if (res.status === 402) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'me',
+ reason: 'wealthbox trial expired (402)',
+ })
+ }
+
+ if (res.status === 401 || res.status === 403) {
+ const body = await readProviderErrorSnippet(res)
+ const accessTokenHeaderWorks = await probeAccessTokenHeader(apiToken)
+ if (accessTokenHeaderWorks) {
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'me',
+ body,
+ reason: 'token accepted only via ACCESS_TOKEN header — not compatible with Sim tools',
+ })
+ }
+ throw new TokenServiceAccountValidationError('invalid_credentials', res.status, {
+ step: 'me',
+ body,
+ })
+ }
+ await throwForProviderResponse(res, 'me')
+
+ const me = await parseProviderJson(res, 'me')
+
+ const displayName = me.name || me.email || 'Wealthbox account'
+ const userId = typeof me.current_user?.id === 'number' ? String(me.current_user.id) : undefined
+ const email = me.email || me.current_user?.email
+
+ const auditMetadata: Record = {}
+ if (userId) auditMetadata.wealthboxUserId = userId
+
+ const storedMetadata: Record = {}
+ if (userId) storedMetadata.userId = userId
+ if (email) storedMetadata.email = email
+
+ return { displayName, auditMetadata, storedMetadata }
+}
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts
new file mode 100644
index 00000000000..1cb3f9018c8
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts
@@ -0,0 +1,105 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
+import { validateWebflowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/webflow'
+
+const mockFetch = vi.fn()
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+describe('validateWebflowServiceAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns site display name and metadata on success', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, {
+ sites: [{ id: 'site123', displayName: 'Acme Marketing', shortName: 'acme-marketing' }],
+ })
+ )
+
+ const result = await validateWebflowServiceAccount({ apiToken: 'wf-token' })
+
+ expect(result).toEqual({
+ displayName: 'Acme Marketing',
+ auditMetadata: { webflowSiteId: 'site123' },
+ storedMetadata: { siteId: 'site123', siteName: 'Acme Marketing' },
+ })
+ expect(mockFetch).toHaveBeenCalledWith('https://api.webflow.com/v2/sites', {
+ headers: {
+ Authorization: 'Bearer wf-token',
+ Accept: 'application/json',
+ },
+ signal: expect.any(AbortSignal),
+ })
+ })
+
+ it('falls back to shortName when displayName is absent', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(200, { sites: [{ id: 'site456', shortName: 'acme' }] })
+ )
+
+ const result = await validateWebflowServiceAccount({ apiToken: 'wf-token' })
+
+ expect(result.displayName).toBe('acme')
+ expect(result.storedMetadata).toEqual({ siteId: 'site456', siteName: 'acme' })
+ })
+
+ it('throws invalid_credentials on 401', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('', { status: 401 }))
+
+ const error = await validateWebflowServiceAccount({ apiToken: 'bad' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('invalid_credentials')
+ expect(error.status).toBe(401)
+ })
+
+ it('throws provider_unavailable on 500', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('server error', { status: 500 }))
+
+ const error = await validateWebflowServiceAccount({ apiToken: 'wf-token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(500)
+ })
+
+ it('throws provider_unavailable on a 200 with a non-JSON body', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('gateway timeout', {
+ status: 200,
+ headers: { 'Content-Type': 'text/html' },
+ })
+ )
+
+ const error = await validateWebflowServiceAccount({ apiToken: 'wf-token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+
+ it('throws provider_unavailable on 200 with empty sites array', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(200, { sites: [] }))
+
+ const error = await validateWebflowServiceAccount({ apiToken: 'wf-token' }).catch((e) => e)
+
+ expect(error).toBeInstanceOf(TokenServiceAccountValidationError)
+ expect(error.code).toBe('provider_unavailable')
+ expect(error.status).toBe(502)
+ })
+})
diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts
new file mode 100644
index 00000000000..034558532b2
--- /dev/null
+++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts
@@ -0,0 +1,59 @@
+import {
+ fetchProvider,
+ parseProviderJson,
+ TokenServiceAccountValidationError,
+ throwForProviderResponse,
+} from '@/lib/credentials/token-service-accounts/errors'
+import type {
+ TokenServiceAccountFields,
+ TokenServiceAccountValidationResult,
+} from '@/lib/credentials/token-service-accounts/server'
+
+interface WebflowSite {
+ id?: string
+ displayName?: string
+ shortName?: string
+}
+
+/**
+ * Validates a Webflow site API token by listing sites via the Data API v2.
+ * A site token is bound to exactly one site, so `GET /v2/sites` returns that
+ * site and doubles as the whoami-equivalent (site tokens cannot call the
+ * authorization endpoints). Requires the `sites:read` scope — a token missing
+ * it surfaces as 401/403, which maps to `invalid_credentials`; any other
+ * non-2xx means Webflow is unavailable. A 200 with an empty `sites` array is
+ * treated as `provider_unavailable` since a site token should always list its
+ * one site.
+ */
+export async function validateWebflowServiceAccount(
+ fields: TokenServiceAccountFields
+): Promise {
+ const sitesRes = await fetchProvider(
+ 'https://api.webflow.com/v2/sites',
+ {
+ headers: {
+ Authorization: `Bearer ${fields.apiToken}`,
+ Accept: 'application/json',
+ },
+ },
+ 'list_sites'
+ )
+ await throwForProviderResponse(sitesRes, 'list_sites')
+
+ const payload = await parseProviderJson<{ sites?: WebflowSite[] }>(sitesRes, 'list_sites')
+ const site = payload.sites?.[0]
+ if (!site?.id) {
+ throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
+ step: 'list_sites',
+ reason: 'missing or empty sites array in response',
+ })
+ }
+
+ const displayName = site.displayName || site.shortName || 'Webflow site'
+
+ return {
+ displayName,
+ auditMetadata: { webflowSiteId: site.id },
+ storedMetadata: { siteId: site.id, siteName: displayName },
+ }
+}
diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts
index dc61fd34759..34712123df8 100644
--- a/apps/sim/lib/integrations/oauth-service.ts
+++ b/apps/sim/lib/integrations/oauth-service.ts
@@ -1,4 +1,5 @@
import type { ComponentType } from 'react'
+import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { getServiceConfigByServiceId } from '@/lib/oauth'
@@ -38,7 +39,8 @@ function asServiceAccountProviderId(
if (
value === 'google-service-account' ||
value === 'atlassian-service-account' ||
- value === SLACK_CUSTOM_BOT_PROVIDER_ID
+ value === SLACK_CUSTOM_BOT_PROVIDER_ID ||
+ isTokenServiceAccountProviderId(value)
) {
return value
}
diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts
index d472d8d611c..def1084fab7 100644
--- a/apps/sim/lib/oauth/oauth.ts
+++ b/apps/sim/lib/oauth/oauth.ts
@@ -589,6 +589,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Airtable',
description: 'Manage Airtable bases, tables, and records.',
providerId: 'airtable',
+ serviceAccountProviderId: 'airtable-service-account',
icon: AirtableIcon,
baseProviderIcon: AirtableIcon,
scopes: [
@@ -610,6 +611,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Notion',
description: 'Connect to your Notion workspace to manage pages and databases.',
providerId: 'notion',
+ serviceAccountProviderId: 'notion-service-account',
icon: NotionIcon,
baseProviderIcon: NotionIcon,
scopes: [],
@@ -625,6 +627,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Linear',
description: 'Manage issues and projects in Linear.',
providerId: 'linear',
+ serviceAccountProviderId: 'linear-service-account',
icon: LinearIcon,
baseProviderIcon: LinearIcon,
scopes: ['read', 'write'],
@@ -640,6 +643,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Monday.com',
description: 'Manage boards, items, and groups in Monday.com.',
providerId: 'monday',
+ serviceAccountProviderId: 'monday-service-account',
icon: MondayIcon,
baseProviderIcon: MondayIcon,
scopes: [
@@ -701,6 +705,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Shopify',
description: 'Manage products, orders, and customers in your Shopify store.',
providerId: 'shopify',
+ serviceAccountProviderId: 'shopify-service-account',
icon: ShopifyIcon,
baseProviderIcon: ShopifyIcon,
scopes: [
@@ -797,6 +802,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Wealthbox',
description: 'Manage contacts, notes, and tasks in your Wealthbox CRM.',
providerId: 'wealthbox',
+ serviceAccountProviderId: 'wealthbox-service-account',
icon: WealthboxIcon,
baseProviderIcon: WealthboxIcon,
scopes: ['login', 'data'],
@@ -812,6 +818,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Webflow',
description: 'Manage Webflow CMS collections, sites, and content.',
providerId: 'webflow',
+ serviceAccountProviderId: 'webflow-service-account',
icon: WebflowIcon,
baseProviderIcon: WebflowIcon,
scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write', 'forms:read'],
@@ -827,6 +834,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Trello',
description: 'Manage Trello boards, cards, and workflows.',
providerId: 'trello',
+ serviceAccountProviderId: 'trello-service-account',
icon: TrelloIcon,
baseProviderIcon: TrelloIcon,
scopes: ['read', 'write'],
@@ -842,6 +850,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Asana',
description: 'Manage Asana projects, tasks, and workflows.',
providerId: 'asana',
+ serviceAccountProviderId: 'asana-service-account',
icon: AsanaIcon,
baseProviderIcon: AsanaIcon,
scopes: ['default'],
@@ -857,6 +866,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Attio',
description: 'Manage records, notes, tasks, lists, comments, and more in Attio CRM.',
providerId: 'attio',
+ serviceAccountProviderId: 'attio-service-account',
icon: AttioIcon,
baseProviderIcon: AttioIcon,
scopes: [
@@ -882,6 +892,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Cal.com',
description: 'Manage Cal.com bookings, event types, and schedules.',
providerId: 'calcom',
+ serviceAccountProviderId: 'calcom-service-account',
icon: CalComIcon,
baseProviderIcon: CalComIcon,
scopes: [],
@@ -935,6 +946,7 @@ export const OAUTH_PROVIDERS: Record = {
name: 'HubSpot',
description: 'Access and manage your HubSpot CRM data.',
providerId: 'hubspot',
+ serviceAccountProviderId: 'hubspot-service-account',
icon: HubspotIcon,
baseProviderIcon: HubspotIcon,
scopes: [
diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts
index 720a0830a3a..4d1ce2b9104 100644
--- a/apps/sim/next.config.ts
+++ b/apps/sim/next.config.ts
@@ -485,6 +485,44 @@ const nextConfig: NextConfig = {
}
)
+ /**
+ * Stray crawler/artifact URLs picked up in an external SEO audit — no
+ * page ever existed at these paths, but they were indexed or linked
+ * somewhere with junk characters/casing. Send them home instead of 404.
+ */
+ redirects.push(
+ {
+ source: '/$',
+ destination: '/',
+ permanent: true,
+ },
+ {
+ source: '/&',
+ destination: '/',
+ permanent: true,
+ },
+ {
+ source: '/Sim',
+ destination: '/',
+ permanent: true,
+ },
+ {
+ source: '/homepage',
+ destination: '/',
+ permanent: true,
+ },
+ {
+ source: '/logo',
+ destination: '/',
+ permanent: true,
+ },
+ {
+ source: '/en-US',
+ destination: '/',
+ permanent: true,
+ }
+ )
+
return redirects
},
async rewrites() {
diff --git a/apps/sim/public/authors/andrew.jpg b/apps/sim/public/authors/andrew.jpg
new file mode 100644
index 00000000000..e20c527a475
Binary files /dev/null and b/apps/sim/public/authors/andrew.jpg differ
diff --git a/apps/sim/public/library/apache-2-0-vs-fair-code/cover.jpg b/apps/sim/public/library/apache-2-0-vs-fair-code/cover.jpg
new file mode 100644
index 00000000000..0572501103e
Binary files /dev/null and b/apps/sim/public/library/apache-2-0-vs-fair-code/cover.jpg differ
diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts
index b898543d029..1f5a13b5737 100644
--- a/apps/sim/tools/index.test.ts
+++ b/apps/sim/tools/index.test.ts
@@ -31,6 +31,7 @@ const {
mockGetCustomToolByIdOrTitle,
mockGenerateInternalToken,
mockResolveWorkspaceFileReference,
+ mockGetEffectiveDecryptedEnv,
} = vi.hoisted(() => ({
mockIsHosted: { value: false },
mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record,
@@ -46,6 +47,7 @@ const {
mockGetCustomToolByIdOrTitle: vi.fn(),
mockGenerateInternalToken: vi.fn(),
mockResolveWorkspaceFileReference: vi.fn(),
+ mockGetEffectiveDecryptedEnv: vi.fn(),
}))
const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPinnedIP
@@ -107,6 +109,10 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({
getHostedKeyRateLimiter: () => mockRateLimiterFns,
}))
+vi.mock('@/lib/environment/utils', () => ({
+ getEffectiveDecryptedEnv: (...args: unknown[]) => mockGetEffectiveDecryptedEnv(...args),
+}))
+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args),
}))
@@ -234,6 +240,26 @@ vi.mock('@/tools/registry', () => {
return { success: true, output: data }
},
},
+ test_env_ref_tool: {
+ id: 'test_env_ref_tool',
+ name: 'Test Env Reference Tool',
+ description: 'Accepts a user-only API key and an llm-writable note',
+ version: '1.0.0',
+ params: {
+ apiKey: { type: 'string', required: true, visibility: 'user-only' },
+ note: { type: 'string', required: false, visibility: 'user-or-llm' },
+ },
+ request: {
+ url: '/api/tools/test/env-ref',
+ method: 'POST',
+ headers: () => ({ 'Content-Type': 'application/json' }),
+ body: (p: any) => ({ apiKey: p.apiKey, note: p.note }),
+ },
+ transformResponse: async (response: any) => {
+ const data = await response.json()
+ return { success: true, output: data }
+ },
+ },
test_file_array_tool: {
id: 'test_file_array_tool',
name: 'Test File Array Tool',
@@ -1259,6 +1285,184 @@ describe('Copilot OAuth Credential Enforcement', () => {
})
})
+describe('Copilot Env Variable Reference Resolution', () => {
+ let cleanupEnvVars: () => void
+
+ function mockJsonFetch() {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ headers: new Headers(),
+ json: () => Promise.resolve({ ok: true }),
+ text: () => Promise.resolve(JSON.stringify({ ok: true })),
+ clone: vi.fn().mockReturnThis(),
+ })
+ global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch
+ return fetchMock
+ }
+
+ function sentRequestBody(fetchMock: ReturnType): Record {
+ return JSON.parse(fetchMock.mock.calls[0][1]?.body as string)
+ }
+
+ const copilotContext = () =>
+ createToolExecutionContext({
+ workspaceId: 'workspace-456',
+ userId: 'user-123',
+ copilotToolExecution: true,
+ } as any)
+
+ beforeEach(() => {
+ process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
+ cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
+ mockGetEffectiveDecryptedEnv.mockReset()
+ mockGetEffectiveDecryptedEnv.mockResolvedValue({ SENTRY_AUTH_TOKEN: 'sntrys_real_token' })
+ })
+
+ afterEach(() => {
+ vi.resetAllMocks()
+ cleanupEnvVars()
+ })
+
+ it('resolves a whole-value {{VAR}} reference in a user-only param', async () => {
+ const fetchMock = mockJsonFetch()
+
+ const result = await executeTool(
+ 'test_env_ref_tool',
+ { apiKey: '{{SENTRY_AUTH_TOKEN}}' },
+ { executionContext: copilotContext() }
+ )
+
+ expect(result.success).toBe(true)
+ expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', 'workspace-456')
+ expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token')
+ })
+
+ it('trims whitespace inside the braces like the executor resolver', async () => {
+ const fetchMock = mockJsonFetch()
+
+ const result = await executeTool(
+ 'test_env_ref_tool',
+ { apiKey: '{{ SENTRY_AUTH_TOKEN }}' },
+ { executionContext: copilotContext() }
+ )
+
+ expect(result.success).toBe(true)
+ expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token')
+ })
+
+ it('never resolves references in llm-writable (user-or-llm) params', async () => {
+ const fetchMock = mockJsonFetch()
+
+ const result = await executeTool(
+ 'test_env_ref_tool',
+ { apiKey: '{{SENTRY_AUTH_TOKEN}}', note: '{{SENTRY_AUTH_TOKEN}}' },
+ { executionContext: copilotContext() }
+ )
+
+ expect(result.success).toBe(true)
+ expect(sentRequestBody(fetchMock).note).toBe('{{SENTRY_AUTH_TOKEN}}')
+ })
+
+ it('leaves embedded references untouched in user-only params', async () => {
+ const fetchMock = mockJsonFetch()
+
+ const result = await executeTool(
+ 'test_env_ref_tool',
+ { apiKey: 'Bearer {{SENTRY_AUTH_TOKEN}}' },
+ { executionContext: copilotContext() }
+ )
+
+ expect(result.success).toBe(true)
+ expect(sentRequestBody(fetchMock).apiKey).toBe('Bearer {{SENTRY_AUTH_TOKEN}}')
+ expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
+ })
+
+ it('fails with a clear error before any request when the variable is missing', async () => {
+ const fetchMock = mockJsonFetch()
+
+ const result = await executeTool(
+ 'test_env_ref_tool',
+ { apiKey: '{{MISSING_VAR}}' },
+ { executionContext: copilotContext() }
+ )
+
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('MISSING_VAR')
+ expect(result.error).toContain('apiKey')
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('fails fast instead of forwarding the placeholder when user context is missing', async () => {
+ const fetchMock = mockJsonFetch()
+
+ const result = await executeTool(
+ 'test_env_ref_tool',
+ { apiKey: '{{SENTRY_AUTH_TOKEN}}' },
+ {
+ executionContext: createToolExecutionContext({
+ workspaceId: 'workspace-456',
+ userId: undefined,
+ copilotToolExecution: true,
+ } as any),
+ }
+ )
+
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('authenticated user context')
+ expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('explains the personal-only scope when a variable is missing without a workspace context', async () => {
+ const fetchMock = mockJsonFetch()
+
+ const result = await executeTool(
+ 'test_env_ref_tool',
+ { apiKey: '{{MISSING_VAR}}' },
+ {
+ executionContext: createToolExecutionContext({
+ workspaceId: undefined,
+ userId: 'user-123',
+ copilotToolExecution: true,
+ } as any),
+ }
+ )
+
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('only personal variables are available')
+ expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', undefined)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('does not resolve references outside copilot execution', async () => {
+ const fetchMock = mockJsonFetch()
+
+ const result = await executeTool(
+ 'test_env_ref_tool',
+ { apiKey: '{{SENTRY_AUTH_TOKEN}}' },
+ { executionContext: createToolExecutionContext({ userId: 'user-123' } as any) }
+ )
+
+ expect(result.success).toBe(true)
+ expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
+ expect(sentRequestBody(fetchMock).apiKey).toBe('{{SENTRY_AUTH_TOKEN}}')
+ })
+
+ it('never mutates the caller-owned params object (log-leak guard)', async () => {
+ mockJsonFetch()
+ const callerParams = { apiKey: '{{SENTRY_AUTH_TOKEN}}' }
+
+ const result = await executeTool('test_env_ref_tool', callerParams, {
+ executionContext: copilotContext(),
+ })
+
+ expect(result.success).toBe(true)
+ expect(callerParams.apiKey).toBe('{{SENTRY_AUTH_TOKEN}}')
+ })
+})
+
describe('Centralized Error Handling', () => {
let cleanupEnvVars: () => void
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index 1602aa1cb79..5e597c53422 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -33,6 +33,7 @@ import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-c
import { isCustomTool, isMcpTool } from '@/executor/constants'
import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver'
import type { ExecutionContext, UserFile } from '@/executor/types'
+import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import type { ErrorInfo } from '@/tools/error-extractors'
import { extractErrorMessage } from '@/tools/error-extractors'
import type {
@@ -183,6 +184,71 @@ async function normalizeCopilotFileParams(
}
}
+/**
+ * Resolves whole-value {{ENV_VAR}} references in user-only params for copilot
+ * tool executions. Chat agents never see secret values (the workspace VFS
+ * exposes env var names only), so they pass references; workflow runs resolve
+ * these in the executor, and this is the equivalent step for direct tool
+ * calls, delegating to the executor's resolver so both paths share one set of
+ * reference semantics. Resolution is deliberately restricted to params
+ * declared `visibility: 'user-only'` (API keys and other operator-supplied
+ * secrets) and to values that are exactly one reference, so LLM-writable
+ * params (URLs, headers, bodies) can never be used to extract secret values.
+ *
+ * Mutates only the given params object — callers pass the per-execution copy,
+ * never the copilot-side tool-call state, so decrypted values cannot leak
+ * into failure logs or persisted chat state.
+ */
+async function resolveCopilotEnvReferences(
+ tool: ToolConfig,
+ params: Record,
+ scope: ToolExecutionScope
+): Promise {
+ if (!scope.copilotToolExecution) {
+ return
+ }
+
+ const pending: Array<{ paramId: string; value: string }> = []
+ for (const [paramId, paramDef] of Object.entries(tool.params || {})) {
+ if (paramDef?.visibility !== 'user-only') continue
+ const value = params[paramId]
+ if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
+ pending.push({ paramId, value })
+ }
+ }
+
+ if (pending.length === 0) {
+ return
+ }
+
+ if (!scope.userId) {
+ throw new Error(
+ `Cannot resolve environment variable reference in parameter "${pending[0].paramId}" without an authenticated user context.`
+ )
+ }
+
+ const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils')
+ const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId)
+
+ for (const { paramId, value } of pending) {
+ const missingKeys: string[] = []
+ const resolved = resolveEnvVarReferences(value, envVars, {
+ allowEmbedded: false,
+ missingKeys,
+ })
+ if (missingKeys.length > 0) {
+ const scopeHint = scope.workspaceId
+ ? ''
+ : ' (no workspace context — only personal variables are available here)'
+ throw new Error(
+ `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` +
+ `Check environment/variables.json for available variable names.`
+ )
+ }
+ params[paramId] = resolved as string
+ }
+}
+
function readExplicitCredentialSelector(params: Record): string | undefined {
for (const key of ['credentialId', 'oauthCredential', 'credential'] as const) {
const value = params[key]
@@ -1025,6 +1091,7 @@ export async function executeTool(
await normalizeCopilotFileParams(tool, contextParams, scope)
normalizeCopilotCredentialParams(contextParams)
enforceCopilotCredentialSelection(toolId, tool, contextParams, scope)
+ await resolveCopilotEnvReferences(tool, contextParams, scope)
// Inject hosted API key if tool supports it and user didn't provide one
const hostedKeyInfo = await injectHostedKeyIfNeeded(
diff --git a/apps/sim/tools/linear/add_label_to_issue.ts b/apps/sim/tools/linear/add_label_to_issue.ts
index 1b320c3f3c0..3067d332c53 100644
--- a/apps/sim/tools/linear/add_label_to_issue.ts
+++ b/apps/sim/tools/linear/add_label_to_issue.ts
@@ -1,4 +1,5 @@
import type { LinearAddLabelResponse, LinearAddLabelToIssueParams } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearAddLabelToIssueTool: ToolConfig<
@@ -39,7 +40,7 @@ export const linearAddLabelToIssueTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/add_label_to_project.ts b/apps/sim/tools/linear/add_label_to_project.ts
index 54408b2f46c..e2af0766e65 100644
--- a/apps/sim/tools/linear/add_label_to_project.ts
+++ b/apps/sim/tools/linear/add_label_to_project.ts
@@ -2,6 +2,7 @@ import type {
LinearAddLabelToProjectParams,
LinearAddLabelToProjectResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearAddLabelToProjectTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearAddLabelToProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/archive_issue.ts b/apps/sim/tools/linear/archive_issue.ts
index a166ee76a63..fb8128f51a7 100644
--- a/apps/sim/tools/linear/archive_issue.ts
+++ b/apps/sim/tools/linear/archive_issue.ts
@@ -1,4 +1,5 @@
import type { LinearArchiveIssueParams, LinearArchiveIssueResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearArchiveIssueTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearArchiveIssueTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/archive_label.ts b/apps/sim/tools/linear/archive_label.ts
index e24250b6e22..0f9f9fa3e89 100644
--- a/apps/sim/tools/linear/archive_label.ts
+++ b/apps/sim/tools/linear/archive_label.ts
@@ -1,4 +1,5 @@
import type { LinearArchiveLabelParams, LinearArchiveLabelResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearArchiveLabelTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearArchiveLabelTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/archive_project.ts b/apps/sim/tools/linear/archive_project.ts
index b7d322b668c..a45e20542b7 100644
--- a/apps/sim/tools/linear/archive_project.ts
+++ b/apps/sim/tools/linear/archive_project.ts
@@ -1,4 +1,5 @@
import type { LinearArchiveProjectParams, LinearArchiveProjectResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearArchiveProjectTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearArchiveProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/create_attachment.ts b/apps/sim/tools/linear/create_attachment.ts
index 03019de9122..1965fd68895 100644
--- a/apps/sim/tools/linear/create_attachment.ts
+++ b/apps/sim/tools/linear/create_attachment.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateAttachmentResponse,
} from '@/tools/linear/types'
import { ATTACHMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateAttachmentTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateAttachmentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_comment.ts b/apps/sim/tools/linear/create_comment.ts
index 966c9bad2ff..52417ae37d3 100644
--- a/apps/sim/tools/linear/create_comment.ts
+++ b/apps/sim/tools/linear/create_comment.ts
@@ -1,5 +1,6 @@
import type { LinearCreateCommentParams, LinearCreateCommentResponse } from '@/tools/linear/types'
import { COMMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCommentTool: ToolConfig<
@@ -40,7 +41,7 @@ export const linearCreateCommentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/create_customer.ts b/apps/sim/tools/linear/create_customer.ts
index 241a9c8dce1..bc249eb4356 100644
--- a/apps/sim/tools/linear/create_customer.ts
+++ b/apps/sim/tools/linear/create_customer.ts
@@ -1,5 +1,6 @@
import type { LinearCreateCustomerParams, LinearCreateCustomerResponse } from '@/tools/linear/types'
import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCustomerTool: ToolConfig<
@@ -82,7 +83,7 @@ export const linearCreateCustomerTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_customer_request.ts b/apps/sim/tools/linear/create_customer_request.ts
index e07c9395864..2414440241c 100644
--- a/apps/sim/tools/linear/create_customer_request.ts
+++ b/apps/sim/tools/linear/create_customer_request.ts
@@ -2,6 +2,7 @@ import type {
LinearCreateCustomerRequestParams,
LinearCreateCustomerRequestResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCustomerRequestTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateCustomerRequestTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_customer_status.ts b/apps/sim/tools/linear/create_customer_status.ts
index 5e1467d5ec8..fb22480727f 100644
--- a/apps/sim/tools/linear/create_customer_status.ts
+++ b/apps/sim/tools/linear/create_customer_status.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateCustomerStatusResponse,
} from '@/tools/linear/types'
import { CUSTOMER_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCustomerStatusTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateCustomerStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_customer_tier.ts b/apps/sim/tools/linear/create_customer_tier.ts
index bdb0df977c8..5335c509db6 100644
--- a/apps/sim/tools/linear/create_customer_tier.ts
+++ b/apps/sim/tools/linear/create_customer_tier.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateCustomerTierResponse,
} from '@/tools/linear/types'
import { CUSTOMER_TIER_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCustomerTierTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateCustomerTierTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_cycle.ts b/apps/sim/tools/linear/create_cycle.ts
index da3d67b5544..04e23390ba3 100644
--- a/apps/sim/tools/linear/create_cycle.ts
+++ b/apps/sim/tools/linear/create_cycle.ts
@@ -1,5 +1,6 @@
import type { LinearCreateCycleParams, LinearCreateCycleResponse } from '@/tools/linear/types'
import { CYCLE_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateCycleTool: ToolConfig =
@@ -50,7 +51,7 @@ export const linearCreateCycleTool: ToolConfig {
diff --git a/apps/sim/tools/linear/create_favorite.ts b/apps/sim/tools/linear/create_favorite.ts
index 429357e2e3a..27efdf50aca 100644
--- a/apps/sim/tools/linear/create_favorite.ts
+++ b/apps/sim/tools/linear/create_favorite.ts
@@ -1,4 +1,5 @@
import type { LinearCreateFavoriteParams, LinearCreateFavoriteResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateFavoriteTool: ToolConfig<
@@ -51,7 +52,7 @@ export const linearCreateFavoriteTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts
index adfc1af73b4..baff55786fc 100644
--- a/apps/sim/tools/linear/create_issue.ts
+++ b/apps/sim/tools/linear/create_issue.ts
@@ -1,5 +1,6 @@
import type { LinearCreateIssueParams, LinearCreateIssueResponse } from '@/tools/linear/types'
import { ISSUE_EXTENDED_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateIssueTool: ToolConfig =
@@ -110,7 +111,7 @@ export const linearCreateIssueTool: ToolConfig {
diff --git a/apps/sim/tools/linear/create_issue_relation.ts b/apps/sim/tools/linear/create_issue_relation.ts
index 82dfc8292f0..6d7d037be90 100644
--- a/apps/sim/tools/linear/create_issue_relation.ts
+++ b/apps/sim/tools/linear/create_issue_relation.ts
@@ -2,6 +2,7 @@ import type {
LinearCreateIssueRelationParams,
LinearCreateIssueRelationResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateIssueRelationTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearCreateIssueRelationTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/create_label.ts b/apps/sim/tools/linear/create_label.ts
index 3ca63a52b1d..760da7301fa 100644
--- a/apps/sim/tools/linear/create_label.ts
+++ b/apps/sim/tools/linear/create_label.ts
@@ -1,5 +1,6 @@
import type { LinearCreateLabelParams, LinearCreateLabelResponse } from '@/tools/linear/types'
import { LABEL_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateLabelTool: ToolConfig =
@@ -50,7 +51,7 @@ export const linearCreateLabelTool: ToolConfig {
diff --git a/apps/sim/tools/linear/create_project.ts b/apps/sim/tools/linear/create_project.ts
index b42c676eb9e..eeabd85cb58 100644
--- a/apps/sim/tools/linear/create_project.ts
+++ b/apps/sim/tools/linear/create_project.ts
@@ -1,5 +1,6 @@
import type { LinearCreateProjectParams, LinearCreateProjectResponse } from '@/tools/linear/types'
import { PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectTool: ToolConfig<
@@ -70,7 +71,7 @@ export const linearCreateProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_project_label.ts b/apps/sim/tools/linear/create_project_label.ts
index 6ad108f310c..3b3e2447afc 100644
--- a/apps/sim/tools/linear/create_project_label.ts
+++ b/apps/sim/tools/linear/create_project_label.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateProjectLabelResponse,
} from '@/tools/linear/types'
import { PROJECT_LABEL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectLabelTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearCreateProjectLabelTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_project_milestone.ts b/apps/sim/tools/linear/create_project_milestone.ts
index 022f8e13bcf..49c8e24666e 100644
--- a/apps/sim/tools/linear/create_project_milestone.ts
+++ b/apps/sim/tools/linear/create_project_milestone.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateProjectMilestoneResponse,
} from '@/tools/linear/types'
import { PROJECT_MILESTONE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectMilestoneTool: ToolConfig<
@@ -55,7 +56,7 @@ export const linearCreateProjectMilestoneTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_project_status.ts b/apps/sim/tools/linear/create_project_status.ts
index 5f63184daaf..e3d9cc92afd 100644
--- a/apps/sim/tools/linear/create_project_status.ts
+++ b/apps/sim/tools/linear/create_project_status.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateProjectStatusResponse,
} from '@/tools/linear/types'
import { PROJECT_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectStatusTool: ToolConfig<
@@ -68,7 +69,7 @@ export const linearCreateProjectStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_project_update.ts b/apps/sim/tools/linear/create_project_update.ts
index f6ce64f9260..1d2de1bda1b 100644
--- a/apps/sim/tools/linear/create_project_update.ts
+++ b/apps/sim/tools/linear/create_project_update.ts
@@ -2,6 +2,7 @@ import type {
LinearCreateProjectUpdateParams,
LinearCreateProjectUpdateResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateProjectUpdateTool: ToolConfig<
@@ -48,7 +49,7 @@ export const linearCreateProjectUpdateTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/create_workflow_state.ts b/apps/sim/tools/linear/create_workflow_state.ts
index 314dc748204..6a4f8b6d444 100644
--- a/apps/sim/tools/linear/create_workflow_state.ts
+++ b/apps/sim/tools/linear/create_workflow_state.ts
@@ -3,6 +3,7 @@ import type {
LinearCreateWorkflowStateResponse,
} from '@/tools/linear/types'
import { WORKFLOW_STATE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearCreateWorkflowStateTool: ToolConfig<
@@ -67,7 +68,7 @@ export const linearCreateWorkflowStateTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/delete_attachment.ts b/apps/sim/tools/linear/delete_attachment.ts
index 44043c741e4..350174c0e44 100644
--- a/apps/sim/tools/linear/delete_attachment.ts
+++ b/apps/sim/tools/linear/delete_attachment.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteAttachmentParams,
LinearDeleteAttachmentResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteAttachmentTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteAttachmentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_comment.ts b/apps/sim/tools/linear/delete_comment.ts
index 92c8acebb27..9d82c67b6f4 100644
--- a/apps/sim/tools/linear/delete_comment.ts
+++ b/apps/sim/tools/linear/delete_comment.ts
@@ -1,4 +1,5 @@
import type { LinearDeleteCommentParams, LinearDeleteCommentResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteCommentTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearDeleteCommentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_customer.ts b/apps/sim/tools/linear/delete_customer.ts
index 9e66d1dd81c..dea9eb028cc 100644
--- a/apps/sim/tools/linear/delete_customer.ts
+++ b/apps/sim/tools/linear/delete_customer.ts
@@ -1,4 +1,5 @@
import type { LinearDeleteCustomerParams, LinearDeleteCustomerResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteCustomerTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearDeleteCustomerTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_customer_status.ts b/apps/sim/tools/linear/delete_customer_status.ts
index 3e3beaf9a07..e461a54d8d8 100644
--- a/apps/sim/tools/linear/delete_customer_status.ts
+++ b/apps/sim/tools/linear/delete_customer_status.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteCustomerStatusParams,
LinearDeleteCustomerStatusResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteCustomerStatusTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteCustomerStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_customer_tier.ts b/apps/sim/tools/linear/delete_customer_tier.ts
index d9d36183eeb..6e75df59186 100644
--- a/apps/sim/tools/linear/delete_customer_tier.ts
+++ b/apps/sim/tools/linear/delete_customer_tier.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteCustomerTierParams,
LinearDeleteCustomerTierResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteCustomerTierTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteCustomerTierTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_issue.ts b/apps/sim/tools/linear/delete_issue.ts
index 1e558666e64..773f9dd8c98 100644
--- a/apps/sim/tools/linear/delete_issue.ts
+++ b/apps/sim/tools/linear/delete_issue.ts
@@ -1,4 +1,5 @@
import type { LinearDeleteIssueParams, LinearDeleteIssueResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteIssueTool: ToolConfig =
@@ -31,7 +32,7 @@ export const linearDeleteIssueTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/delete_issue_relation.ts b/apps/sim/tools/linear/delete_issue_relation.ts
index 5ebb8708b78..faea68134cd 100644
--- a/apps/sim/tools/linear/delete_issue_relation.ts
+++ b/apps/sim/tools/linear/delete_issue_relation.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteIssueRelationParams,
LinearDeleteIssueRelationResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteIssueRelationTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteIssueRelationTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_project.ts b/apps/sim/tools/linear/delete_project.ts
index 24f49c0b409..a993401dba5 100644
--- a/apps/sim/tools/linear/delete_project.ts
+++ b/apps/sim/tools/linear/delete_project.ts
@@ -1,4 +1,5 @@
import type { LinearDeleteProjectParams, LinearDeleteProjectResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteProjectTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearDeleteProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_project_label.ts b/apps/sim/tools/linear/delete_project_label.ts
index a4bec4e2a40..31384e8858c 100644
--- a/apps/sim/tools/linear/delete_project_label.ts
+++ b/apps/sim/tools/linear/delete_project_label.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteProjectLabelParams,
LinearDeleteProjectLabelResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteProjectLabelTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteProjectLabelTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_project_milestone.ts b/apps/sim/tools/linear/delete_project_milestone.ts
index 43637409fc1..7089f99235e 100644
--- a/apps/sim/tools/linear/delete_project_milestone.ts
+++ b/apps/sim/tools/linear/delete_project_milestone.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteProjectMilestoneParams,
LinearDeleteProjectMilestoneResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteProjectMilestoneTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteProjectMilestoneTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/delete_project_status.ts b/apps/sim/tools/linear/delete_project_status.ts
index 9785816344e..566c3c1fbc4 100644
--- a/apps/sim/tools/linear/delete_project_status.ts
+++ b/apps/sim/tools/linear/delete_project_status.ts
@@ -2,6 +2,7 @@ import type {
LinearDeleteProjectStatusParams,
LinearDeleteProjectStatusResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearDeleteProjectStatusTool: ToolConfig<
@@ -36,7 +37,7 @@ export const linearDeleteProjectStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/get_active_cycle.ts b/apps/sim/tools/linear/get_active_cycle.ts
index eda62de1102..7c8a06ea523 100644
--- a/apps/sim/tools/linear/get_active_cycle.ts
+++ b/apps/sim/tools/linear/get_active_cycle.ts
@@ -1,5 +1,6 @@
import type { LinearGetActiveCycleParams, LinearGetActiveCycleResponse } from '@/tools/linear/types'
import { CYCLE_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetActiveCycleTool: ToolConfig<
@@ -34,7 +35,7 @@ export const linearGetActiveCycleTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/get_customer.ts b/apps/sim/tools/linear/get_customer.ts
index 10d241698c8..4641629179d 100644
--- a/apps/sim/tools/linear/get_customer.ts
+++ b/apps/sim/tools/linear/get_customer.ts
@@ -1,5 +1,6 @@
import type { LinearGetCustomerParams, LinearGetCustomerResponse } from '@/tools/linear/types'
import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetCustomerTool: ToolConfig =
@@ -32,7 +33,7 @@ export const linearGetCustomerTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/get_cycle.ts b/apps/sim/tools/linear/get_cycle.ts
index f5f9eb24083..d47184d4983 100644
--- a/apps/sim/tools/linear/get_cycle.ts
+++ b/apps/sim/tools/linear/get_cycle.ts
@@ -1,5 +1,6 @@
import type { LinearGetCycleParams, LinearGetCycleResponse } from '@/tools/linear/types'
import { CYCLE_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetCycleTool: ToolConfig = {
@@ -31,7 +32,7 @@ export const linearGetCycleTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/get_issue.ts b/apps/sim/tools/linear/get_issue.ts
index e5f354c7fa6..8f6840e99fa 100644
--- a/apps/sim/tools/linear/get_issue.ts
+++ b/apps/sim/tools/linear/get_issue.ts
@@ -1,5 +1,6 @@
import type { LinearGetIssueParams, LinearGetIssueResponse } from '@/tools/linear/types'
import { ISSUE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetIssueTool: ToolConfig = {
@@ -31,7 +32,7 @@ export const linearGetIssueTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/get_project.ts b/apps/sim/tools/linear/get_project.ts
index 71690a6ba09..b1fef8bef15 100644
--- a/apps/sim/tools/linear/get_project.ts
+++ b/apps/sim/tools/linear/get_project.ts
@@ -1,5 +1,6 @@
import type { LinearGetProjectParams, LinearGetProjectResponse } from '@/tools/linear/types'
import { PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetProjectTool: ToolConfig = {
@@ -31,7 +32,7 @@ export const linearGetProjectTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/get_viewer.ts b/apps/sim/tools/linear/get_viewer.ts
index faf5a13938f..9e9a88ee8e5 100644
--- a/apps/sim/tools/linear/get_viewer.ts
+++ b/apps/sim/tools/linear/get_viewer.ts
@@ -1,5 +1,6 @@
import type { LinearGetViewerParams, LinearGetViewerResponse } from '@/tools/linear/types'
import { USER_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearGetViewerTool: ToolConfig = {
@@ -24,7 +25,7 @@ export const linearGetViewerTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/list_attachments.ts b/apps/sim/tools/linear/list_attachments.ts
index 0eac48b829b..074b3c4acd0 100644
--- a/apps/sim/tools/linear/list_attachments.ts
+++ b/apps/sim/tools/linear/list_attachments.ts
@@ -3,6 +3,7 @@ import type {
LinearListAttachmentsResponse,
} from '@/tools/linear/types'
import { ATTACHMENT_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListAttachmentsTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearListAttachmentsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_comments.ts b/apps/sim/tools/linear/list_comments.ts
index 05eb77103ae..a440ad8ec6d 100644
--- a/apps/sim/tools/linear/list_comments.ts
+++ b/apps/sim/tools/linear/list_comments.ts
@@ -1,5 +1,6 @@
import type { LinearListCommentsParams, LinearListCommentsResponse } from '@/tools/linear/types'
import { COMMENT_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCommentsTool: ToolConfig<
@@ -46,7 +47,7 @@ export const linearListCommentsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_customer_requests.ts b/apps/sim/tools/linear/list_customer_requests.ts
index abf406e7680..35a1830ddae 100644
--- a/apps/sim/tools/linear/list_customer_requests.ts
+++ b/apps/sim/tools/linear/list_customer_requests.ts
@@ -2,6 +2,7 @@ import type {
LinearListCustomerRequestsParams,
LinearListCustomerRequestsResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCustomerRequestsTool: ToolConfig<
@@ -48,7 +49,7 @@ export const linearListCustomerRequestsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_customer_statuses.ts b/apps/sim/tools/linear/list_customer_statuses.ts
index b7f3ea7d76b..da3bf26c376 100644
--- a/apps/sim/tools/linear/list_customer_statuses.ts
+++ b/apps/sim/tools/linear/list_customer_statuses.ts
@@ -3,6 +3,7 @@ import type {
LinearListCustomerStatusesResponse,
} from '@/tools/linear/types'
import { CUSTOMER_STATUS_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCustomerStatusesTool: ToolConfig<
@@ -43,7 +44,7 @@ export const linearListCustomerStatusesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_customer_tiers.ts b/apps/sim/tools/linear/list_customer_tiers.ts
index cecfe9e653c..ab6f7cccffe 100644
--- a/apps/sim/tools/linear/list_customer_tiers.ts
+++ b/apps/sim/tools/linear/list_customer_tiers.ts
@@ -3,6 +3,7 @@ import type {
LinearListCustomerTiersResponse,
} from '@/tools/linear/types'
import { CUSTOMER_TIER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCustomerTiersTool: ToolConfig<
@@ -43,7 +44,7 @@ export const linearListCustomerTiersTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_customers.ts b/apps/sim/tools/linear/list_customers.ts
index 09db5ab6499..a4e5bc66f4c 100644
--- a/apps/sim/tools/linear/list_customers.ts
+++ b/apps/sim/tools/linear/list_customers.ts
@@ -1,5 +1,6 @@
import type { LinearListCustomersParams, LinearListCustomersResponse } from '@/tools/linear/types'
import { CUSTOMER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCustomersTool: ToolConfig<
@@ -46,7 +47,7 @@ export const linearListCustomersTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_cycles.ts b/apps/sim/tools/linear/list_cycles.ts
index 5a3ee3d078c..304b7cc38e2 100644
--- a/apps/sim/tools/linear/list_cycles.ts
+++ b/apps/sim/tools/linear/list_cycles.ts
@@ -1,5 +1,6 @@
import type { LinearListCyclesParams, LinearListCyclesResponse } from '@/tools/linear/types'
import { CYCLE_FULL_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListCyclesTool: ToolConfig = {
@@ -43,7 +44,7 @@ export const linearListCyclesTool: ToolConfig {
diff --git a/apps/sim/tools/linear/list_favorites.ts b/apps/sim/tools/linear/list_favorites.ts
index 79e6cd0b01d..a778ef4bec0 100644
--- a/apps/sim/tools/linear/list_favorites.ts
+++ b/apps/sim/tools/linear/list_favorites.ts
@@ -1,4 +1,5 @@
import type { LinearListFavoritesParams, LinearListFavoritesResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListFavoritesTool: ToolConfig<
@@ -39,7 +40,7 @@ export const linearListFavoritesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_issue_relations.ts b/apps/sim/tools/linear/list_issue_relations.ts
index b008ba45fc4..23035e2b2dd 100644
--- a/apps/sim/tools/linear/list_issue_relations.ts
+++ b/apps/sim/tools/linear/list_issue_relations.ts
@@ -2,6 +2,7 @@ import type {
LinearListIssueRelationsParams,
LinearListIssueRelationsResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListIssueRelationsTool: ToolConfig<
@@ -48,7 +49,7 @@ export const linearListIssueRelationsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_labels.ts b/apps/sim/tools/linear/list_labels.ts
index ec1f66e375f..b4fceec910a 100644
--- a/apps/sim/tools/linear/list_labels.ts
+++ b/apps/sim/tools/linear/list_labels.ts
@@ -1,5 +1,6 @@
import type { LinearListLabelsParams, LinearListLabelsResponse } from '@/tools/linear/types'
import { LABEL_FULL_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListLabelsTool: ToolConfig = {
@@ -43,7 +44,7 @@ export const linearListLabelsTool: ToolConfig {
diff --git a/apps/sim/tools/linear/list_notifications.ts b/apps/sim/tools/linear/list_notifications.ts
index 7509334c4fa..b09a1ae7bab 100644
--- a/apps/sim/tools/linear/list_notifications.ts
+++ b/apps/sim/tools/linear/list_notifications.ts
@@ -2,6 +2,7 @@ import type {
LinearListNotificationsParams,
LinearListNotificationsResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListNotificationsTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearListNotificationsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_project_labels.ts b/apps/sim/tools/linear/list_project_labels.ts
index 5156572b817..c98dac175e4 100644
--- a/apps/sim/tools/linear/list_project_labels.ts
+++ b/apps/sim/tools/linear/list_project_labels.ts
@@ -3,6 +3,7 @@ import type {
LinearListProjectLabelsResponse,
} from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, PROJECT_LABEL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectLabelsTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearListProjectLabelsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/list_project_milestones.ts b/apps/sim/tools/linear/list_project_milestones.ts
index 59e9bd2cb40..3039a407c43 100644
--- a/apps/sim/tools/linear/list_project_milestones.ts
+++ b/apps/sim/tools/linear/list_project_milestones.ts
@@ -3,6 +3,7 @@ import type {
LinearListProjectMilestonesResponse,
} from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, PROJECT_MILESTONE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectMilestonesTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearListProjectMilestonesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_project_statuses.ts b/apps/sim/tools/linear/list_project_statuses.ts
index b0b1efb1240..455f33500ca 100644
--- a/apps/sim/tools/linear/list_project_statuses.ts
+++ b/apps/sim/tools/linear/list_project_statuses.ts
@@ -3,6 +3,7 @@ import type {
LinearListProjectStatusesResponse,
} from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, PROJECT_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectStatusesTool: ToolConfig<
@@ -43,7 +44,7 @@ export const linearListProjectStatusesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_project_updates.ts b/apps/sim/tools/linear/list_project_updates.ts
index 2b76d92ab94..bf2a0a5a265 100644
--- a/apps/sim/tools/linear/list_project_updates.ts
+++ b/apps/sim/tools/linear/list_project_updates.ts
@@ -2,6 +2,7 @@ import type {
LinearListProjectUpdatesParams,
LinearListProjectUpdatesResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectUpdatesTool: ToolConfig<
@@ -48,7 +49,7 @@ export const linearListProjectUpdatesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/list_projects.ts b/apps/sim/tools/linear/list_projects.ts
index 2af440f53a3..4ebbcb3e0b9 100644
--- a/apps/sim/tools/linear/list_projects.ts
+++ b/apps/sim/tools/linear/list_projects.ts
@@ -1,5 +1,6 @@
import type { LinearListProjectsParams, LinearListProjectsResponse } from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListProjectsTool: ToolConfig<
@@ -52,7 +53,7 @@ export const linearListProjectsTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/list_teams.ts b/apps/sim/tools/linear/list_teams.ts
index 268586ae7db..a695ff63f93 100644
--- a/apps/sim/tools/linear/list_teams.ts
+++ b/apps/sim/tools/linear/list_teams.ts
@@ -1,5 +1,6 @@
import type { LinearListTeamsParams, LinearListTeamsResponse } from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, TEAM_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListTeamsTool: ToolConfig = {
@@ -37,7 +38,7 @@ export const linearListTeamsTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/list_users.ts b/apps/sim/tools/linear/list_users.ts
index 00dace61086..f6378d39ea0 100644
--- a/apps/sim/tools/linear/list_users.ts
+++ b/apps/sim/tools/linear/list_users.ts
@@ -1,5 +1,6 @@
import type { LinearListUsersParams, LinearListUsersResponse } from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, USER_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListUsersTool: ToolConfig = {
@@ -43,7 +44,7 @@ export const linearListUsersTool: ToolConfig ({
diff --git a/apps/sim/tools/linear/list_workflow_states.ts b/apps/sim/tools/linear/list_workflow_states.ts
index cf95be8e1cc..87c0f36c210 100644
--- a/apps/sim/tools/linear/list_workflow_states.ts
+++ b/apps/sim/tools/linear/list_workflow_states.ts
@@ -3,6 +3,7 @@ import type {
LinearListWorkflowStatesResponse,
} from '@/tools/linear/types'
import { PAGE_INFO_OUTPUT, WORKFLOW_STATE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearListWorkflowStatesTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearListWorkflowStatesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/merge_customers.ts b/apps/sim/tools/linear/merge_customers.ts
index 6ed745d0687..d761df08c2c 100644
--- a/apps/sim/tools/linear/merge_customers.ts
+++ b/apps/sim/tools/linear/merge_customers.ts
@@ -1,4 +1,5 @@
import type { LinearMergeCustomersParams, LinearMergeCustomersResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearMergeCustomersTool: ToolConfig<
@@ -39,7 +40,7 @@ export const linearMergeCustomersTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/read_issues.ts b/apps/sim/tools/linear/read_issues.ts
index 3dd443d12b7..fabe39c6af8 100644
--- a/apps/sim/tools/linear/read_issues.ts
+++ b/apps/sim/tools/linear/read_issues.ts
@@ -1,5 +1,6 @@
import type { LinearReadIssuesParams, LinearReadIssuesResponse } from '@/tools/linear/types'
import { ISSUE_LIST_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearReadIssuesTool: ToolConfig = {
@@ -97,7 +98,7 @@ export const linearReadIssuesTool: ToolConfig {
diff --git a/apps/sim/tools/linear/remove_label_from_issue.ts b/apps/sim/tools/linear/remove_label_from_issue.ts
index 262ce31905a..202d179dfae 100644
--- a/apps/sim/tools/linear/remove_label_from_issue.ts
+++ b/apps/sim/tools/linear/remove_label_from_issue.ts
@@ -2,6 +2,7 @@ import type {
LinearRemoveLabelFromIssueParams,
LinearRemoveLabelResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearRemoveLabelFromIssueTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearRemoveLabelFromIssueTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/remove_label_from_project.ts b/apps/sim/tools/linear/remove_label_from_project.ts
index 278cfff393c..dd725782953 100644
--- a/apps/sim/tools/linear/remove_label_from_project.ts
+++ b/apps/sim/tools/linear/remove_label_from_project.ts
@@ -2,6 +2,7 @@ import type {
LinearRemoveLabelFromProjectParams,
LinearRemoveLabelFromProjectResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearRemoveLabelFromProjectTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearRemoveLabelFromProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/search_issues.ts b/apps/sim/tools/linear/search_issues.ts
index 05efaa59118..9bda838eece 100644
--- a/apps/sim/tools/linear/search_issues.ts
+++ b/apps/sim/tools/linear/search_issues.ts
@@ -1,5 +1,6 @@
import type { LinearSearchIssuesParams, LinearSearchIssuesResponse } from '@/tools/linear/types'
import { ISSUE_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearSearchIssuesTool: ToolConfig<
@@ -58,7 +59,7 @@ export const linearSearchIssuesTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/unarchive_issue.ts b/apps/sim/tools/linear/unarchive_issue.ts
index 111b86d1e0f..bf46cbeee7d 100644
--- a/apps/sim/tools/linear/unarchive_issue.ts
+++ b/apps/sim/tools/linear/unarchive_issue.ts
@@ -1,4 +1,5 @@
import type { LinearUnarchiveIssueParams, LinearUnarchiveIssueResponse } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUnarchiveIssueTool: ToolConfig<
@@ -33,7 +34,7 @@ export const linearUnarchiveIssueTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => ({
diff --git a/apps/sim/tools/linear/update_attachment.ts b/apps/sim/tools/linear/update_attachment.ts
index 4afbe56bb41..f89d872048e 100644
--- a/apps/sim/tools/linear/update_attachment.ts
+++ b/apps/sim/tools/linear/update_attachment.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateAttachmentResponse,
} from '@/tools/linear/types'
import { ATTACHMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateAttachmentTool: ToolConfig<
@@ -49,7 +50,7 @@ export const linearUpdateAttachmentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_comment.ts b/apps/sim/tools/linear/update_comment.ts
index 67249d393ec..6102d5a27eb 100644
--- a/apps/sim/tools/linear/update_comment.ts
+++ b/apps/sim/tools/linear/update_comment.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateCommentParams, LinearUpdateCommentResponse } from '@/tools/linear/types'
import { COMMENT_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCommentTool: ToolConfig<
@@ -40,7 +41,7 @@ export const linearUpdateCommentTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_customer.ts b/apps/sim/tools/linear/update_customer.ts
index e635d5f29c6..1e5f21f697b 100644
--- a/apps/sim/tools/linear/update_customer.ts
+++ b/apps/sim/tools/linear/update_customer.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateCustomerParams, LinearUpdateCustomerResponse } from '@/tools/linear/types'
import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCustomerTool: ToolConfig<
@@ -88,7 +89,7 @@ export const linearUpdateCustomerTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_customer_request.ts b/apps/sim/tools/linear/update_customer_request.ts
index 317ddf315fe..ad380abf66d 100644
--- a/apps/sim/tools/linear/update_customer_request.ts
+++ b/apps/sim/tools/linear/update_customer_request.ts
@@ -2,6 +2,7 @@ import type {
LinearUpdateCustomerRequestParams,
LinearUpdateCustomerRequestResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCustomerRequestTool: ToolConfig<
@@ -67,7 +68,7 @@ export const linearUpdateCustomerRequestTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_customer_status.ts b/apps/sim/tools/linear/update_customer_status.ts
index 290ceedd339..7a30933330d 100644
--- a/apps/sim/tools/linear/update_customer_status.ts
+++ b/apps/sim/tools/linear/update_customer_status.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateCustomerStatusResponse,
} from '@/tools/linear/types'
import { CUSTOMER_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCustomerStatusTool: ToolConfig<
@@ -67,7 +68,7 @@ export const linearUpdateCustomerStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_customer_tier.ts b/apps/sim/tools/linear/update_customer_tier.ts
index 62f5c2c9c3e..de72c7e501c 100644
--- a/apps/sim/tools/linear/update_customer_tier.ts
+++ b/apps/sim/tools/linear/update_customer_tier.ts
@@ -2,6 +2,7 @@ import type {
LinearUpdateCustomerTierParams,
LinearUpdateCustomerTierResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateCustomerTierTool: ToolConfig<
@@ -66,7 +67,7 @@ export const linearUpdateCustomerTierTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_issue.ts b/apps/sim/tools/linear/update_issue.ts
index 2b98eaa5c16..5f7053d712b 100644
--- a/apps/sim/tools/linear/update_issue.ts
+++ b/apps/sim/tools/linear/update_issue.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateIssueParams, LinearUpdateIssueResponse } from '@/tools/linear/types'
import { ISSUE_EXTENDED_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateIssueTool: ToolConfig =
@@ -110,7 +111,7 @@ export const linearUpdateIssueTool: ToolConfig {
diff --git a/apps/sim/tools/linear/update_label.ts b/apps/sim/tools/linear/update_label.ts
index ea34b08820f..d7edc5e9112 100644
--- a/apps/sim/tools/linear/update_label.ts
+++ b/apps/sim/tools/linear/update_label.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateLabelParams, LinearUpdateLabelResponse } from '@/tools/linear/types'
import { LABEL_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateLabelTool: ToolConfig =
@@ -50,7 +51,7 @@ export const linearUpdateLabelTool: ToolConfig {
diff --git a/apps/sim/tools/linear/update_notification.ts b/apps/sim/tools/linear/update_notification.ts
index b297682141d..a298f2f0cc5 100644
--- a/apps/sim/tools/linear/update_notification.ts
+++ b/apps/sim/tools/linear/update_notification.ts
@@ -2,6 +2,7 @@ import type {
LinearUpdateNotificationParams,
LinearUpdateNotificationResponse,
} from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateNotificationTool: ToolConfig<
@@ -42,7 +43,7 @@ export const linearUpdateNotificationTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_project.ts b/apps/sim/tools/linear/update_project.ts
index 10a65d3320b..31ca7a9529a 100644
--- a/apps/sim/tools/linear/update_project.ts
+++ b/apps/sim/tools/linear/update_project.ts
@@ -1,5 +1,6 @@
import type { LinearUpdateProjectParams, LinearUpdateProjectResponse } from '@/tools/linear/types'
import { PROJECT_FULL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateProjectTool: ToolConfig<
@@ -76,7 +77,7 @@ export const linearUpdateProjectTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_project_label.ts b/apps/sim/tools/linear/update_project_label.ts
index 51932a54efe..804ffae7b6c 100644
--- a/apps/sim/tools/linear/update_project_label.ts
+++ b/apps/sim/tools/linear/update_project_label.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateProjectLabelResponse,
} from '@/tools/linear/types'
import { PROJECT_LABEL_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateProjectLabelTool: ToolConfig<
@@ -55,7 +56,7 @@ export const linearUpdateProjectLabelTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_project_milestone.ts b/apps/sim/tools/linear/update_project_milestone.ts
index 59a09b6c521..528f4754fd0 100644
--- a/apps/sim/tools/linear/update_project_milestone.ts
+++ b/apps/sim/tools/linear/update_project_milestone.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateProjectMilestoneResponse,
} from '@/tools/linear/types'
import { PROJECT_MILESTONE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateProjectMilestoneTool: ToolConfig<
@@ -55,7 +56,7 @@ export const linearUpdateProjectMilestoneTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_project_status.ts b/apps/sim/tools/linear/update_project_status.ts
index c6688d91074..63d90185625 100644
--- a/apps/sim/tools/linear/update_project_status.ts
+++ b/apps/sim/tools/linear/update_project_status.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateProjectStatusResponse,
} from '@/tools/linear/types'
import { PROJECT_STATUS_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateProjectStatusTool: ToolConfig<
@@ -67,7 +68,7 @@ export const linearUpdateProjectStatusTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/update_workflow_state.ts b/apps/sim/tools/linear/update_workflow_state.ts
index 536e2213aae..a27ffa87835 100644
--- a/apps/sim/tools/linear/update_workflow_state.ts
+++ b/apps/sim/tools/linear/update_workflow_state.ts
@@ -3,6 +3,7 @@ import type {
LinearUpdateWorkflowStateResponse,
} from '@/tools/linear/types'
import { WORKFLOW_STATE_OUTPUT_PROPERTIES } from '@/tools/linear/types'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
import type { ToolConfig } from '@/tools/types'
export const linearUpdateWorkflowStateTool: ToolConfig<
@@ -61,7 +62,7 @@ export const linearUpdateWorkflowStateTool: ToolConfig<
}
return {
'Content-Type': 'application/json',
- Authorization: `Bearer ${params.accessToken}`,
+ Authorization: linearAuthorizationHeader(params.accessToken),
}
},
body: (params) => {
diff --git a/apps/sim/tools/linear/utils.test.ts b/apps/sim/tools/linear/utils.test.ts
new file mode 100644
index 00000000000..18f8fc709de
--- /dev/null
+++ b/apps/sim/tools/linear/utils.test.ts
@@ -0,0 +1,16 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { linearAuthorizationHeader } from '@/tools/linear/utils'
+
+describe('linearAuthorizationHeader', () => {
+ it('returns a personal API key bare without the Bearer scheme', () => {
+ expect(linearAuthorizationHeader('lin_api_abc123')).toBe('lin_api_abc123')
+ })
+
+ it('returns OAuth access tokens with the Bearer scheme', () => {
+ expect(linearAuthorizationHeader('lin_oauth_xyz789')).toBe('Bearer lin_oauth_xyz789')
+ expect(linearAuthorizationHeader('some-opaque-token')).toBe('Bearer some-opaque-token')
+ })
+})
diff --git a/apps/sim/tools/linear/utils.ts b/apps/sim/tools/linear/utils.ts
new file mode 100644
index 00000000000..6a07107b9f8
--- /dev/null
+++ b/apps/sim/tools/linear/utils.ts
@@ -0,0 +1,15 @@
+/**
+ * Builds the Authorization header value for Linear API requests.
+ *
+ * Linear documents two credential shapes: OAuth access tokens are sent as
+ * `Authorization: Bearer `, while personal API keys (prefixed with
+ * `lin_api_`) must be sent bare as `Authorization: ` with no scheme.
+ * This helper detects the personal-key prefix and returns the correct form
+ * so tools work with both OAuth connections and pasted API keys.
+ *
+ * @param accessToken - OAuth access token or Linear personal API key
+ * @returns The value to use for the `Authorization` header
+ */
+export function linearAuthorizationHeader(accessToken: string): string {
+ return accessToken.startsWith('lin_api_') ? accessToken : `Bearer ${accessToken}`
+}
diff --git a/apps/sim/tools/shopify/adjust_inventory.ts b/apps/sim/tools/shopify/adjust_inventory.ts
index 04780af9aaf..950ca816452 100644
--- a/apps/sim/tools/shopify/adjust_inventory.ts
+++ b/apps/sim/tools/shopify/adjust_inventory.ts
@@ -48,7 +48,7 @@ export const shopifyAdjustInventoryTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/cancel_order.ts b/apps/sim/tools/shopify/cancel_order.ts
index 97c9eb317c6..3a7ee6d7af9 100644
--- a/apps/sim/tools/shopify/cancel_order.ts
+++ b/apps/sim/tools/shopify/cancel_order.ts
@@ -64,7 +64,7 @@ export const shopifyCancelOrderTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/create_customer.ts b/apps/sim/tools/shopify/create_customer.ts
index fac5bc17045..e56d1f2c48d 100644
--- a/apps/sim/tools/shopify/create_customer.ts
+++ b/apps/sim/tools/shopify/create_customer.ts
@@ -69,7 +69,7 @@ export const shopifyCreateCustomerTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/create_fulfillment.ts b/apps/sim/tools/shopify/create_fulfillment.ts
index 6537fda0567..05435306d17 100644
--- a/apps/sim/tools/shopify/create_fulfillment.ts
+++ b/apps/sim/tools/shopify/create_fulfillment.ts
@@ -61,7 +61,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/create_product.ts b/apps/sim/tools/shopify/create_product.ts
index bfee95f7c5f..dd1fb16c8d3 100644
--- a/apps/sim/tools/shopify/create_product.ts
+++ b/apps/sim/tools/shopify/create_product.ts
@@ -63,7 +63,7 @@ export const shopifyCreateProductTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/delete_customer.ts b/apps/sim/tools/shopify/delete_customer.ts
index 06dfb072c4e..010cf109c83 100644
--- a/apps/sim/tools/shopify/delete_customer.ts
+++ b/apps/sim/tools/shopify/delete_customer.ts
@@ -32,7 +32,7 @@ export const shopifyDeleteCustomerTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/delete_product.ts b/apps/sim/tools/shopify/delete_product.ts
index c00f46ab697..42b3aa843fa 100644
--- a/apps/sim/tools/shopify/delete_product.ts
+++ b/apps/sim/tools/shopify/delete_product.ts
@@ -32,7 +32,7 @@ export const shopifyDeleteProductTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_collection.ts b/apps/sim/tools/shopify/get_collection.ts
index 480ea0ab07b..6cf7afa8dd6 100644
--- a/apps/sim/tools/shopify/get_collection.ts
+++ b/apps/sim/tools/shopify/get_collection.ts
@@ -40,7 +40,7 @@ export const shopifyGetCollectionTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_customer.ts b/apps/sim/tools/shopify/get_customer.ts
index ef83449d198..e7657df42c7 100644
--- a/apps/sim/tools/shopify/get_customer.ts
+++ b/apps/sim/tools/shopify/get_customer.ts
@@ -31,7 +31,7 @@ export const shopifyGetCustomerTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_inventory_level.ts b/apps/sim/tools/shopify/get_inventory_level.ts
index b39f9ca56f9..215bd731a36 100644
--- a/apps/sim/tools/shopify/get_inventory_level.ts
+++ b/apps/sim/tools/shopify/get_inventory_level.ts
@@ -42,7 +42,7 @@ export const shopifyGetInventoryLevelTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_order.ts b/apps/sim/tools/shopify/get_order.ts
index 93b552c6b3d..65f69ddaa02 100644
--- a/apps/sim/tools/shopify/get_order.ts
+++ b/apps/sim/tools/shopify/get_order.ts
@@ -30,7 +30,7 @@ export const shopifyGetOrderTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/get_product.ts b/apps/sim/tools/shopify/get_product.ts
index afe9be4949f..e99c3b1140f 100644
--- a/apps/sim/tools/shopify/get_product.ts
+++ b/apps/sim/tools/shopify/get_product.ts
@@ -30,7 +30,7 @@ export const shopifyGetProductTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_collections.ts b/apps/sim/tools/shopify/list_collections.ts
index e50ec507753..bc7bd3e2f0e 100644
--- a/apps/sim/tools/shopify/list_collections.ts
+++ b/apps/sim/tools/shopify/list_collections.ts
@@ -44,7 +44,7 @@ export const shopifyListCollectionsTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_customers.ts b/apps/sim/tools/shopify/list_customers.ts
index 09d160deeb1..0ecc1ab31e5 100644
--- a/apps/sim/tools/shopify/list_customers.ts
+++ b/apps/sim/tools/shopify/list_customers.ts
@@ -40,7 +40,7 @@ export const shopifyListCustomersTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_inventory_items.ts b/apps/sim/tools/shopify/list_inventory_items.ts
index 97c3992a733..57de928cd96 100644
--- a/apps/sim/tools/shopify/list_inventory_items.ts
+++ b/apps/sim/tools/shopify/list_inventory_items.ts
@@ -44,7 +44,7 @@ export const shopifyListInventoryItemsTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_locations.ts b/apps/sim/tools/shopify/list_locations.ts
index 6d6e675f5be..f51817b23a3 100644
--- a/apps/sim/tools/shopify/list_locations.ts
+++ b/apps/sim/tools/shopify/list_locations.ts
@@ -40,7 +40,7 @@ export const shopifyListLocationsTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_orders.ts b/apps/sim/tools/shopify/list_orders.ts
index 1e78c4a7ae2..4d818bde393 100644
--- a/apps/sim/tools/shopify/list_orders.ts
+++ b/apps/sim/tools/shopify/list_orders.ts
@@ -43,7 +43,7 @@ export const shopifyListOrdersTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/list_products.ts b/apps/sim/tools/shopify/list_products.ts
index 3903f9df073..ceb6cae7d23 100644
--- a/apps/sim/tools/shopify/list_products.ts
+++ b/apps/sim/tools/shopify/list_products.ts
@@ -40,7 +40,7 @@ export const shopifyListProductsTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/types.ts b/apps/sim/tools/shopify/types.ts
index fb877199fe3..d010c0b0cb9 100644
--- a/apps/sim/tools/shopify/types.ts
+++ b/apps/sim/tools/shopify/types.ts
@@ -725,7 +725,10 @@ interface ShopifyInventoryItem {
interface ShopifyBaseParams {
accessToken: string
shopDomain: string
- idToken?: string // Shop domain from OAuth, used as fallback
+ /** Store domain resolved from a service-account credential */
+ domain?: string
+ /** Shop domain from OAuth, used as fallback */
+ idToken?: string
}
// Product Tool Params
diff --git a/apps/sim/tools/shopify/update_customer.ts b/apps/sim/tools/shopify/update_customer.ts
index e44b2ea7a93..af3ee478cbd 100644
--- a/apps/sim/tools/shopify/update_customer.ts
+++ b/apps/sim/tools/shopify/update_customer.ts
@@ -69,7 +69,7 @@ export const shopifyUpdateCustomerTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/update_order.ts b/apps/sim/tools/shopify/update_order.ts
index 1d161722e1e..9e34c3476aa 100644
--- a/apps/sim/tools/shopify/update_order.ts
+++ b/apps/sim/tools/shopify/update_order.ts
@@ -48,7 +48,7 @@ export const shopifyUpdateOrderTool: ToolConfig
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/apps/sim/tools/shopify/update_product.ts b/apps/sim/tools/shopify/update_product.ts
index caa2adbf930..771dca61343 100644
--- a/apps/sim/tools/shopify/update_product.ts
+++ b/apps/sim/tools/shopify/update_product.ts
@@ -69,7 +69,7 @@ export const shopifyUpdateProductTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
+ `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`,
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
diff --git a/bun.lock b/bun.lock
index 76d9a07de1f..454f3857417 100644
--- a/bun.lock
+++ b/bun.lock
@@ -3861,7 +3861,7 @@
"svix": ["svix@1.88.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw=="],
- "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
+ "swr": ["swr@2.4.2", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],