From 8800f03fa389cf1fd3b58f38c89842aeefc03acf Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Tue, 24 Mar 2026 22:28:39 -0700
Subject: [PATCH 01/20] improvement(billing): treat past_due state correctly
(#3750)
* improvement(billing): treat past_due state correctly
* toggle fixes
* address comments
* Fix type errors
---
apps/sim/app/api/billing/portal/route.ts | 5 +-
apps/sim/app/api/billing/route.ts | 88 +--------------
apps/sim/app/api/billing/switch-plan/route.ts | 12 +-
apps/sim/app/api/logs/cleanup/route.ts | 12 +-
.../[id]/invitations/[invitationId]/route.ts | 11 +-
.../app/api/organizations/[id]/seats/route.ts | 20 +++-
.../me/subscription/[id]/transfer/route.ts | 4 +-
apps/sim/app/api/v1/admin/credits/route.ts | 19 +++-
.../api/v1/admin/organizations/[id]/route.ts | 10 +-
.../api/v1/admin/users/[id]/billing/route.ts | 4 +-
apps/sim/app/api/v1/audit-logs/auth.ts | 17 ++-
.../credential-sets/credential-sets.tsx | 6 +-
.../settings/components/inbox/inbox.tsx | 13 +--
.../components/subscription/subscription.tsx | 24 +++-
.../team-management/team-management.tsx | 6 +-
.../[workspaceId]/settings/navigation.ts | 3 +
.../credential-selector.tsx | 6 +-
.../settings-sidebar/settings-sidebar.tsx | 58 +++++-----
.../components/access-control.tsx | 31 +++++-
apps/sim/ee/sso/components/sso-settings.tsx | 6 +-
apps/sim/hooks/queries/organization.ts | 5 +-
apps/sim/lib/auth/auth.ts | 3 +-
apps/sim/lib/billing/authorization.ts | 4 +-
apps/sim/lib/billing/client/index.ts | 1 +
apps/sim/lib/billing/client/upgrade.ts | 7 +-
apps/sim/lib/billing/client/utils.ts | 24 +++-
apps/sim/lib/billing/core/access.ts | 105 ++++++++++++++++++
apps/sim/lib/billing/core/billing.ts | 25 +++--
apps/sim/lib/billing/core/organization.ts | 24 +++-
apps/sim/lib/billing/core/plan.ts | 21 +++-
apps/sim/lib/billing/core/subscription.ts | 57 ++++++++--
apps/sim/lib/billing/core/usage.ts | 10 +-
apps/sim/lib/billing/credits/balance.ts | 8 +-
apps/sim/lib/billing/index.ts | 2 +-
apps/sim/lib/billing/organization.ts | 4 +-
.../lib/billing/organizations/membership.ts | 24 ++--
apps/sim/lib/billing/plan-helpers.ts | 18 +++
.../lib/billing/subscriptions/utils.test.ts | 45 ++++++++
apps/sim/lib/billing/subscriptions/utils.ts | 42 ++++++-
apps/sim/lib/billing/threshold-billing.ts | 23 +++-
apps/sim/lib/billing/webhooks/subscription.ts | 9 +-
.../sim/lib/core/rate-limiter/rate-limiter.ts | 4 +-
apps/sim/lib/webhooks/processor.ts | 19 +---
43 files changed, 597 insertions(+), 242 deletions(-)
create mode 100644 apps/sim/lib/billing/core/access.ts
create mode 100644 apps/sim/lib/billing/subscriptions/utils.test.ts
diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts
index 34807f0c6d1..7c42728f729 100644
--- a/apps/sim/app/api/billing/portal/route.ts
+++ b/apps/sim/app/api/billing/portal/route.ts
@@ -1,11 +1,12 @@
import { db } from '@sim/db'
import { subscription as subscriptionTable, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, or } from 'drizzle-orm'
+import { and, eq, inArray, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { requireStripeClient } from '@/lib/billing/stripe-client'
+import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('BillingPortal')
@@ -45,7 +46,7 @@ export async function POST(request: NextRequest) {
and(
eq(subscriptionTable.referenceId, organizationId),
or(
- eq(subscriptionTable.status, 'active'),
+ inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
eq(subscriptionTable.cancelAtPeriodEnd, true)
)
)
diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts
index 1d793243053..3fbae3c1df1 100644
--- a/apps/sim/app/api/billing/route.ts
+++ b/apps/sim/app/api/billing/route.ts
@@ -1,99 +1,15 @@
import { db } from '@sim/db'
-import { member, userStats } from '@sim/db/schema'
+import { member } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
+import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'
-/**
- * Gets the effective billing blocked status for a user.
- * If user is in an org, also checks if the org owner is blocked.
- */
-async function getEffectiveBillingStatus(userId: string): Promise<{
- billingBlocked: boolean
- billingBlockedReason: 'payment_failed' | 'dispute' | null
- blockedByOrgOwner: boolean
-}> {
- // Check user's own status
- const userStatsRows = await db
- .select({
- blocked: userStats.billingBlocked,
- blockedReason: userStats.billingBlockedReason,
- })
- .from(userStats)
- .where(eq(userStats.userId, userId))
- .limit(1)
-
- const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false
- const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null
-
- if (userBlocked) {
- return {
- billingBlocked: true,
- billingBlockedReason: userBlockedReason,
- blockedByOrgOwner: false,
- }
- }
-
- // Check if user is in an org where owner is blocked
- const memberships = await db
- .select({ organizationId: member.organizationId })
- .from(member)
- .where(eq(member.userId, userId))
-
- // Fetch all org owners in parallel
- const ownerResults = await Promise.all(
- memberships.map((m) =>
- db
- .select({ userId: member.userId })
- .from(member)
- .where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
- .limit(1)
- )
- )
-
- // Collect owner IDs that are not the current user
- const otherOwnerIds = ownerResults
- .filter((owners) => owners.length > 0 && owners[0].userId !== userId)
- .map((owners) => owners[0].userId)
-
- if (otherOwnerIds.length > 0) {
- // Fetch all owner stats in parallel
- const ownerStatsResults = await Promise.all(
- otherOwnerIds.map((ownerId) =>
- db
- .select({
- blocked: userStats.billingBlocked,
- blockedReason: userStats.billingBlockedReason,
- })
- .from(userStats)
- .where(eq(userStats.userId, ownerId))
- .limit(1)
- )
- )
-
- for (const stats of ownerStatsResults) {
- if (stats.length > 0 && stats[0].blocked) {
- return {
- billingBlocked: true,
- billingBlockedReason: stats[0].blockedReason,
- blockedByOrgOwner: true,
- }
- }
- }
- }
-
- return {
- billingBlocked: false,
- billingBlockedReason: null,
- blockedByOrgOwner: false,
- }
-}
-
const logger = createLogger('UnifiedBillingAPI')
/**
diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts
index bf984a075af..b668335db2a 100644
--- a/apps/sim/app/api/billing/switch-plan/route.ts
+++ b/apps/sim/app/api/billing/switch-plan/route.ts
@@ -5,12 +5,17 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
+import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { writeBillingInterval } from '@/lib/billing/core/subscription'
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
+import {
+ hasUsableSubscriptionAccess,
+ hasUsableSubscriptionStatus,
+} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('SwitchPlan')
@@ -60,6 +65,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
}
+ const billingStatus = await getEffectiveBillingStatus(userId)
+ if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) {
+ return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
+ }
+
if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) {
return NextResponse.json(
{ error: 'Enterprise plan changes must be handled via support' },
@@ -91,7 +101,7 @@ export async function POST(request: NextRequest) {
const stripe = requireStripeClient()
const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId)
- if (stripeSubscription.status !== 'active') {
+ if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
}
diff --git a/apps/sim/app/api/logs/cleanup/route.ts b/apps/sim/app/api/logs/cleanup/route.ts
index 2293199c6b4..25a0acabf55 100644
--- a/apps/sim/app/api/logs/cleanup/route.ts
+++ b/apps/sim/app/api/logs/cleanup/route.ts
@@ -1,9 +1,11 @@
import { db } from '@sim/db'
import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, inArray, lt, sql } from 'drizzle-orm'
+import { and, eq, inArray, isNull, lt } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
+import { sqlIsPaid } from '@/lib/billing/plan-helpers'
+import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/core/config/env'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
@@ -29,9 +31,13 @@ export async function GET(request: NextRequest) {
.from(user)
.leftJoin(
subscription,
- sql`${user.id} = ${subscription.referenceId} AND ${subscription.status} = 'active' AND ${subscription.plan} IN ('pro', 'team', 'enterprise')`
+ and(
+ eq(user.id, subscription.referenceId),
+ inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
+ sqlIsPaid(subscription.plan)
+ )
)
- .where(sql`${subscription.id} IS NULL`)
+ .where(isNull(subscription.id))
if (freeUsers.length === 0) {
logger.info('No free users found for log cleanup')
diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts
index d7b1df2a776..044f239d825 100644
--- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts
+++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts
@@ -15,7 +15,7 @@ import {
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
@@ -23,8 +23,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
-import { isOrgPlan } from '@/lib/billing/plan-helpers'
+import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
+import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -320,7 +321,7 @@ export async function PUT(
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
- eq(subscriptionTable.status, 'active')
+ inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
@@ -338,8 +339,8 @@ export async function PUT(
.where(
and(
eq(subscriptionTable.referenceId, userId),
- eq(subscriptionTable.status, 'active'),
- eq(subscriptionTable.plan, 'pro')
+ inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
+ sqlIsPro(subscriptionTable.plan)
)
)
.limit(1)
diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts
index 0c3535df3fd..6a2be6238c0 100644
--- a/apps/sim/app/api/organizations/[id]/seats/route.ts
+++ b/apps/sim/app/api/organizations/[id]/seats/route.ts
@@ -1,13 +1,18 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
+import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { isTeam } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
+import {
+ hasUsableSubscriptionStatus,
+ USABLE_SUBSCRIPTION_STATUSES,
+} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('OrganizationSeatsAPI')
@@ -66,7 +71,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const subscriptionRecord = await db
.select()
.from(subscription)
- .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
+ .where(
+ and(
+ eq(subscription.referenceId, organizationId),
+ inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
+ )
+ )
.limit(1)
if (subscriptionRecord.length === 0) {
@@ -75,6 +85,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const orgSubscription = subscriptionRecord[0]
+ if (await isOrganizationBillingBlocked(organizationId)) {
+ return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
+ }
+
// Only team plans support seat changes (not enterprise - those are handled manually)
if (!isTeam(orgSubscription.plan)) {
return NextResponse.json(
@@ -127,7 +141,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
orgSubscription.stripeSubscriptionId
)
- if (stripeSubscription.status !== 'active') {
+ if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
}
diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts
index 16a0023edfb..fad7ddb9a81 100644
--- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts
+++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts
@@ -5,7 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
-import { hasActiveSubscription } from '@/lib/billing'
+import { hasPaidSubscription } from '@/lib/billing'
const logger = createLogger('SubscriptionTransferAPI')
@@ -90,7 +90,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
// Check if org already has an active subscription (prevent duplicates)
- if (await hasActiveSubscription(organizationId)) {
+ if (await hasPaidSubscription(organizationId)) {
return NextResponse.json(
{ error: 'Organization already has an active subscription' },
{ status: 409 }
diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts
index e2bdfbe079c..93276f8afc3 100644
--- a/apps/sim/app/api/v1/admin/credits/route.ts
+++ b/apps/sim/app/api/v1/admin/credits/route.ts
@@ -26,12 +26,16 @@
import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
-import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
+import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
+import {
+ ENTITLED_SUBSCRIPTION_STATUSES,
+ getEffectiveSeats,
+} from '@/lib/billing/subscriptions/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -95,7 +99,7 @@ export const POST = withAdminAuth(async (request) => {
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
- if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
+ if (!userSubscription || !isPaid(userSubscription.plan)) {
return badRequestResponse(
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
)
@@ -106,7 +110,7 @@ export const POST = withAdminAuth(async (request) => {
const plan = userSubscription.plan
let seats: number | null = null
- if (plan === 'team' || plan === 'enterprise') {
+ if (isOrgPlan(plan)) {
entityType = 'organization'
entityId = userSubscription.referenceId
@@ -123,7 +127,12 @@ export const POST = withAdminAuth(async (request) => {
const [subData] = await db
.select()
.from(subscription)
- .where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
+ .where(
+ and(
+ eq(subscription.referenceId, entityId),
+ inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
+ )
+ )
.limit(1)
seats = getEffectiveSeats(subData)
diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts
index 3d0373014ec..5542d8b3131 100644
--- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts
+++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts
@@ -19,7 +19,8 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, count, eq } from 'drizzle-orm'
+import { and, count, eq, inArray } from 'drizzle-orm'
+import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -58,7 +59,12 @@ export const GET = withAdminAuthParams(async (request, context) =>
db
.select()
.from(subscription)
- .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
+ .where(
+ and(
+ eq(subscription.referenceId, organizationId),
+ inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
+ )
+ )
.limit(1),
])
diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts
index 0caf2c655a9..5e863c82ab9 100644
--- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts
+++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts
@@ -24,6 +24,7 @@ import { createLogger } from '@sim/logger'
import { eq, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
+import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -154,8 +155,7 @@ export const PATCH = withAdminAuthParams(async (request, context) =
.limit(1)
const userSubscription = await getHighestPrioritySubscription(userId)
- const isTeamOrEnterpriseMember =
- userSubscription && ['team', 'enterprise'].includes(userSubscription.plan)
+ const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan)
const [orgMembership] = await db
.select({ organizationId: member.organizationId })
diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts
index 085884488e9..b382e284be2 100644
--- a/apps/sim/app/api/v1/audit-logs/auth.ts
+++ b/apps/sim/app/api/v1/audit-logs/auth.ts
@@ -8,8 +8,10 @@
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, inArray } from 'drizzle-orm'
import { NextResponse } from 'next/server'
+import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
+import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('V1AuditLogsAuth')
@@ -57,6 +59,17 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise= 25000 || isEnterprise(plan)
+ const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
if (isLoading || (isBillingEnabled && isSubLoading)) {
return
}
- if (isBillingEnabled && !isMaxPlan) {
+ if (isBillingEnabled && !subscriptionAccess.hasUsableMaxAccess) {
return (
- Sim Mailer requires a Max plan
+ Sim Mailer requires an active Max plan
- Upgrade to Max to receive tasks via email and let Sim work on your behalf.
+ Upgrade to Max and ensure billing is active to receive tasks via email and let Sim work
+ on your behalf.
{
- if (!subscription.isPaid || !permissions.canEditUsageLimit || isBlocked) return 'hidden'
+ if (!hasUsablePaidAccess || !permissions.canEditUsageLimit) return 'hidden'
return isOnDemandActive ? 'disable' : 'enable'
})()
@@ -751,7 +761,9 @@ export function Subscription() {
? `Switch to ${isAnnual ? 'Annual' : 'Monthly'}`
: subscription.isTeam
? 'Upgrade Team'
- : 'Upgrade'
+ : subscription.isFree
+ ? 'Get started'
+ : 'Upgrade'
}
onButtonClick={
isOnMax
@@ -916,7 +928,7 @@ export function Subscription() {
{subscription.isPaid && permissions.canViewUsageInfo && (
{
- return allNavigationItems.filter((item) => {
+ return allNavigationItems.flatMap((item) => {
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
- return false
+ return []
}
if (item.id === 'template-profile') {
- return false
+ return []
}
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
- return false
+ return []
}
if (item.id === 'mcp' && permissionConfig.disableMcpTools) {
- return false
+ return []
}
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) {
- return false
+ return []
}
if (item.id === 'skills' && permissionConfig.disableSkills) {
- return false
+ return []
}
if (item.selfHostedOverride && !isHosted) {
if (item.id === 'sso') {
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
- return !hasProviders || isSSOProviderOwner === true
+ return !hasProviders || isSSOProviderOwner === true ? [{ ...item, disabled: false }] : []
}
- return true
- }
-
- if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
- return false
- }
-
- if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
- return false
+ return [{ ...item, disabled: false }]
}
if (item.requiresHosted && !isHosted) {
- return false
+ return []
}
const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
const effectiveSuperUser = isSuperUser && superUserModeEnabled
if (item.requiresSuperUser && !effectiveSuperUser) {
- return false
+ return []
}
if (item.requiresAdminRole && !isSuperUser) {
- return false
+ return []
}
- return true
+ const disabled =
+ (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) ||
+ (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) ||
+ (item.requiresMax && !subscriptionAccess.hasUsableMaxAccess)
+
+ return [{ ...item, disabled }]
})
}, [
hasTeamPlan,
hasEnterprisePlan,
+ subscriptionAccess.hasUsableMaxAccess,
isOrgAdminOrOwner,
isSSOProviderOwner,
ssoProvidersData?.providers?.length,
@@ -252,9 +250,13 @@ export function SettingsSidebar({
{sectionItems.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
+ const disabled = Boolean(item.disabled)
const itemClassName = cn(
- 'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]',
- active && 'bg-[var(--surface-active)]'
+ 'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px]',
+ disabled
+ ? 'cursor-not-allowed opacity-50'
+ : 'hover:bg-[var(--surface-active)]',
+ active && !disabled && 'bg-[var(--surface-active)]'
)
const content = (
<>
@@ -278,9 +280,11 @@ export function SettingsSidebar({
handlePrefetch(item.id)}
onFocus={() => handlePrefetch(item.id)}
onClick={() =>
+ !disabled &&
router.replace(getSettingsHref({ section: item.id as SettingsSection }), {
scroll: false,
})
diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx
index f91ceaff1d7..083b7be68dc 100644
--- a/apps/sim/ee/access-control/components/access-control.tsx
+++ b/apps/sim/ee/access-control/components/access-control.tsx
@@ -25,7 +25,8 @@ import {
} from '@/components/emcn'
import { Input as BaseInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
-import { getSubscriptionStatus } from '@/lib/billing/client'
+import { getSubscriptionAccessState } from '@/lib/billing/client'
+import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization'
@@ -242,8 +243,8 @@ export function AccessControl() {
const { data: subscriptionData, isPending: subLoading } = useSubscriptionData()
const activeOrganization = organizationsData?.activeOrganization
- const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
- const hasEnterprisePlan = subscriptionStatus.isEnterprise
+ const subscriptionStatus = getSubscriptionAccessState(subscriptionData?.data)
+ const hasEnterprisePlan = subscriptionStatus.hasUsableEnterpriseAccess
const userRole = getUserRole(activeOrganization, session?.user?.email)
const isOwner = userRole === 'owner'
const isAdmin = userRole === 'admin'
@@ -685,6 +686,30 @@ export function AccessControl() {
return
}
+ if (isBillingEnabled && !canManage) {
+ if (!activeOrganization) {
+ return (
+
+ You must be part of an organization to manage Access Control.
+
+ )
+ }
+ if (!hasEnterprisePlan) {
+ return (
+
+ Access Control is available on Enterprise plans only.
+
+ )
+ }
+ if (!isOrgAdminOrOwner) {
+ return (
+
+ Only organization owners and admins can manage Access Control settings.
+
+ )
+ }
+ }
+
if (viewingGroup) {
return (
<>
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx
index abb7f120d6f..e4dcd4c28eb 100644
--- a/apps/sim/ee/sso/components/sso-settings.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.tsx
@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react'
import { Button, Combobox, Input, Skeleton, Switch, Textarea } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
-import { getSubscriptionStatus } from '@/lib/billing/client/utils'
+import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -76,8 +76,8 @@ export function SSO() {
const isOwner = userRole === 'owner'
const isAdmin = userRole === 'admin'
const canManageSSO = isOwner || isAdmin
- const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
- const hasEnterprisePlan = subscriptionStatus.isEnterprise
+ const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data)
+ const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
const isSSOProviderOwner =
!isBillingEnabled && userId ? providers.some((p: any) => p.userId === userId) : null
diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts
index 3b08ee0de4b..b5f634cf06a 100644
--- a/apps/sim/hooks/queries/organization.ts
+++ b/apps/sim/hooks/queries/organization.ts
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client } from '@/lib/auth/auth-client'
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
+import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('OrganizationQueries')
@@ -87,10 +88,10 @@ async function fetchOrganizationSubscription(orgId: string, _signal?: AbortSigna
}
const teamSubscription = response.data?.find(
- (sub: any) => sub.status === 'active' && isTeam(sub.plan)
+ (sub: any) => hasPaidSubscriptionStatus(sub.status) && isTeam(sub.plan)
)
const enterpriseSubscription = response.data?.find(
- (sub: any) => isEnterprise(sub.plan) || sub.plan === 'enterprise-plus'
+ (sub: any) => hasPaidSubscriptionStatus(sub.status) && isEnterprise(sub.plan)
)
const activeSubscription = enterpriseSubscription || teamSubscription
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 166353d0eb9..7bfaa64889d 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -45,6 +45,7 @@ import {
} from '@/lib/billing/organization'
import { isOrgPlan, isTeam } from '@/lib/billing/plan-helpers'
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
+import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
@@ -2999,7 +3000,7 @@ export const auth = betterAuth({
.where(eq(schema.subscription.referenceId, user.id))
const hasTeamPlan = dbSubscriptions.some(
- (sub) => sub.status === 'active' && isOrgPlan(sub.plan)
+ (sub) => hasPaidSubscriptionStatus(sub.status) && isOrgPlan(sub.plan)
)
return hasTeamPlan
diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts
index 88317f9ad12..c84353cc76a 100644
--- a/apps/sim/lib/billing/authorization.ts
+++ b/apps/sim/lib/billing/authorization.ts
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
-import { hasActiveSubscription } from '@/lib/billing'
+import { hasPaidSubscription } from '@/lib/billing'
const logger = createLogger('BillingAuthorization')
@@ -24,7 +24,7 @@ export async function authorizeSubscriptionReference(
}
// Only block duplicate subscriptions during upgrade/checkout, not cancel/restore/list
- if (action === 'upgrade-subscription' && (await hasActiveSubscription(referenceId))) {
+ if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
referenceId,
diff --git a/apps/sim/lib/billing/client/index.ts b/apps/sim/lib/billing/client/index.ts
index b088805946a..55927afbf28 100644
--- a/apps/sim/lib/billing/client/index.ts
+++ b/apps/sim/lib/billing/client/index.ts
@@ -14,6 +14,7 @@ export {
getDaysRemainingInPeriod,
getFilledPillColor,
getRemainingBudget,
+ getSubscriptionAccessState,
getSubscriptionStatus,
getUsage,
isAtLeastPro,
diff --git a/apps/sim/lib/billing/client/upgrade.ts b/apps/sim/lib/billing/client/upgrade.ts
index 5c278dd7fa0..08d58f52772 100644
--- a/apps/sim/lib/billing/client/upgrade.ts
+++ b/apps/sim/lib/billing/client/upgrade.ts
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { client, useSession, useSubscription } from '@/lib/auth/auth-client'
import { buildPlanName, isOrgPlan } from '@/lib/billing/plan-helpers'
+import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { organizationKeys } from '@/hooks/queries/organization'
const logger = createLogger('SubscriptionUpgrade')
@@ -41,7 +42,7 @@ export function useSubscriptionUpgrade() {
const listResult = await client.subscription.list()
allSubscriptions = listResult.data || []
const activePersonalSub = allSubscriptions.find(
- (sub: any) => sub.status === 'active' && sub.referenceId === userId
+ (sub: any) => hasPaidSubscriptionStatus(sub.status) && sub.referenceId === userId
)
currentSubscriptionId = activePersonalSub?.id
} catch (_e) {
@@ -67,7 +68,9 @@ export function useSubscriptionUpgrade() {
// Check if this org already has an active team subscription
const existingTeamSub = allSubscriptions.find(
(sub: any) =>
- sub.status === 'active' && sub.referenceId === existingOrg.id && isOrgPlan(sub.plan)
+ hasPaidSubscriptionStatus(sub.status) &&
+ sub.referenceId === existingOrg.id &&
+ isOrgPlan(sub.plan)
)
if (existingTeamSub) {
diff --git a/apps/sim/lib/billing/client/utils.ts b/apps/sim/lib/billing/client/utils.ts
index f84ad868bdb..206af4d5bdf 100644
--- a/apps/sim/lib/billing/client/utils.ts
+++ b/apps/sim/lib/billing/client/utils.ts
@@ -4,7 +4,8 @@
*/
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
-import { isFree, isPro } from '@/lib/billing/plan-helpers'
+import { getPlanTierCredits, isEnterprise, isFree, isPro } from '@/lib/billing/plan-helpers'
+import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils'
import { USAGE_PILL_COLORS } from './consts'
import type { BillingStatus, SubscriptionData, UsageData } from './types'
@@ -38,6 +39,27 @@ export function getSubscriptionStatus(
}
}
+export function getSubscriptionAccessState(
+ subscriptionData: Partial | null | undefined
+) {
+ const status = getSubscriptionStatus(subscriptionData)
+ const billingBlocked = Boolean(subscriptionData?.billingBlocked)
+ const hasUsablePaidAccess = hasUsableSubscriptionAccess(status.status, billingBlocked)
+ const hasUsableTeamAccess = hasUsablePaidAccess && (status.isTeam || status.isEnterprise)
+ const hasUsableEnterpriseAccess = hasUsablePaidAccess && status.isEnterprise
+ const hasUsableMaxAccess =
+ hasUsablePaidAccess && (getPlanTierCredits(status.plan) >= 25000 || isEnterprise(status.plan))
+
+ return {
+ ...status,
+ billingBlocked,
+ hasUsablePaidAccess,
+ hasUsableTeamAccess,
+ hasUsableEnterpriseAccess,
+ hasUsableMaxAccess,
+ }
+}
+
/**
* Get usage data from subscription data
* Validates and sanitizes all numeric values to prevent crashes from malformed data
diff --git a/apps/sim/lib/billing/core/access.ts b/apps/sim/lib/billing/core/access.ts
new file mode 100644
index 00000000000..944b89478b9
--- /dev/null
+++ b/apps/sim/lib/billing/core/access.ts
@@ -0,0 +1,105 @@
+import { db } from '@sim/db'
+import { member, userStats } from '@sim/db/schema'
+import { and, eq } from 'drizzle-orm'
+
+export interface EffectiveBillingStatus {
+ billingBlocked: boolean
+ billingBlockedReason: 'payment_failed' | 'dispute' | null
+ blockedByOrgOwner: boolean
+}
+
+/**
+ * Gets the effective billing blocked status for a user.
+ * If the user belongs to an organization, also checks whether the org owner is blocked.
+ */
+export async function getEffectiveBillingStatus(userId: string): Promise {
+ const userStatsRows = await db
+ .select({
+ blocked: userStats.billingBlocked,
+ blockedReason: userStats.billingBlockedReason,
+ })
+ .from(userStats)
+ .where(eq(userStats.userId, userId))
+ .limit(1)
+
+ const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false
+ const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null
+
+ if (userBlocked) {
+ return {
+ billingBlocked: true,
+ billingBlockedReason: userBlockedReason,
+ blockedByOrgOwner: false,
+ }
+ }
+
+ const memberships = await db
+ .select({ organizationId: member.organizationId })
+ .from(member)
+ .where(eq(member.userId, userId))
+
+ const ownerResults = await Promise.all(
+ memberships.map((m) =>
+ db
+ .select({ userId: member.userId })
+ .from(member)
+ .where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
+ .limit(1)
+ )
+ )
+
+ const otherOwnerIds = ownerResults
+ .filter((owners) => owners.length > 0 && owners[0].userId !== userId)
+ .map((owners) => owners[0].userId)
+
+ if (otherOwnerIds.length > 0) {
+ const ownerStatsResults = await Promise.all(
+ otherOwnerIds.map((ownerId) =>
+ db
+ .select({
+ blocked: userStats.billingBlocked,
+ blockedReason: userStats.billingBlockedReason,
+ })
+ .from(userStats)
+ .where(eq(userStats.userId, ownerId))
+ .limit(1)
+ )
+ )
+
+ for (const stats of ownerStatsResults) {
+ if (stats.length > 0 && stats[0].blocked) {
+ return {
+ billingBlocked: true,
+ billingBlockedReason: stats[0].blockedReason,
+ blockedByOrgOwner: true,
+ }
+ }
+ }
+ }
+
+ return {
+ billingBlocked: false,
+ billingBlockedReason: null,
+ blockedByOrgOwner: false,
+ }
+}
+
+export async function isOrganizationBillingBlocked(organizationId: string): Promise {
+ const [owner] = await db
+ .select({ userId: member.userId })
+ .from(member)
+ .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
+ .limit(1)
+
+ if (!owner) {
+ return false
+ }
+
+ const [ownerStats] = await db
+ .select({ blocked: userStats.billingBlocked })
+ .from(userStats)
+ .where(eq(userStats.userId, owner.userId))
+ .limit(1)
+
+ return !!ownerStats?.blocked
+}
diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts
index ab5cf367e7a..2edb18f4af9 100644
--- a/apps/sim/lib/billing/core/billing.ts
+++ b/apps/sim/lib/billing/core/billing.ts
@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, inArray } from 'drizzle-orm'
import {
getBillingInterval,
getHighestPrioritySubscription,
@@ -19,7 +19,12 @@ import {
isPro,
isTeam,
} from '@/lib/billing/plan-helpers'
-import { getFreeTierLimit, getPlanPricing } from '@/lib/billing/subscriptions/utils'
+import {
+ ENTITLED_SUBSCRIPTION_STATUSES,
+ getFreeTierLimit,
+ getPlanPricing,
+ hasPaidSubscriptionStatus,
+} from '@/lib/billing/subscriptions/utils'
import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal'
export { getPlanPricing }
@@ -36,7 +41,12 @@ export async function getOrganizationSubscription(organizationId: string) {
const orgSubs = await db
.select()
.from(subscription)
- .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
+ .where(
+ and(
+ eq(subscription.referenceId, organizationId),
+ inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
+ )
+ )
.limit(1)
return orgSubs.length > 0 ? orgSubs[0] : null
@@ -299,10 +309,11 @@ export async function getSimplifiedBillingSummary(
// Determine subscription type flags
const plan = subscription?.plan || 'free'
- const planIsPaid = isPaid(plan)
- const planIsPro = isPro(plan)
- const planIsTeam = isTeam(plan)
- const planIsEnterprise = isEnterprise(plan)
+ const hasPaidEntitlement = hasPaidSubscriptionStatus(subscription?.status)
+ const planIsPaid = hasPaidEntitlement && isPaid(plan)
+ const planIsPro = hasPaidEntitlement && isPro(plan)
+ const planIsTeam = hasPaidEntitlement && isTeam(plan)
+ const planIsEnterprise = hasPaidEntitlement && isEnterprise(plan)
if (organizationId) {
// Organization billing summary
diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts
index 2067fc36af7..c025e722b6d 100644
--- a/apps/sim/lib/billing/core/organization.ts
+++ b/apps/sim/lib/billing/core/organization.ts
@@ -1,11 +1,17 @@
import { db } from '@sim/db'
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, inArray } from 'drizzle-orm'
+import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers'
-import { getEffectiveSeats, getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
+import {
+ ENTITLED_SUBSCRIPTION_STATUSES,
+ getEffectiveSeats,
+ getFreeTierLimit,
+ hasUsableSubscriptionStatus,
+} from '@/lib/billing/subscriptions/utils'
const logger = createLogger('OrganizationBilling')
@@ -18,7 +24,12 @@ async function getOrganizationSubscription(organizationId: string) {
const orgSubs = await db
.select()
.from(subscription)
- .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
+ .where(
+ and(
+ eq(subscription.referenceId, organizationId),
+ inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
+ )
+ )
.limit(1)
return orgSubs.length > 0 ? orgSubs[0] : null
@@ -237,6 +248,13 @@ export async function updateOrganizationUsageLimit(
return { success: false, error: 'No active subscription found' }
}
+ if (
+ !hasUsableSubscriptionStatus(subscription.status) ||
+ (await isOrganizationBillingBlocked(organizationId))
+ ) {
+ return { success: false, error: 'An active subscription is required to edit usage limits' }
+ }
+
// Enterprise plans have fixed usage limits that cannot be changed
if (isEnterprise(subscription.plan)) {
return {
diff --git a/apps/sim/lib/billing/core/plan.ts b/apps/sim/lib/billing/core/plan.ts
index 6224b0986ec..efed6996b3a 100644
--- a/apps/sim/lib/billing/core/plan.ts
+++ b/apps/sim/lib/billing/core/plan.ts
@@ -2,14 +2,19 @@ import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
-import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
+import {
+ checkEnterprisePlan,
+ checkProPlan,
+ checkTeamPlan,
+ ENTITLED_SUBSCRIPTION_STATUSES,
+} from '@/lib/billing/subscriptions/utils'
const logger = createLogger('PlanLookup')
export type HighestPrioritySubscription = Awaited>
/**
- * Get the highest priority active subscription for a user
+ * Get the highest priority paid subscription for a user.
* Priority: Enterprise > Team > Pro > Free
*/
export async function getHighestPrioritySubscription(userId: string) {
@@ -17,7 +22,12 @@ export async function getHighestPrioritySubscription(userId: string) {
const personalSubs = await db
.select()
.from(subscription)
- .where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
+ .where(
+ and(
+ eq(subscription.referenceId, userId),
+ inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
+ )
+ )
const memberships = await db
.select({ organizationId: member.organizationId })
@@ -41,7 +51,10 @@ export async function getHighestPrioritySubscription(userId: string) {
.select()
.from(subscription)
.where(
- and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
+ and(
+ inArray(subscription.referenceId, validOrgIds),
+ inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
+ )
)
}
}
diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts
index ff435a085ed..e7c08e76f63 100644
--- a/apps/sim/lib/billing/core/subscription.ts
+++ b/apps/sim/lib/billing/core/subscription.ts
@@ -1,7 +1,8 @@
import { db } from '@sim/db'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, sql } from 'drizzle-orm'
+import { and, eq, inArray, sql } from 'drizzle-orm'
+import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import {
@@ -14,8 +15,11 @@ import {
checkEnterprisePlan,
checkProPlan,
checkTeamPlan,
+ ENTITLED_SUBSCRIPTION_STATUSES,
getFreeTierLimit,
getPerUserMinimumLimit,
+ hasUsableSubscriptionAccess,
+ USABLE_SUBSCRIPTION_STATUSES,
} from '@/lib/billing/subscriptions/utils'
import type { UserSubscriptionState } from '@/lib/billing/types'
import {
@@ -63,17 +67,22 @@ export async function writeBillingInterval(
}
/**
- * Check if a referenceId (user ID or org ID) has an active subscription
- * Used for duplicate subscription prevention
+ * Check if a referenceId (user ID or org ID) has a paid subscription row.
+ * Used for duplicate subscription prevention and transfer safety.
*
* Fails closed: returns true on error to prevent duplicate creation
*/
-export async function hasActiveSubscription(referenceId: string): Promise {
+export async function hasPaidSubscription(referenceId: string): Promise {
try {
const [activeSub] = await db
.select({ id: subscription.id })
.from(subscription)
- .where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active')))
+ .where(
+ and(
+ eq(subscription.referenceId, referenceId),
+ inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
+ )
+ )
.limit(1)
return !!activeSub
@@ -189,13 +198,18 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise {
return false
}
+ const billingStatus = await getEffectiveBillingStatus(userId)
+ if (billingStatus.billingBlocked) {
+ return false
+ }
+
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, memberRecord.organizationId),
- eq(subscription.status, 'active')
+ inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
@@ -293,10 +312,19 @@ export async function isOrganizationOnTeamOrEnterprisePlan(
return true
}
+ if (await isOrganizationBillingBlocked(organizationId)) {
+ return false
+ }
+
const [orgSub] = await db
.select()
.from(subscription)
- .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
+ .where(
+ and(
+ eq(subscription.referenceId, organizationId),
+ inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
+ )
+ )
.limit(1)
return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
@@ -320,10 +348,19 @@ export async function isOrganizationOnEnterprisePlan(organizationId: string): Pr
return true
}
+ if (await isOrganizationBillingBlocked(organizationId)) {
+ return false
+ }
+
const [orgSub] = await db
.select()
.from(subscription)
- .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
+ .where(
+ and(
+ eq(subscription.referenceId, organizationId),
+ inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
+ )
+ )
.limit(1)
return !!orgSub && checkEnterprisePlan(orgSub)
@@ -413,6 +450,8 @@ export async function hasInboxAccess(userId: string): Promise {
}
const sub = await getHighestPrioritySubscription(userId)
if (!sub) return false
+ const billingStatus = await getEffectiveBillingStatus(userId)
+ if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false
return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub)
} catch (error) {
logger.error('Error checking inbox access', { error, userId })
diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts
index a822c332fb2..d5154e80a04 100644
--- a/apps/sim/lib/billing/core/usage.ts
+++ b/apps/sim/lib/billing/core/usage.ts
@@ -7,6 +7,7 @@ import {
renderFreeTierUpgradeEmail,
renderUsageThresholdEmail,
} from '@/components/emails'
+import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import {
getHighestPrioritySubscription,
type HighestPrioritySubscription,
@@ -25,6 +26,8 @@ import {
getFreeTierLimit,
getPerUserMinimumLimit,
getPlanPricing,
+ hasPaidSubscriptionStatus,
+ hasUsableSubscriptionAccess,
} from '@/lib/billing/subscriptions/utils'
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal'
@@ -338,6 +341,11 @@ export async function updateUserUsageLimit(
return { success: false, error: 'Free plan users cannot edit usage limits' }
}
+ const billingStatus = await getEffectiveBillingStatus(userId)
+ if (!hasUsableSubscriptionAccess(subscription.status, billingStatus.billingBlocked)) {
+ return { success: false, error: 'An active subscription is required to edit usage limits' }
+ }
+
const minimumLimit = getPerUserMinimumLimit(subscription)
logger.info('Applying plan-based validation', {
@@ -501,7 +509,7 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise {
const subscription = await getHighestPrioritySubscription(userId)
- if (!subscription || subscription.status !== 'active') {
+ if (!subscription) {
+ return false
+ }
+ const billingStatus = await getEffectiveBillingStatus(userId)
+ if (!hasUsableSubscriptionAccess(subscription.status, billingStatus.billingBlocked)) {
return false
}
// Enterprise users must contact support to purchase credits
diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts
index 18960897953..c8dd1d6c4ff 100644
--- a/apps/sim/lib/billing/index.ts
+++ b/apps/sim/lib/billing/index.ts
@@ -11,8 +11,8 @@ export {
getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState,
hasAccessControlAccess,
- hasActiveSubscription,
hasCredentialSetsAccess,
+ hasPaidSubscription,
hasSSOAccess,
isEnterpriseOrgAdminOrOwner,
isEnterprisePlan as hasEnterprisePlan,
diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts
index b828ba1f966..cc75af83374 100644
--- a/apps/sim/lib/billing/organization.ts
+++ b/apps/sim/lib/billing/organization.ts
@@ -8,7 +8,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
-import { hasActiveSubscription } from '@/lib/billing'
+import { hasPaidSubscription } from '@/lib/billing'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isTeam } from '@/lib/billing/plan-helpers'
@@ -162,7 +162,7 @@ export async function ensureOrganizationForTeamSubscription(
const membership = existingMembership[0]
if (membership.role === 'owner' || membership.role === 'admin') {
// Check if org already has an active subscription (prevent duplicates)
- if (await hasActiveSubscription(membership.organizationId)) {
+ if (await hasPaidSubscription(membership.organizationId)) {
logger.error('Organization already has an active subscription', {
userId,
organizationId: membership.organizationId,
diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts
index 2a917beaa16..424f8c07147 100644
--- a/apps/sim/lib/billing/organizations/membership.ts
+++ b/apps/sim/lib/billing/organizations/membership.ts
@@ -17,8 +17,9 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
-import { isOrgPlan } from '@/lib/billing/plan-helpers'
+import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
+import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
const logger = createLogger('OrganizationMembership')
@@ -123,8 +124,8 @@ export async function restoreUserProSubscription(userId: string): Promise orgIds.includes(s.referenceId) && isOrgPlan(s.plan)
- )
+ hasAnyPaidTeam = orgPaidSubs.some((s) => isOrgPlan(s.plan))
}
if (!hasAnyPaidTeam) {
diff --git a/apps/sim/lib/billing/plan-helpers.ts b/apps/sim/lib/billing/plan-helpers.ts
index 7bce474eb30..0031bf3dffe 100644
--- a/apps/sim/lib/billing/plan-helpers.ts
+++ b/apps/sim/lib/billing/plan-helpers.ts
@@ -10,6 +10,8 @@
* and map to their original dollar amounts ($20 / $40).
*/
+import type { AnyColumn } from 'drizzle-orm'
+import { eq, like, or, type SQL } from 'drizzle-orm'
import {
CREDIT_TIERS,
DEFAULT_PRO_TIER_COST_LIMIT,
@@ -113,6 +115,22 @@ export function getValidPlanNames(type: 'pro' | 'team'): string[] {
* @example getDisplayPlanName('team_6000') => 'Pro for Teams'
* @example getDisplayPlanName('pro') => 'Legacy Pro'
*/
+/**
+ * SQL-level plan filters for Drizzle queries.
+ * These are the SQL equivalents of the JS helpers above.
+ */
+export function sqlIsPro(column: AnyColumn): SQL | undefined {
+ return or(eq(column, 'pro'), like(column, 'pro_%'))
+}
+
+export function sqlIsTeam(column: AnyColumn): SQL | undefined {
+ return or(eq(column, 'team'), like(column, 'team_%'))
+}
+
+export function sqlIsPaid(column: AnyColumn): SQL | undefined {
+ return or(sqlIsPro(column)!, sqlIsTeam(column)!, eq(column, 'enterprise'))
+}
+
export function getDisplayPlanName(plan: string | null | undefined): string {
if (!plan || isFree(plan)) return 'Free'
if (isEnterprise(plan)) return 'Enterprise'
diff --git a/apps/sim/lib/billing/subscriptions/utils.test.ts b/apps/sim/lib/billing/subscriptions/utils.test.ts
new file mode 100644
index 00000000000..6a02f4e8ef4
--- /dev/null
+++ b/apps/sim/lib/billing/subscriptions/utils.test.ts
@@ -0,0 +1,45 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ canEditUsageLimit,
+ checkEnterprisePlan,
+ checkProPlan,
+ checkTeamPlan,
+ hasPaidSubscriptionStatus,
+ hasUsableSubscriptionAccess,
+ hasUsableSubscriptionStatus,
+} from '@/lib/billing/subscriptions/utils'
+
+describe('billing subscription status helpers', () => {
+ it('treats past_due as paid entitlement but not usable access', () => {
+ expect(hasPaidSubscriptionStatus('active')).toBe(true)
+ expect(hasPaidSubscriptionStatus('past_due')).toBe(true)
+ expect(hasPaidSubscriptionStatus('canceled')).toBe(false)
+
+ expect(hasUsableSubscriptionStatus('active')).toBe(true)
+ expect(hasUsableSubscriptionStatus('past_due')).toBe(false)
+ expect(hasUsableSubscriptionStatus('incomplete')).toBe(false)
+
+ expect(hasUsableSubscriptionAccess('active', false)).toBe(true)
+ expect(hasUsableSubscriptionAccess('active', true)).toBe(false)
+ expect(hasUsableSubscriptionAccess('past_due', false)).toBe(false)
+ })
+
+ it('keeps paid plan checks true for past_due subscriptions', () => {
+ expect(checkProPlan({ plan: 'pro_4000', status: 'past_due' })).toBe(true)
+ expect(checkTeamPlan({ plan: 'team_8000', status: 'past_due' })).toBe(true)
+ expect(checkEnterprisePlan({ plan: 'enterprise', status: 'past_due' })).toBe(true)
+ })
+
+ it('only allows usage limit editing for active usable subscriptions', () => {
+ expect(canEditUsageLimit({ plan: 'pro_4000', status: 'active' })).toBe(true)
+ expect(canEditUsageLimit({ plan: 'team_8000', status: 'active' })).toBe(true)
+
+ expect(canEditUsageLimit({ plan: 'pro_4000', status: 'past_due' })).toBe(false)
+ expect(canEditUsageLimit({ plan: 'team_8000', status: 'past_due' })).toBe(false)
+ expect(canEditUsageLimit({ plan: 'enterprise', status: 'active' })).toBe(false)
+ expect(canEditUsageLimit({ plan: 'free', status: 'active' })).toBe(false)
+ })
+})
diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts
index d5ddbe33223..3b26fe2a4e8 100644
--- a/apps/sim/lib/billing/subscriptions/utils.ts
+++ b/apps/sim/lib/billing/subscriptions/utils.ts
@@ -16,6 +16,38 @@ import {
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { env } from '@/lib/core/config/env'
+export const ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'past_due'] as const
+
+export const USABLE_SUBSCRIPTION_STATUSES = ['active'] as const
+
+/**
+ * Returns true when a subscription should still count as a paid plan entitlement.
+ */
+export function hasPaidSubscriptionStatus(status: string | null | undefined): boolean {
+ return ENTITLED_SUBSCRIPTION_STATUSES.includes(
+ status as (typeof ENTITLED_SUBSCRIPTION_STATUSES)[number]
+ )
+}
+
+/**
+ * Returns true when a subscription status is usable for product access.
+ */
+export function hasUsableSubscriptionStatus(status: string | null | undefined): boolean {
+ return USABLE_SUBSCRIPTION_STATUSES.includes(
+ status as (typeof USABLE_SUBSCRIPTION_STATUSES)[number]
+ )
+}
+
+/**
+ * Returns true when a subscription is usable for product access.
+ */
+export function hasUsableSubscriptionAccess(
+ status: string | null | undefined,
+ billingBlocked: boolean | null | undefined
+): boolean {
+ return hasUsableSubscriptionStatus(status) && !billingBlocked
+}
+
/**
* Get the free tier limit from env or fallback to default
*/
@@ -45,7 +77,7 @@ export function getEnterpriseTierLimitPerSeat(): number {
}
export function checkEnterprisePlan(subscription: any): boolean {
- return isEnterprise(subscription?.plan) && subscription?.status === 'active'
+ return isEnterprise(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
}
/**
@@ -81,11 +113,11 @@ export function getEffectiveSeats(subscription: any): number {
}
export function checkProPlan(subscription: any): boolean {
- return isPro(subscription?.plan) && subscription?.status === 'active'
+ return isPro(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
}
export function checkTeamPlan(subscription: any): boolean {
- return isTeam(subscription?.plan) && subscription?.status === 'active'
+ return isTeam(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
}
/**
@@ -96,7 +128,7 @@ export function checkTeamPlan(subscription: any): boolean {
* @returns The per-user minimum limit in dollars
*/
export function getPerUserMinimumLimit(subscription: any): number {
- if (!subscription || subscription.status !== 'active') {
+ if (!subscription || !hasPaidSubscriptionStatus(subscription.status)) {
return getFreeTierLimit()
}
@@ -124,7 +156,7 @@ export function getPerUserMinimumLimit(subscription: any): number {
* @returns Whether the user can edit their usage limits
*/
export function canEditUsageLimit(subscription: any): boolean {
- if (!subscription || subscription.status !== 'active') {
+ if (!subscription || !hasUsableSubscriptionStatus(subscription.status)) {
return false // Free plan users cannot edit limits
}
diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts
index 46e1defdecb..8e59b429f3e 100644
--- a/apps/sim/lib/billing/threshold-billing.ts
+++ b/apps/sim/lib/billing/threshold-billing.ts
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray, sql } from 'drizzle-orm'
import type Stripe from 'stripe'
import { DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants'
+import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { calculateSubscriptionOverage, getPlanPricing } from '@/lib/billing/core/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
@@ -15,6 +16,10 @@ import {
isTeam,
} from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
+import {
+ hasUsableSubscriptionAccess,
+ USABLE_SUBSCRIPTION_STATUSES,
+} from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/core/config/env'
const logger = createLogger('ThresholdBilling')
@@ -109,8 +114,12 @@ export async function checkAndBillOverageThreshold(userId: string): Promise
Date: Wed, 25 Mar 2026 18:29:47 +0100
Subject: [PATCH 02/20] feat(agents): generalize repository guidance for coding
agents (#3760)
* feat(agents): generalize repository guidance for coding agents
* fix(agents): use repo-root link in sim app guidance
---
.agents/skills/add-block/SKILL.md | 825 ++++++++++++++++++
.agents/skills/add-block/agents/openai.yaml | 5 +
.agents/skills/add-connector/SKILL.md | 437 ++++++++++
.../skills/add-connector/agents/openai.yaml | 5 +
.agents/skills/add-integration/SKILL.md | 760 ++++++++++++++++
.../skills/add-integration/agents/openai.yaml | 5 +
.agents/skills/add-tools/SKILL.md | 321 +++++++
.agents/skills/add-tools/agents/openai.yaml | 5 +
.agents/skills/add-trigger/SKILL.md | 708 +++++++++++++++
.agents/skills/add-trigger/agents/openai.yaml | 5 +
.agents/skills/validate-connector/SKILL.md | 316 +++++++
.../validate-connector/agents/openai.yaml | 5 +
.agents/skills/validate-integration/SKILL.md | 289 ++++++
.../validate-integration/agents/openai.yaml | 5 +
AGENTS.md | 383 ++++++++
apps/sim/AGENTS.md | 33 +
apps/sim/blocks/AGENTS.md | 12 +
apps/sim/components/emcn/AGENTS.md | 11 +
apps/sim/hooks/AGENTS.md | 11 +
apps/sim/hooks/queries/AGENTS.md | 13 +
apps/sim/stores/AGENTS.md | 12 +
apps/sim/tools/AGENTS.md | 13 +
apps/sim/triggers/AGENTS.md | 10 +
23 files changed, 4189 insertions(+)
create mode 100644 .agents/skills/add-block/SKILL.md
create mode 100644 .agents/skills/add-block/agents/openai.yaml
create mode 100644 .agents/skills/add-connector/SKILL.md
create mode 100644 .agents/skills/add-connector/agents/openai.yaml
create mode 100644 .agents/skills/add-integration/SKILL.md
create mode 100644 .agents/skills/add-integration/agents/openai.yaml
create mode 100644 .agents/skills/add-tools/SKILL.md
create mode 100644 .agents/skills/add-tools/agents/openai.yaml
create mode 100644 .agents/skills/add-trigger/SKILL.md
create mode 100644 .agents/skills/add-trigger/agents/openai.yaml
create mode 100644 .agents/skills/validate-connector/SKILL.md
create mode 100644 .agents/skills/validate-connector/agents/openai.yaml
create mode 100644 .agents/skills/validate-integration/SKILL.md
create mode 100644 .agents/skills/validate-integration/agents/openai.yaml
create mode 100644 AGENTS.md
create mode 100644 apps/sim/AGENTS.md
create mode 100644 apps/sim/blocks/AGENTS.md
create mode 100644 apps/sim/components/emcn/AGENTS.md
create mode 100644 apps/sim/hooks/AGENTS.md
create mode 100644 apps/sim/hooks/queries/AGENTS.md
create mode 100644 apps/sim/stores/AGENTS.md
create mode 100644 apps/sim/tools/AGENTS.md
create mode 100644 apps/sim/triggers/AGENTS.md
diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md
new file mode 100644
index 00000000000..0133b14e14f
--- /dev/null
+++ b/.agents/skills/add-block/SKILL.md
@@ -0,0 +1,825 @@
+---
+name: add-block
+description: Create or update a Sim integration block with correct subBlocks, conditions, dependsOn, modes, canonicalParamId usage, outputs, and tool wiring. Use when working on `apps/sim/blocks/blocks/{service}.ts` or aligning a block with its tools.
+---
+
+# Add Block Skill
+
+You are an expert at creating block configurations for Sim. You understand the serializer, subBlock types, conditions, dependsOn, modes, and all UI patterns.
+
+## Your Task
+
+When the user asks you to create a block:
+1. Create the block file in `apps/sim/blocks/blocks/{service}.ts`
+2. Configure all subBlocks with proper types, conditions, and dependencies
+3. Wire up tools correctly
+
+## Block Configuration Structure
+
+```typescript
+import { {ServiceName}Icon } from '@/components/icons'
+import type { BlockConfig } from '@/blocks/types'
+import { AuthMode } from '@/blocks/types'
+import { getScopesForService } from '@/lib/oauth/utils'
+
+export const {ServiceName}Block: BlockConfig = {
+ type: '{service}', // snake_case identifier
+ name: '{Service Name}', // Human readable
+ description: 'Brief description', // One sentence
+ longDescription: 'Detailed description for docs',
+ docsLink: 'https://docs.sim.ai/tools/{service}',
+ category: 'tools', // 'tools' | 'blocks' | 'triggers'
+ bgColor: '#HEXCOLOR', // Brand color
+ icon: {ServiceName}Icon,
+
+ // Auth mode
+ authMode: AuthMode.OAuth, // or AuthMode.ApiKey
+
+ subBlocks: [
+ // Define all UI fields here
+ ],
+
+ tools: {
+ access: ['tool_id_1', 'tool_id_2'], // Array of tool IDs this block can use
+ config: {
+ tool: (params) => `{service}_${params.operation}`, // Tool selector function
+ params: (params) => ({
+ // Transform subBlock values to tool params
+ }),
+ },
+ },
+
+ inputs: {
+ // Optional: define expected inputs from other blocks
+ },
+
+ outputs: {
+ // Define outputs available to downstream blocks
+ },
+}
+```
+
+## SubBlock Types Reference
+
+**Critical:** Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions.
+
+### Text Inputs
+```typescript
+// Single-line input
+{ id: 'field', title: 'Label', type: 'short-input', placeholder: '...' }
+
+// Multi-line input
+{ id: 'field', title: 'Label', type: 'long-input', placeholder: '...', rows: 6 }
+
+// Password input
+{ id: 'apiKey', title: 'API Key', type: 'short-input', password: true }
+```
+
+### Selection Inputs
+```typescript
+// Dropdown (static options)
+{
+ id: 'operation',
+ title: 'Operation',
+ type: 'dropdown',
+ options: [
+ { label: 'Create', id: 'create' },
+ { label: 'Update', id: 'update' },
+ ],
+ value: () => 'create', // Default value function
+}
+
+// Combobox (searchable dropdown)
+{
+ id: 'field',
+ title: 'Label',
+ type: 'combobox',
+ options: [...],
+ searchable: true,
+}
+```
+
+### Code/JSON Inputs
+```typescript
+{
+ id: 'code',
+ title: 'Code',
+ type: 'code',
+ language: 'javascript', // 'javascript' | 'json' | 'python'
+ placeholder: '// Enter code...',
+}
+```
+
+### OAuth/Credentials
+```typescript
+{
+ id: 'credential',
+ title: 'Account',
+ type: 'oauth-input',
+ serviceId: '{service}', // Must match OAuth provider service key
+ requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils
+ placeholder: 'Select account',
+ required: true,
+}
+```
+
+**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
+
+**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
+
+### Selectors (with dynamic options)
+```typescript
+// Channel selector (Slack, Discord, etc.)
+{
+ id: 'channel',
+ title: 'Channel',
+ type: 'channel-selector',
+ serviceId: '{service}',
+ placeholder: 'Select channel',
+ dependsOn: ['credential'],
+}
+
+// Project selector (Jira, etc.)
+{
+ id: 'project',
+ title: 'Project',
+ type: 'project-selector',
+ serviceId: '{service}',
+ dependsOn: ['credential'],
+}
+
+// File selector (Google Drive, etc.)
+{
+ id: 'file',
+ title: 'File',
+ type: 'file-selector',
+ serviceId: '{service}',
+ mimeType: 'application/pdf',
+ dependsOn: ['credential'],
+}
+
+// User selector
+{
+ id: 'user',
+ title: 'User',
+ type: 'user-selector',
+ serviceId: '{service}',
+ dependsOn: ['credential'],
+}
+```
+
+### Other Types
+```typescript
+// Switch/toggle
+{ id: 'enabled', type: 'switch' }
+
+// Slider
+{ id: 'temperature', title: 'Temperature', type: 'slider', min: 0, max: 2, step: 0.1 }
+
+// Table (key-value pairs)
+{ id: 'headers', title: 'Headers', type: 'table', columns: ['Key', 'Value'] }
+
+// File upload
+{
+ id: 'files',
+ title: 'Attachments',
+ type: 'file-upload',
+ multiple: true,
+ acceptedTypes: 'image/*,application/pdf',
+}
+```
+
+## File Input Handling
+
+When your block accepts file uploads, use the basic/advanced mode pattern with `normalizeFileInput`.
+
+### Basic/Advanced File Pattern
+
+```typescript
+// Basic mode: Visual file upload
+{
+ id: 'uploadFile',
+ title: 'File',
+ type: 'file-upload',
+ canonicalParamId: 'file', // Both map to 'file' param
+ placeholder: 'Upload file',
+ mode: 'basic',
+ multiple: false,
+ required: true,
+ condition: { field: 'operation', value: 'upload' },
+},
+// Advanced mode: Reference from other blocks
+{
+ id: 'fileRef',
+ title: 'File',
+ type: 'short-input',
+ canonicalParamId: 'file', // Both map to 'file' param
+ placeholder: 'Reference file (e.g., {{file_block.output}})',
+ mode: 'advanced',
+ required: true,
+ condition: { field: 'operation', value: 'upload' },
+},
+```
+
+**Critical constraints:**
+- `canonicalParamId` must NOT match any subblock's `id` in the same block
+- Values are stored under subblock `id`, not `canonicalParamId`
+
+### Normalizing File Input in tools.config
+
+Use `normalizeFileInput` to handle all input variants:
+
+```typescript
+import { normalizeFileInput } from '@/blocks/utils'
+
+tools: {
+ access: ['service_upload'],
+ config: {
+ tool: (params) => {
+ // Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy)
+ const normalizedFile = normalizeFileInput(
+ params.uploadFile || params.fileRef || params.fileContent,
+ { single: true }
+ )
+ if (normalizedFile) {
+ params.file = normalizedFile
+ }
+ return `service_${params.operation}`
+ },
+ },
+}
+```
+
+**Why this pattern?**
+- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs)
+- `canonicalParamId` only controls UI/schema mapping, not runtime values
+- `normalizeFileInput` handles JSON strings from advanced mode template resolution
+
+### File Input Types in `inputs`
+
+Use `type: 'json'` for file inputs:
+
+```typescript
+inputs: {
+ uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' },
+ fileRef: { type: 'json', description: 'File reference from previous block' },
+ // Legacy field for backwards compatibility
+ fileContent: { type: 'string', description: 'Legacy: base64 encoded content' },
+}
+```
+
+### Multiple Files
+
+For multiple file uploads:
+
+```typescript
+{
+ id: 'attachments',
+ title: 'Attachments',
+ type: 'file-upload',
+ multiple: true, // Allow multiple files
+ maxSize: 25, // Max size in MB per file
+ acceptedTypes: 'image/*,application/pdf,.doc,.docx',
+}
+
+// In tools.config:
+const normalizedFiles = normalizeFileInput(
+ params.attachments || params.attachmentRefs,
+ // No { single: true } - returns array
+)
+if (normalizedFiles) {
+ params.files = normalizedFiles
+}
+```
+
+## Condition Syntax
+
+Controls when a field is shown based on other field values.
+
+### Simple Condition
+```typescript
+condition: { field: 'operation', value: 'create' }
+// Shows when operation === 'create'
+```
+
+### Multiple Values (OR)
+```typescript
+condition: { field: 'operation', value: ['create', 'update'] }
+// Shows when operation is 'create' OR 'update'
+```
+
+### Negation
+```typescript
+condition: { field: 'operation', value: 'delete', not: true }
+// Shows when operation !== 'delete'
+```
+
+### Compound (AND)
+```typescript
+condition: {
+ field: 'operation',
+ value: 'send',
+ and: {
+ field: 'type',
+ value: 'dm',
+ not: true,
+ }
+}
+// Shows when operation === 'send' AND type !== 'dm'
+```
+
+### Complex Example
+```typescript
+condition: {
+ field: 'operation',
+ value: ['list', 'search'],
+ not: true,
+ and: {
+ field: 'authMethod',
+ value: 'oauth',
+ }
+}
+// Shows when operation NOT in ['list', 'search'] AND authMethod === 'oauth'
+```
+
+## DependsOn Pattern
+
+Controls when a field is enabled and when its options are refetched.
+
+### Simple Array (all must be set)
+```typescript
+dependsOn: ['credential']
+// Enabled only when credential has a value
+// Options refetch when credential changes
+
+dependsOn: ['credential', 'projectId']
+// Enabled only when BOTH have values
+```
+
+### Complex (all + any)
+```typescript
+dependsOn: {
+ all: ['authMethod'], // All must be set
+ any: ['credential', 'apiKey'] // At least one must be set
+}
+// Enabled when authMethod is set AND (credential OR apiKey is set)
+```
+
+## Required Pattern
+
+Can be boolean or condition-based.
+
+### Simple Boolean
+```typescript
+required: true
+required: false
+```
+
+### Conditional Required
+```typescript
+required: { field: 'operation', value: 'create' }
+// Required only when operation === 'create'
+
+required: { field: 'operation', value: ['create', 'update'] }
+// Required when operation is 'create' OR 'update'
+```
+
+## Mode Pattern (Basic vs Advanced)
+
+Controls which UI view shows the field.
+
+### Mode Options
+- `'basic'` - Only in basic view (default UI)
+- `'advanced'` - Only in advanced view
+- `'both'` - Both views (default if not specified)
+- `'trigger'` - Only in trigger configuration
+
+### canonicalParamId Pattern
+
+Maps multiple UI fields to a single serialized parameter:
+
+```typescript
+// Basic mode: Visual selector
+{
+ id: 'channel',
+ title: 'Channel',
+ type: 'channel-selector',
+ mode: 'basic',
+ canonicalParamId: 'channel', // Both map to 'channel' param
+ dependsOn: ['credential'],
+}
+
+// Advanced mode: Manual input
+{
+ id: 'channelId',
+ title: 'Channel ID',
+ type: 'short-input',
+ mode: 'advanced',
+ canonicalParamId: 'channel', // Both map to 'channel' param
+ placeholder: 'Enter channel ID manually',
+}
+```
+
+**How it works:**
+- In basic mode: `channel` selector value → `params.channel`
+- In advanced mode: `channelId` input value → `params.channel`
+- The serializer consolidates based on current mode
+
+**Critical constraints:**
+- `canonicalParamId` must NOT match any other subblock's `id` in the same block (causes conflicts)
+- `canonicalParamId` must be unique per block (only one basic/advanced pair per canonicalParamId)
+- ONLY use `canonicalParamId` to link basic/advanced mode alternatives for the same logical parameter
+- Do NOT use it for any other purpose
+
+## WandConfig Pattern
+
+Enables AI-assisted field generation.
+
+```typescript
+{
+ id: 'query',
+ title: 'Query',
+ type: 'code',
+ language: 'json',
+ wandConfig: {
+ enabled: true,
+ prompt: 'Generate a query based on the user request. Return ONLY the JSON.',
+ placeholder: 'Describe what you want to query...',
+ generationType: 'json-object', // Optional: affects AI behavior
+ maintainHistory: true, // Optional: keeps conversation context
+ },
+}
+```
+
+### Generation Types
+- `'javascript-function-body'` - JS code generation
+- `'json-object'` - Raw JSON (adds "no markdown" instruction)
+- `'json-schema'` - JSON Schema definitions
+- `'sql-query'` - SQL statements
+- `'timestamp'` - Adds current date/time context
+
+## Tools Configuration
+
+**Important:** `tools.config.tool` runs during serialization before variable resolution. Put `Number()` and other type coercions in `tools.config.params` instead, which runs at execution time after variables are resolved.
+
+**Preferred:** Use tool names directly as dropdown option IDs to avoid switch cases:
+```typescript
+// Dropdown options use tool IDs directly
+options: [
+ { label: 'Create', id: 'service_create' },
+ { label: 'Read', id: 'service_read' },
+]
+
+// Tool selector just returns the operation value
+tool: (params) => params.operation,
+```
+
+### With Parameter Transformation
+```typescript
+tools: {
+ access: ['service_action'],
+ config: {
+ tool: (params) => 'service_action',
+ params: (params) => ({
+ id: params.resourceId,
+ data: typeof params.data === 'string' ? JSON.parse(params.data) : params.data,
+ }),
+ },
+}
+```
+
+### V2 Versioned Tool Selector
+```typescript
+import { createVersionedToolSelector } from '@/blocks/utils'
+
+tools: {
+ access: [
+ 'service_create_v2',
+ 'service_read_v2',
+ 'service_update_v2',
+ ],
+ config: {
+ tool: createVersionedToolSelector({
+ baseToolSelector: (params) => `service_${params.operation}`,
+ suffix: '_v2',
+ fallbackToolId: 'service_create_v2',
+ }),
+ },
+}
+```
+
+## Outputs Definition
+
+**IMPORTANT:** Block outputs have a simpler schema than tool outputs. Block outputs do NOT support:
+- `optional: true` - This is only for tool outputs
+- `items` property - This is only for tool outputs with array types
+
+Block outputs only support:
+- `type` - The data type ('string', 'number', 'boolean', 'json', 'array')
+- `description` - Human readable description
+- Nested object structure (for complex types)
+
+```typescript
+outputs: {
+ // Simple outputs
+ id: { type: 'string', description: 'Resource ID' },
+ success: { type: 'boolean', description: 'Whether operation succeeded' },
+
+ // Use type: 'json' for complex objects or arrays (NOT type: 'array' with items)
+ items: { type: 'json', description: 'List of items' },
+ metadata: { type: 'json', description: 'Response metadata' },
+
+ // Nested outputs (for structured data)
+ user: {
+ id: { type: 'string', description: 'User ID' },
+ name: { type: 'string', description: 'User name' },
+ email: { type: 'string', description: 'User email' },
+ },
+}
+```
+
+### Typed JSON Outputs
+
+When using `type: 'json'` and you know the object shape in advance, **describe the inner fields in the description** so downstream blocks know what properties are available. For well-known, stable objects, use nested output definitions instead:
+
+```typescript
+outputs: {
+ // BAD: Opaque json with no info about what's inside
+ plan: { type: 'json', description: 'Zone plan information' },
+
+ // GOOD: Describe the known fields in the description
+ plan: {
+ type: 'json',
+ description: 'Zone plan information (id, name, price, currency, frequency, is_subscribed)',
+ },
+
+ // BEST: Use nested output definition when the shape is stable and well-known
+ plan: {
+ id: { type: 'string', description: 'Plan identifier' },
+ name: { type: 'string', description: 'Plan name' },
+ price: { type: 'number', description: 'Plan price' },
+ currency: { type: 'string', description: 'Price currency' },
+ },
+}
+```
+
+Use the nested pattern when:
+- The object has a small, stable set of fields (< 10)
+- Downstream blocks will commonly access specific properties
+- The API response shape is well-documented and unlikely to change
+
+Use `type: 'json'` with a descriptive string when:
+- The object has many fields or a dynamic shape
+- It represents a list/array of items
+- The shape varies by operation
+
+## V2 Block Pattern
+
+When creating V2 blocks (alongside legacy V1):
+
+```typescript
+// V1 Block - mark as legacy
+export const ServiceBlock: BlockConfig = {
+ type: 'service',
+ name: 'Service (Legacy)',
+ hideFromToolbar: true, // Hide from toolbar
+ // ... rest of config
+}
+
+// V2 Block - visible, uses V2 tools
+export const ServiceV2Block: BlockConfig = {
+ type: 'service_v2',
+ name: 'Service', // Clean name
+ hideFromToolbar: false, // Visible
+ subBlocks: ServiceBlock.subBlocks, // Reuse UI
+ tools: {
+ access: ServiceBlock.tools?.access?.map(id => `${id}_v2`) || [],
+ config: {
+ tool: createVersionedToolSelector({
+ baseToolSelector: (params) => (ServiceBlock.tools?.config as any)?.tool(params),
+ suffix: '_v2',
+ fallbackToolId: 'service_default_v2',
+ }),
+ params: ServiceBlock.tools?.config?.params,
+ },
+ },
+ outputs: {
+ // Flat, API-aligned outputs (not wrapped in content/metadata)
+ },
+}
+```
+
+## Registering Blocks
+
+After creating the block, remind the user to:
+1. Import in `apps/sim/blocks/registry.ts`
+2. Add to the `registry` object (alphabetically):
+
+```typescript
+import { ServiceBlock } from '@/blocks/blocks/service'
+
+export const registry: Record = {
+ // ... existing blocks ...
+ service: ServiceBlock,
+}
+```
+
+## Complete Example
+
+```typescript
+import { ServiceIcon } from '@/components/icons'
+import type { BlockConfig } from '@/blocks/types'
+import { AuthMode } from '@/blocks/types'
+import { getScopesForService } from '@/lib/oauth/utils'
+
+export const ServiceBlock: BlockConfig = {
+ type: 'service',
+ name: 'Service',
+ description: 'Integrate with Service API',
+ longDescription: 'Full description for documentation...',
+ docsLink: 'https://docs.sim.ai/tools/service',
+ category: 'tools',
+ bgColor: '#FF6B6B',
+ icon: ServiceIcon,
+ authMode: AuthMode.OAuth,
+
+ subBlocks: [
+ {
+ id: 'operation',
+ title: 'Operation',
+ type: 'dropdown',
+ options: [
+ { label: 'Create', id: 'create' },
+ { label: 'Read', id: 'read' },
+ { label: 'Update', id: 'update' },
+ { label: 'Delete', id: 'delete' },
+ ],
+ value: () => 'create',
+ },
+ {
+ id: 'credential',
+ title: 'Service Account',
+ type: 'oauth-input',
+ serviceId: 'service',
+ requiredScopes: getScopesForService('service'),
+ placeholder: 'Select account',
+ required: true,
+ },
+ {
+ id: 'resourceId',
+ title: 'Resource ID',
+ type: 'short-input',
+ placeholder: 'Enter resource ID',
+ condition: { field: 'operation', value: ['read', 'update', 'delete'] },
+ required: { field: 'operation', value: ['read', 'update', 'delete'] },
+ },
+ {
+ id: 'name',
+ title: 'Name',
+ type: 'short-input',
+ placeholder: 'Resource name',
+ condition: { field: 'operation', value: ['create', 'update'] },
+ required: { field: 'operation', value: 'create' },
+ },
+ ],
+
+ tools: {
+ access: ['service_create', 'service_read', 'service_update', 'service_delete'],
+ config: {
+ tool: (params) => `service_${params.operation}`,
+ },
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Resource ID' },
+ name: { type: 'string', description: 'Resource name' },
+ createdAt: { type: 'string', description: 'Creation timestamp' },
+ },
+}
+```
+
+## Connecting Blocks with Triggers
+
+If the service supports webhooks, connect the block to its triggers.
+
+```typescript
+import { getTrigger } from '@/triggers'
+
+export const ServiceBlock: BlockConfig = {
+ // ... basic config ...
+
+ triggers: {
+ enabled: true,
+ available: ['service_event_a', 'service_event_b', 'service_webhook'],
+ },
+
+ subBlocks: [
+ // Tool subBlocks first...
+ { id: 'operation', /* ... */ },
+
+ // Then spread trigger subBlocks
+ ...getTrigger('service_event_a').subBlocks,
+ ...getTrigger('service_event_b').subBlocks,
+ ...getTrigger('service_webhook').subBlocks,
+ ],
+}
+```
+
+See the `/add-trigger` skill for creating triggers.
+
+## Icon Requirement
+
+If the icon doesn't already exist in `@/components/icons.tsx`, **do NOT search for it yourself**. After completing the block, ask the user to provide the SVG:
+
+```
+The block is complete, but I need an icon for {Service}.
+Please provide the SVG and I'll convert it to a React component.
+
+You can usually find this in the service's brand/press kit page, or copy it from their website.
+```
+
+## Advanced Mode for Optional Fields
+
+Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes:
+- Pagination tokens
+- Time range filters (start/end time)
+- Sort order options
+- Reply settings
+- Rarely used IDs (e.g., reply-to tweet ID, quote tweet ID)
+- Max results / limits
+
+```typescript
+{
+ id: 'startTime',
+ title: 'Start Time',
+ type: 'short-input',
+ placeholder: 'ISO 8601 timestamp',
+ condition: { field: 'operation', value: ['search', 'list'] },
+ mode: 'advanced', // Rarely used, hide from basic view
+}
+```
+
+## WandConfig for Complex Inputs
+
+Use `wandConfig` for fields that are hard to fill out manually, such as timestamps, comma-separated lists, and complex query strings. This gives users an AI-assisted input experience.
+
+```typescript
+// Timestamps - use generationType: 'timestamp' to inject current date context
+{
+ id: 'startTime',
+ title: 'Start Time',
+ type: 'short-input',
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.',
+ generationType: 'timestamp',
+ },
+}
+
+// Comma-separated lists - simple prompt without generationType
+{
+ id: 'mediaIds',
+ title: 'Media IDs',
+ type: 'short-input',
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: 'Generate a comma-separated list of media IDs. Return ONLY the comma-separated values.',
+ },
+}
+```
+
+## Naming Convention
+
+All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MUST use `snake_case` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase.
+
+## Checklist Before Finishing
+
+- [ ] All subBlocks have `id`, `title` (except switch), and `type`
+- [ ] Conditions use correct syntax (field, value, not, and)
+- [ ] DependsOn set for fields that need other values
+- [ ] Required fields marked correctly (boolean or condition)
+- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)`
+- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
+- [ ] Tools.access lists all tool IDs (snake_case)
+- [ ] Tools.config.tool returns correct tool ID (snake_case)
+- [ ] Outputs match tool outputs
+- [ ] Block registered in registry.ts
+- [ ] If icon missing: asked user to provide SVG
+- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread
+- [ ] Optional/rarely-used fields set to `mode: 'advanced'`
+- [ ] Timestamps and complex inputs have `wandConfig` enabled
+
+## Final Validation (Required)
+
+After creating the block, you MUST validate it against every tool it references:
+
+1. **Read every tool definition** that appears in `tools.access` — do not skip any
+2. **For each tool, verify the block has correct:**
+ - SubBlock inputs that cover all required tool params (with correct `condition` to show for that operation)
+ - SubBlock input types that match the tool param types (e.g., dropdown for enums, short-input for strings)
+ - `tools.config.params` correctly maps subBlock IDs to tool param names (if they differ)
+ - Type coercions in `tools.config.params` for any params that need conversion (Number(), Boolean(), JSON.parse())
+3. **Verify block outputs** cover the key fields returned by all tools
+4. **Verify conditions** — each subBlock should only show for the operations that actually use it
diff --git a/.agents/skills/add-block/agents/openai.yaml b/.agents/skills/add-block/agents/openai.yaml
new file mode 100644
index 00000000000..2817f341be4
--- /dev/null
+++ b/.agents/skills/add-block/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Add Block"
+ short_description: "Build a Sim block definition"
+ brand_color: "#2563EB"
+ default_prompt: "Use $add-block to create or update the block for a Sim integration."
diff --git a/.agents/skills/add-connector/SKILL.md b/.agents/skills/add-connector/SKILL.md
new file mode 100644
index 00000000000..b26718f92f8
--- /dev/null
+++ b/.agents/skills/add-connector/SKILL.md
@@ -0,0 +1,437 @@
+---
+name: add-connector
+description: Add or update a Sim knowledge base connector for syncing documents from an external source, including auth mode, config fields, pagination, document mapping, tags, and registry wiring. Use when working in `apps/sim/connectors/{service}/` or adding a new external document source.
+---
+
+# Add Connector Skill
+
+You are an expert at adding knowledge base connectors to Sim. A connector syncs documents from an external source (Confluence, Google Drive, Notion, etc.) into a knowledge base.
+
+## Your Task
+
+When the user asks you to create a connector:
+1. Use Context7 or WebFetch to read the service's API documentation
+2. Determine the auth mode: **OAuth** (if Sim already has an OAuth provider for the service) or **API key** (if the service uses API key / Bearer token auth)
+3. Create the connector directory and config
+4. Register it in the connector registry
+
+## Directory Structure
+
+Create files in `apps/sim/connectors/{service}/`:
+```
+connectors/{service}/
+├── index.ts # Barrel export
+└── {service}.ts # ConnectorConfig definition
+```
+
+## Authentication
+
+Connectors use a discriminated union for auth config (`ConnectorAuthConfig` in `connectors/types.ts`):
+
+```typescript
+type ConnectorAuthConfig =
+ | { mode: 'oauth'; provider: OAuthService; requiredScopes?: string[] }
+ | { mode: 'apiKey'; label?: string; placeholder?: string }
+```
+
+### OAuth mode
+For services with existing OAuth providers in `apps/sim/lib/oauth/types.ts`. The `provider` must match an `OAuthService`. The modal shows a credential picker and handles token refresh automatically.
+
+### API key mode
+For services that use API key / Bearer token auth. The modal shows a password input with the configured `label` and `placeholder`. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated `encryptedApiKey` column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in `listDocuments`, `getDocument`, and `validateConfig`.
+
+## ConnectorConfig Structure
+
+### OAuth connector example
+
+```typescript
+import { createLogger } from '@sim/logger'
+import { {Service}Icon } from '@/components/icons'
+import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
+import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
+
+const logger = createLogger('{Service}Connector')
+
+export const {service}Connector: ConnectorConfig = {
+ id: '{service}',
+ name: '{Service}',
+ description: 'Sync documents from {Service} into your knowledge base',
+ version: '1.0.0',
+ icon: {Service}Icon,
+
+ auth: {
+ mode: 'oauth',
+ provider: '{service}', // Must match OAuthService in lib/oauth/types.ts
+ requiredScopes: ['read:...'],
+ },
+
+ configFields: [
+ // Rendered dynamically by the add-connector modal UI
+ // Supports 'short-input' and 'dropdown' types
+ ],
+
+ listDocuments: async (accessToken, sourceConfig, cursor) => {
+ // Paginate via cursor, extract text, compute SHA-256 hash
+ // Return { documents: ExternalDocument[], nextCursor?, hasMore }
+ },
+
+ getDocument: async (accessToken, sourceConfig, externalId) => {
+ // Return ExternalDocument or null
+ },
+
+ validateConfig: async (accessToken, sourceConfig) => {
+ // Return { valid: true } or { valid: false, error: 'message' }
+ },
+
+ // Optional: map source metadata to semantic tag keys (translated to slots by sync engine)
+ mapTags: (metadata) => {
+ // Return Record with keys matching tagDefinitions[].id
+ },
+}
+```
+
+### API key connector example
+
+```typescript
+export const {service}Connector: ConnectorConfig = {
+ id: '{service}',
+ name: '{Service}',
+ description: 'Sync documents from {Service} into your knowledge base',
+ version: '1.0.0',
+ icon: {Service}Icon,
+
+ auth: {
+ mode: 'apiKey',
+ label: 'API Key', // Shown above the input field
+ placeholder: 'Enter your {Service} API key', // Input placeholder
+ },
+
+ configFields: [ /* ... */ ],
+ listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ },
+ getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ },
+ validateConfig: async (accessToken, sourceConfig) => { /* ... */ },
+}
+```
+
+## ConfigField Types
+
+The add-connector modal renders these automatically — no custom UI needed.
+
+Three field types are supported: `short-input`, `dropdown`, and `selector`.
+
+```typescript
+// Text input
+{
+ id: 'domain',
+ title: 'Domain',
+ type: 'short-input',
+ placeholder: 'yoursite.example.com',
+ required: true,
+}
+
+// Dropdown (static options)
+{
+ id: 'contentType',
+ title: 'Content Type',
+ type: 'dropdown',
+ required: false,
+ options: [
+ { label: 'Pages only', id: 'page' },
+ { label: 'Blog posts only', id: 'blogpost' },
+ { label: 'All content', id: 'all' },
+ ],
+}
+```
+
+## Dynamic Selectors (Canonical Pairs)
+
+Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`.
+
+The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`.
+
+### Rules
+
+1. **Every selector field MUST have a canonical pair** — a corresponding `short-input` (or `dropdown`) field with the same `canonicalParamId` and `mode: 'advanced'`.
+2. **`required` must be set identically on both fields** in a pair. If the selector is required, the manual input must also be required.
+3. **`canonicalParamId` must match the key the connector expects in `sourceConfig`** (e.g. `baseId`, `channel`, `teamId`). The advanced field's `id` should typically match `canonicalParamId`.
+4. **`dependsOn` references the selector field's `id`**, not the `canonicalParamId`. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children.
+
+### Selector canonical pair example (Airtable base → table cascade)
+
+```typescript
+configFields: [
+ // Base: selector (basic) + manual (advanced)
+ {
+ id: 'baseSelector',
+ title: 'Base',
+ type: 'selector',
+ selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts
+ canonicalParamId: 'baseId',
+ mode: 'basic',
+ placeholder: 'Select a base',
+ required: true,
+ },
+ {
+ id: 'baseId',
+ title: 'Base ID',
+ type: 'short-input',
+ canonicalParamId: 'baseId',
+ mode: 'advanced',
+ placeholder: 'e.g. appXXXXXXXXXXXXXX',
+ required: true,
+ },
+ // Table: selector depends on base (basic) + manual (advanced)
+ {
+ id: 'tableSelector',
+ title: 'Table',
+ type: 'selector',
+ selectorKey: 'airtable.tables',
+ canonicalParamId: 'tableIdOrName',
+ mode: 'basic',
+ dependsOn: ['baseSelector'], // References the selector field ID
+ placeholder: 'Select a table',
+ required: true,
+ },
+ {
+ id: 'tableIdOrName',
+ title: 'Table Name or ID',
+ type: 'short-input',
+ canonicalParamId: 'tableIdOrName',
+ mode: 'advanced',
+ placeholder: 'e.g. Tasks',
+ required: true,
+ },
+ // Non-selector fields stay as-is
+ { id: 'maxRecords', title: 'Max Records', type: 'short-input', ... },
+]
+```
+
+### Selector with domain dependency (Jira/Confluence pattern)
+
+When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`.
+
+```typescript
+configFields: [
+ {
+ id: 'domain',
+ title: 'Jira Domain',
+ type: 'short-input',
+ placeholder: 'yoursite.atlassian.net',
+ required: true,
+ },
+ {
+ id: 'projectSelector',
+ title: 'Project',
+ type: 'selector',
+ selectorKey: 'jira.projects',
+ canonicalParamId: 'projectKey',
+ mode: 'basic',
+ dependsOn: ['domain'],
+ placeholder: 'Select a project',
+ required: true,
+ },
+ {
+ id: 'projectKey',
+ title: 'Project Key',
+ type: 'short-input',
+ canonicalParamId: 'projectKey',
+ mode: 'advanced',
+ placeholder: 'e.g. ENG, PROJ',
+ required: true,
+ },
+]
+```
+
+### How `dependsOn` maps to `SelectorContext`
+
+The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`):
+
+```
+oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId,
+siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId
+```
+
+### Available selector keys
+
+Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors:
+
+| SelectorKey | Context Deps | Returns |
+|-------------|-------------|---------|
+| `airtable.bases` | credential | Base ID + name |
+| `airtable.tables` | credential, `baseId` | Table ID + name |
+| `slack.channels` | credential | Channel ID + name |
+| `gmail.labels` | credential | Label ID + name |
+| `google.calendar` | credential | Calendar ID + name |
+| `linear.teams` | credential | Team ID + name |
+| `linear.projects` | credential, `teamId` | Project ID + name |
+| `jira.projects` | credential, `domain` | Project key + name |
+| `confluence.spaces` | credential, `domain` | Space key + name |
+| `notion.databases` | credential | Database ID + name |
+| `asana.workspaces` | credential | Workspace GID + name |
+| `microsoft.teams` | credential | Team ID + name |
+| `microsoft.channels` | credential, `teamId` | Channel ID + name |
+| `webflow.sites` | credential | Site ID + name |
+| `outlook.folders` | credential | Folder ID + name |
+
+## ExternalDocument Shape
+
+Every document returned from `listDocuments`/`getDocument` must include:
+
+```typescript
+{
+ externalId: string // Source-specific unique ID
+ title: string // Document title
+ content: string // Extracted plain text
+ mimeType: 'text/plain' // Always text/plain (content is extracted)
+ contentHash: string // SHA-256 of content (change detection)
+ sourceUrl?: string // Link back to original (stored on document record)
+ metadata?: Record // Source-specific data (fed to mapTags)
+}
+```
+
+## Content Hashing (Required)
+
+The sync engine uses content hashes for change detection:
+
+```typescript
+async function computeContentHash(content: string): Promise {
+ const data = new TextEncoder().encode(content)
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
+ return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
+}
+```
+
+## tagDefinitions — Declared Tag Definitions
+
+Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes.
+On connector creation, slots are **dynamically assigned** via `getNextAvailableSlot` — connectors never hardcode slot names.
+
+```typescript
+tagDefinitions: [
+ { id: 'labels', displayName: 'Labels', fieldType: 'text' },
+ { id: 'version', displayName: 'Version', fieldType: 'number' },
+ { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
+],
+```
+
+Each entry has:
+- `id`: Semantic key matching a key returned by `mapTags` (e.g. `'labels'`, `'version'`)
+- `displayName`: Human-readable name shown in the UI (e.g. "Labels", "Last Modified")
+- `fieldType`: `'text'` | `'number'` | `'date'` | `'boolean'` — determines which slot pool to draw from
+
+Users can opt out of specific tags in the modal. Disabled IDs are stored in `sourceConfig.disabledTagIds`.
+The assigned mapping (`semantic id → slot`) is stored in `sourceConfig.tagSlotMapping`.
+
+## mapTags — Metadata to Semantic Keys
+
+Maps source metadata to semantic tag keys. Required if `tagDefinitions` is set.
+The sync engine calls this automatically and translates semantic keys to actual DB slots
+using the `tagSlotMapping` stored on the connector.
+
+Return keys must match the `id` values declared in `tagDefinitions`.
+
+```typescript
+mapTags: (metadata: Record): Record => {
+ const result: Record = {}
+
+ // Validate arrays before casting — metadata may be malformed
+ const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : []
+ if (labels.length > 0) result.labels = labels.join(', ')
+
+ // Validate numbers — guard against NaN
+ if (metadata.version != null) {
+ const num = Number(metadata.version)
+ if (!Number.isNaN(num)) result.version = num
+ }
+
+ // Validate dates — guard against Invalid Date
+ if (typeof metadata.lastModified === 'string') {
+ const date = new Date(metadata.lastModified)
+ if (!Number.isNaN(date.getTime())) result.lastModified = date
+ }
+
+ return result
+}
+```
+
+## External API Calls — Use `fetchWithRetry`
+
+All external API calls must use `fetchWithRetry` from `@/lib/knowledge/documents/utils` instead of raw `fetch()`. This provides exponential backoff with retries on 429/502/503/504 errors. It returns a standard `Response` — all `.ok`, `.json()`, `.text()` checks work unchanged.
+
+For `validateConfig` (user-facing, called on save), pass `VALIDATE_RETRY_OPTIONS` to cap wait time at ~7s. Background operations (`listDocuments`, `getDocument`) use the built-in defaults (5 retries, ~31s max).
+
+```typescript
+import { VALIDATE_RETRY_OPTIONS, fetchWithRetry } from '@/lib/knowledge/documents/utils'
+
+// Background sync — use defaults
+const response = await fetchWithRetry(url, {
+ method: 'GET',
+ headers: { Authorization: `Bearer ${accessToken}` },
+})
+
+// validateConfig — tighter retry budget
+const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS)
+```
+
+## sourceUrl
+
+If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path).
+
+## Sync Engine Behavior (Do Not Modify)
+
+The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It:
+1. Calls `listDocuments` with pagination until `hasMore` is false
+2. Compares `contentHash` to detect new/changed/unchanged documents
+3. Stores `sourceUrl` and calls `mapTags` on insert/update automatically
+4. Handles soft-delete of removed documents
+5. Resolves access tokens automatically — OAuth tokens are refreshed, API keys are decrypted from the `encryptedApiKey` column
+
+You never need to modify the sync engine when adding a connector.
+
+## Icon
+
+The `icon` field on `ConnectorConfig` is used throughout the UI — in the connector list, the add-connector modal, and as the document icon in the knowledge base table (replacing the generic file type icon for connector-sourced documents). The icon is read from `CONNECTOR_REGISTRY[connectorType].icon` at runtime — no separate icon map to maintain.
+
+If the service already has an icon in `apps/sim/components/icons.tsx` (from a tool integration), reuse it. Otherwise, ask the user to provide the SVG.
+
+## Registering
+
+Add one line to `apps/sim/connectors/registry.ts`:
+
+```typescript
+import { {service}Connector } from '@/connectors/{service}'
+
+export const CONNECTOR_REGISTRY: ConnectorRegistry = {
+ // ... existing connectors ...
+ {service}: {service}Connector,
+}
+```
+
+## Reference Implementations
+
+- **OAuth**: `apps/sim/connectors/confluence/confluence.ts` — multiple config field types, `mapTags`, label fetching
+- **API key**: `apps/sim/connectors/fireflies/fireflies.ts` — GraphQL API with Bearer token auth
+
+## Checklist
+
+- [ ] Created `connectors/{service}/{service}.ts` with full ConnectorConfig
+- [ ] Created `connectors/{service}/index.ts` barrel export
+- [ ] **Auth configured correctly:**
+ - OAuth: `auth.provider` matches an existing `OAuthService` in `lib/oauth/types.ts`
+ - API key: `auth.label` and `auth.placeholder` set appropriately
+- [ ] **Selector fields configured correctly (if applicable):**
+ - Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`)
+ - `required` is identical on both fields in each canonical pair
+ - `selectorKey` exists in `hooks/selectors/registry.ts`
+ - `dependsOn` references selector field IDs (not `canonicalParamId`)
+ - Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS`
+- [ ] `listDocuments` handles pagination and computes content hashes
+- [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative)
+- [ ] `metadata` includes source-specific data for tag mapping
+- [ ] `tagDefinitions` declared for each semantic key returned by `mapTags`
+- [ ] `mapTags` implemented if source has useful metadata (labels, dates, versions)
+- [ ] `validateConfig` verifies the source is accessible
+- [ ] All external API calls use `fetchWithRetry` (not raw `fetch`)
+- [ ] All optional config fields validated in `validateConfig`
+- [ ] Icon exists in `components/icons.tsx` (or asked user to provide SVG)
+- [ ] Registered in `connectors/registry.ts`
diff --git a/.agents/skills/add-connector/agents/openai.yaml b/.agents/skills/add-connector/agents/openai.yaml
new file mode 100644
index 00000000000..0ea2afd147e
--- /dev/null
+++ b/.agents/skills/add-connector/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Add Connector"
+ short_description: "Build a Sim knowledge connector"
+ brand_color: "#0F766E"
+ default_prompt: "Use $add-connector to add or update a Sim knowledge connector for a service."
diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md
new file mode 100644
index 00000000000..ecfda6b0fac
--- /dev/null
+++ b/.agents/skills/add-integration/SKILL.md
@@ -0,0 +1,760 @@
+---
+name: add-integration
+description: Add a complete Sim integration from API docs, covering tools, block, icon, optional triggers, registrations, and integration conventions. Use when introducing a new service under `apps/sim/tools`, `apps/sim/blocks`, and `apps/sim/triggers`.
+---
+
+# Add Integration Skill
+
+You are an expert at adding complete integrations to Sim. This skill orchestrates the full process of adding a new service integration.
+
+## Overview
+
+Adding an integration involves these steps in order:
+1. **Research** - Read the service's API documentation
+2. **Create Tools** - Build tool configurations for each API operation
+3. **Create Block** - Build the block UI configuration
+4. **Add Icon** - Add the service's brand icon
+5. **Create Triggers** (optional) - If the service supports webhooks
+6. **Register** - Register tools, block, and triggers in their registries
+7. **Generate Docs** - Run the docs generation script
+
+## Step 1: Research the API
+
+Before writing any code:
+1. Use Context7 to find official documentation: `mcp__plugin_context7_context7__resolve-library-id`
+2. Or use WebFetch to read API docs directly
+3. Identify:
+ - Authentication method (OAuth, API Key, both)
+ - Available operations (CRUD, search, etc.)
+ - Required vs optional parameters
+ - Response structures
+
+## Step 2: Create Tools
+
+### Directory Structure
+```
+apps/sim/tools/{service}/
+├── index.ts # Barrel exports
+├── types.ts # TypeScript interfaces
+├── {action1}.ts # Tool for action 1
+├── {action2}.ts # Tool for action 2
+└── ...
+```
+
+### Key Patterns
+
+**types.ts:**
+```typescript
+import type { ToolResponse } from '@/tools/types'
+
+export interface {Service}{Action}Params {
+ accessToken: string // For OAuth services
+ // OR
+ apiKey: string // For API key services
+
+ requiredParam: string
+ optionalParam?: string
+}
+
+export interface {Service}Response extends ToolResponse {
+ output: {
+ // Define output structure
+ }
+}
+```
+
+**Tool file pattern:**
+```typescript
+export const {service}{Action}Tool: ToolConfig = {
+ id: '{service}_{action}',
+ name: '{Service} {Action}',
+ description: '...',
+ version: '1.0.0',
+
+ oauth: { required: true, provider: '{service}' }, // If OAuth
+
+ params: {
+ accessToken: { type: 'string', required: true, visibility: 'hidden', description: '...' },
+ // ... other params
+ },
+
+ request: { url, method, headers, body },
+
+ transformResponse: async (response) => {
+ const data = await response.json()
+ return {
+ success: true,
+ output: {
+ field: data.field ?? null, // Always handle nullables
+ },
+ }
+ },
+
+ outputs: { /* ... */ },
+}
+```
+
+### Critical Rules
+- `visibility: 'hidden'` for OAuth tokens
+- `visibility: 'user-only'` for API keys and user credentials
+- `visibility: 'user-or-llm'` for operation parameters
+- Always use `?? null` for nullable API response fields
+- Always use `?? []` for optional array fields
+- Set `optional: true` for outputs that may not exist
+- Never output raw JSON dumps - extract meaningful fields
+- When using `type: 'json'` and you know the object shape, define `properties` with the inner fields so downstream consumers know the structure. Only use bare `type: 'json'` when the shape is truly dynamic
+
+## Step 3: Create Block
+
+### File Location
+`apps/sim/blocks/blocks/{service}.ts`
+
+### Block Structure
+```typescript
+import { {Service}Icon } from '@/components/icons'
+import type { BlockConfig } from '@/blocks/types'
+import { AuthMode } from '@/blocks/types'
+import { getScopesForService } from '@/lib/oauth/utils'
+
+export const {Service}Block: BlockConfig = {
+ type: '{service}',
+ name: '{Service}',
+ description: '...',
+ longDescription: '...',
+ docsLink: 'https://docs.sim.ai/tools/{service}',
+ category: 'tools',
+ bgColor: '#HEXCOLOR',
+ icon: {Service}Icon,
+ authMode: AuthMode.OAuth, // or AuthMode.ApiKey
+
+ subBlocks: [
+ // Operation dropdown
+ {
+ id: 'operation',
+ title: 'Operation',
+ type: 'dropdown',
+ options: [
+ { label: 'Operation 1', id: 'action1' },
+ { label: 'Operation 2', id: 'action2' },
+ ],
+ value: () => 'action1',
+ },
+ // Credential field
+ {
+ id: 'credential',
+ title: '{Service} Account',
+ type: 'oauth-input',
+ serviceId: '{service}',
+ requiredScopes: getScopesForService('{service}'),
+ required: true,
+ },
+ // Conditional fields per operation
+ // ...
+ ],
+
+ tools: {
+ access: ['{service}_action1', '{service}_action2'],
+ config: {
+ tool: (params) => `{service}_${params.operation}`,
+ },
+ },
+
+ outputs: { /* ... */ },
+}
+```
+
+### Key SubBlock Patterns
+
+**Condition-based visibility:**
+```typescript
+{
+ id: 'resourceId',
+ title: 'Resource ID',
+ type: 'short-input',
+ condition: { field: 'operation', value: ['read', 'update', 'delete'] },
+ required: { field: 'operation', value: ['read', 'update', 'delete'] },
+}
+```
+
+**DependsOn for cascading selectors:**
+```typescript
+{
+ id: 'project',
+ type: 'project-selector',
+ dependsOn: ['credential'],
+},
+{
+ id: 'issue',
+ type: 'file-selector',
+ dependsOn: ['credential', 'project'],
+}
+```
+
+**Basic/Advanced mode for dual UX:**
+```typescript
+// Basic: Visual selector
+{
+ id: 'channel',
+ type: 'channel-selector',
+ mode: 'basic',
+ canonicalParamId: 'channel',
+ dependsOn: ['credential'],
+},
+// Advanced: Manual input
+{
+ id: 'channelId',
+ type: 'short-input',
+ mode: 'advanced',
+ canonicalParamId: 'channel',
+}
+```
+
+**Critical Canonical Param Rules:**
+- `canonicalParamId` must NOT match any subblock's `id` in the block
+- `canonicalParamId` must be unique per operation/condition context
+- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter
+- `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent
+- Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions
+- **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes)
+- **Inputs section:** Must list canonical param IDs (e.g., `fileId`), NOT raw subblock IDs (e.g., `fileSelector`, `manualFileId`)
+- **Params function:** Must use canonical param IDs, NOT raw subblock IDs (raw IDs are deleted after canonical transformation)
+
+## Step 4: Add Icon
+
+### File Location
+`apps/sim/components/icons.tsx`
+
+### Pattern
+```typescript
+export function {Service}Icon(props: SVGProps) {
+ return (
+
+ {/* SVG paths from user-provided SVG */}
+
+ )
+}
+```
+
+### Getting Icons
+**Do NOT search for icons yourself.** At the end of implementation, ask the user to provide the SVG:
+
+```
+I've completed the integration. Before I can add the icon, please provide the SVG for {Service}.
+You can usually find this in the service's brand/press kit page, or copy it from their website.
+
+Paste the SVG code here and I'll convert it to a React component.
+```
+
+Once the user provides the SVG:
+1. Extract the SVG paths/content
+2. Create a React component that spreads props
+3. Ensure viewBox is preserved from the original SVG
+
+## Step 5: Create Triggers (Optional)
+
+If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper.
+
+### Directory Structure
+```
+apps/sim/triggers/{service}/
+├── index.ts # Barrel exports
+├── utils.ts # Trigger options, setup instructions, extra fields
+├── {event_a}.ts # Primary trigger (includes dropdown)
+├── {event_b}.ts # Secondary triggers (no dropdown)
+└── webhook.ts # Generic webhook (optional)
+```
+
+### Key Pattern
+
+```typescript
+import { buildTriggerSubBlocks } from '@/triggers'
+import { {service}TriggerOptions, {service}SetupInstructions, build{Service}ExtraFields } from './utils'
+
+// Primary trigger - includeDropdown: true
+export const {service}EventATrigger: TriggerConfig = {
+ id: '{service}_event_a',
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: '{service}_event_a',
+ triggerOptions: {service}TriggerOptions,
+ includeDropdown: true, // Only for primary trigger!
+ setupInstructions: {service}SetupInstructions('Event A'),
+ extraFields: build{Service}ExtraFields('{service}_event_a'),
+ }),
+ // ...
+}
+
+// Secondary triggers - no dropdown
+export const {service}EventBTrigger: TriggerConfig = {
+ id: '{service}_event_b',
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: '{service}_event_b',
+ triggerOptions: {service}TriggerOptions,
+ // No includeDropdown!
+ setupInstructions: {service}SetupInstructions('Event B'),
+ extraFields: build{Service}ExtraFields('{service}_event_b'),
+ }),
+ // ...
+}
+```
+
+### Connect to Block
+```typescript
+import { getTrigger } from '@/triggers'
+
+export const {Service}Block: BlockConfig = {
+ triggers: {
+ enabled: true,
+ available: ['{service}_event_a', '{service}_event_b'],
+ },
+ subBlocks: [
+ // Tool fields...
+ ...getTrigger('{service}_event_a').subBlocks,
+ ...getTrigger('{service}_event_b').subBlocks,
+ ],
+}
+```
+
+See `/add-trigger` skill for complete documentation.
+
+## Step 6: Register Everything
+
+### Tools Registry (`apps/sim/tools/registry.ts`)
+
+```typescript
+// Add import (alphabetically)
+import {
+ {service}Action1Tool,
+ {service}Action2Tool,
+} from '@/tools/{service}'
+
+// Add to tools object (alphabetically)
+export const tools: Record = {
+ // ... existing tools ...
+ {service}_action1: {service}Action1Tool,
+ {service}_action2: {service}Action2Tool,
+}
+```
+
+### Block Registry (`apps/sim/blocks/registry.ts`)
+
+```typescript
+// Add import (alphabetically)
+import { {Service}Block } from '@/blocks/blocks/{service}'
+
+// Add to registry (alphabetically)
+export const registry: Record = {
+ // ... existing blocks ...
+ {service}: {Service}Block,
+}
+```
+
+### Trigger Registry (`apps/sim/triggers/registry.ts`) - If triggers exist
+
+```typescript
+// Add import (alphabetically)
+import {
+ {service}EventATrigger,
+ {service}EventBTrigger,
+ {service}WebhookTrigger,
+} from '@/triggers/{service}'
+
+// Add to TRIGGER_REGISTRY (alphabetically)
+export const TRIGGER_REGISTRY: TriggerRegistry = {
+ // ... existing triggers ...
+ {service}_event_a: {service}EventATrigger,
+ {service}_event_b: {service}EventBTrigger,
+ {service}_webhook: {service}WebhookTrigger,
+}
+```
+
+## Step 7: Generate Docs
+
+Run the documentation generator:
+```bash
+bun run scripts/generate-docs.ts
+```
+
+This creates `apps/docs/content/docs/en/tools/{service}.mdx`
+
+## V2 Integration Pattern
+
+If creating V2 versions (API-aligned outputs):
+
+1. **V2 Tools** - Add `_v2` suffix, version `2.0.0`, flat outputs
+2. **V2 Block** - Add `_v2` type, use `createVersionedToolSelector`
+3. **V1 Block** - Add `(Legacy)` to name, set `hideFromToolbar: true`
+4. **Registry** - Register both versions
+
+```typescript
+// In registry
+{service}: {Service}Block, // V1 (legacy, hidden)
+{service}_v2: {Service}V2Block, // V2 (visible)
+```
+
+## Complete Checklist
+
+### Tools
+- [ ] Created `tools/{service}/` directory
+- [ ] Created `types.ts` with all interfaces
+- [ ] Created tool file for each operation
+- [ ] All params have correct visibility
+- [ ] All nullable fields use `?? null`
+- [ ] All optional outputs have `optional: true`
+- [ ] Created `index.ts` barrel export
+- [ ] Registered all tools in `tools/registry.ts`
+
+### Block
+- [ ] Created `blocks/blocks/{service}.ts`
+- [ ] Defined operation dropdown with all operations
+- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')`
+- [ ] Added conditional fields per operation
+- [ ] Set up dependsOn for cascading selectors
+- [ ] Configured tools.access with all tool IDs
+- [ ] Configured tools.config.tool selector
+- [ ] Defined outputs matching tool outputs
+- [ ] Registered block in `blocks/registry.ts`
+- [ ] If triggers: set `triggers.enabled` and `triggers.available`
+- [ ] If triggers: spread trigger subBlocks with `getTrigger()`
+
+### OAuth Scopes (if OAuth service)
+- [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS`
+- [ ] Added scope descriptions in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
+- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode)
+- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode)
+
+### Icon
+- [ ] Asked user to provide SVG
+- [ ] Added icon to `components/icons.tsx`
+- [ ] Icon spreads props correctly
+
+### Triggers (if service supports webhooks)
+- [ ] Created `triggers/{service}/` directory
+- [ ] Created `utils.ts` with options, instructions, and extra fields helpers
+- [ ] Primary trigger uses `includeDropdown: true`
+- [ ] Secondary triggers do NOT have `includeDropdown`
+- [ ] All triggers use `buildTriggerSubBlocks` helper
+- [ ] Created `index.ts` barrel export
+- [ ] Registered all triggers in `triggers/registry.ts`
+
+### Docs
+- [ ] Ran `bun run scripts/generate-docs.ts`
+- [ ] Verified docs file created
+
+### Final Validation (Required)
+- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs
+- [ ] Verified block subBlocks cover all required tool params with correct conditions
+- [ ] Verified block outputs match what the tools actually return
+- [ ] Verified `tools.config.params` correctly maps and coerces all param types
+
+## Example Command
+
+When the user asks to add an integration:
+
+```
+User: Add a Stripe integration
+
+You: I'll add the Stripe integration. Let me:
+
+1. First, research the Stripe API using Context7
+2. Create the tools for key operations (payments, subscriptions, etc.)
+3. Create the block with operation dropdown
+4. Register everything
+5. Generate docs
+6. Ask you for the Stripe icon SVG
+
+[Proceed with implementation...]
+
+[After completing steps 1-5...]
+
+I've completed the Stripe integration. Before I can add the icon, please provide the SVG for Stripe.
+You can usually find this in the service's brand/press kit page, or copy it from their website.
+
+Paste the SVG code here and I'll convert it to a React component.
+```
+
+## File Handling
+
+When your integration handles file uploads or downloads, follow these patterns to work with `UserFile` objects consistently.
+
+### What is a UserFile?
+
+A `UserFile` is the standard file representation in Sim:
+
+```typescript
+interface UserFile {
+ id: string // Unique identifier
+ name: string // Original filename
+ url: string // Presigned URL for download
+ size: number // File size in bytes
+ type: string // MIME type (e.g., 'application/pdf')
+ base64?: string // Optional base64 content (if small file)
+ key?: string // Internal storage key
+ context?: object // Storage context metadata
+}
+```
+
+### File Input Pattern (Uploads)
+
+For tools that accept file uploads, **always route through an internal API endpoint** rather than calling external APIs directly. This ensures proper file content retrieval.
+
+#### 1. Block SubBlocks for File Input
+
+Use the basic/advanced mode pattern:
+
+```typescript
+// Basic mode: File upload UI
+{
+ id: 'uploadFile',
+ title: 'File',
+ type: 'file-upload',
+ canonicalParamId: 'file', // Maps to 'file' param
+ placeholder: 'Upload file',
+ mode: 'basic',
+ multiple: false,
+ required: true,
+ condition: { field: 'operation', value: 'upload' },
+},
+// Advanced mode: Reference from previous block
+{
+ id: 'fileRef',
+ title: 'File',
+ type: 'short-input',
+ canonicalParamId: 'file', // Same canonical param
+ placeholder: 'Reference file (e.g., {{file_block.output}})',
+ mode: 'advanced',
+ required: true,
+ condition: { field: 'operation', value: 'upload' },
+},
+```
+
+**Critical:** `canonicalParamId` must NOT match any subblock `id`.
+
+#### 2. Normalize File Input in Block Config
+
+In `tools.config.tool`, use `normalizeFileInput` to handle all input variants:
+
+```typescript
+import { normalizeFileInput } from '@/blocks/utils'
+
+tools: {
+ config: {
+ tool: (params) => {
+ // Normalize file from basic (uploadFile), advanced (fileRef), or legacy (fileContent)
+ const normalizedFile = normalizeFileInput(
+ params.uploadFile || params.fileRef || params.fileContent,
+ { single: true }
+ )
+ if (normalizedFile) {
+ params.file = normalizedFile
+ }
+ return `{service}_${params.operation}`
+ },
+ },
+}
+```
+
+#### 3. Create Internal API Route
+
+Create `apps/sim/app/api/tools/{service}/{action}/route.ts`:
+
+```typescript
+import { createLogger } from '@sim/logger'
+import { NextResponse, type NextRequest } from 'next/server'
+import { z } from 'zod'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas'
+import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
+import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
+
+const logger = createLogger('{Service}UploadAPI')
+
+const RequestSchema = z.object({
+ accessToken: z.string(),
+ file: FileInputSchema.optional().nullable(),
+ // Legacy field for backwards compatibility
+ fileContent: z.string().optional().nullable(),
+ // ... other params
+})
+
+export async function POST(request: NextRequest) {
+ const requestId = generateRequestId()
+
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+ if (!authResult.success) {
+ return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const body = await request.json()
+ const data = RequestSchema.parse(body)
+
+ let fileBuffer: Buffer
+ let fileName: string
+
+ // Prefer UserFile input, fall back to legacy base64
+ if (data.file) {
+ const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger)
+ if (userFiles.length === 0) {
+ return NextResponse.json({ success: false, error: 'Invalid file' }, { status: 400 })
+ }
+ const userFile = userFiles[0]
+ fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
+ fileName = userFile.name
+ } else if (data.fileContent) {
+ // Legacy: base64 string (backwards compatibility)
+ fileBuffer = Buffer.from(data.fileContent, 'base64')
+ fileName = 'file'
+ } else {
+ return NextResponse.json({ success: false, error: 'File required' }, { status: 400 })
+ }
+
+ // Now call external API with fileBuffer
+ const response = await fetch('https://api.{service}.com/upload', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${data.accessToken}` },
+ body: new Uint8Array(fileBuffer), // Convert Buffer for fetch
+ })
+
+ // ... handle response
+}
+```
+
+#### 4. Update Tool to Use Internal Route
+
+```typescript
+export const {service}UploadTool: ToolConfig = {
+ id: '{service}_upload',
+ // ...
+ params: {
+ file: { type: 'file', required: false, visibility: 'user-or-llm' },
+ fileContent: { type: 'string', required: false, visibility: 'hidden' }, // Legacy
+ },
+ request: {
+ url: '/api/tools/{service}/upload', // Internal route
+ method: 'POST',
+ body: (params) => ({
+ accessToken: params.accessToken,
+ file: params.file,
+ fileContent: params.fileContent,
+ }),
+ },
+}
+```
+
+### File Output Pattern (Downloads)
+
+For tools that return files, use `FileToolProcessor` to store files and return `UserFile` objects.
+
+#### In Tool transformResponse
+
+```typescript
+import { FileToolProcessor } from '@/executor/utils/file-tool-processor'
+
+transformResponse: async (response, context) => {
+ const data = await response.json()
+
+ // Process file outputs to UserFile objects
+ const fileProcessor = new FileToolProcessor(context)
+ const file = await fileProcessor.processFileData({
+ data: data.content, // base64 or buffer
+ mimeType: data.mimeType,
+ filename: data.filename,
+ })
+
+ return {
+ success: true,
+ output: { file },
+ }
+}
+```
+
+#### In API Route (for complex file handling)
+
+```typescript
+// Return file data that FileToolProcessor can handle
+return NextResponse.json({
+ success: true,
+ output: {
+ file: {
+ data: base64Content,
+ mimeType: 'application/pdf',
+ filename: 'document.pdf',
+ },
+ },
+})
+```
+
+### Key Helpers Reference
+
+| Helper | Location | Purpose |
+|--------|----------|---------|
+| `normalizeFileInput` | `@/blocks/utils` | Normalize file params in block config |
+| `processFilesToUserFiles` | `@/lib/uploads/utils/file-utils` | Convert raw inputs to UserFile[] |
+| `downloadFileFromStorage` | `@/lib/uploads/utils/file-utils.server` | Get file Buffer from UserFile |
+| `FileToolProcessor` | `@/executor/utils/file-tool-processor` | Process tool output files |
+| `isUserFile` | `@/lib/core/utils/user-file` | Type guard for UserFile objects |
+| `FileInputSchema` | `@/lib/uploads/utils/file-schemas` | Zod schema for file validation |
+
+### Advanced Mode for Optional Fields
+
+Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. Examples: pagination tokens, time range filters, sort order, max results, reply settings.
+
+### WandConfig for Complex Inputs
+
+Use `wandConfig` for fields that are hard to fill out manually:
+- **Timestamps**: Use `generationType: 'timestamp'` to inject current date context into the AI prompt
+- **JSON arrays**: Use `generationType: 'json-object'` for structured data
+- **Complex queries**: Use a descriptive prompt explaining the expected format
+
+```typescript
+{
+ id: 'startTime',
+ title: 'Start Time',
+ type: 'short-input',
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
+ generationType: 'timestamp',
+ },
+}
+```
+
+### OAuth Scopes (Centralized System)
+
+Scopes are maintained in a single source of truth and reused everywhere:
+
+1. **Define scopes** in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
+2. **Add descriptions** in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for the OAuth modal UI
+3. **Reference in auth.ts** using `getCanonicalScopesForProvider(providerId)` from `@/lib/oauth/utils`
+4. **Reference in blocks** using `getScopesForService(serviceId)` from `@/lib/oauth/utils`
+
+**Never hardcode scope arrays** in `auth.ts` or block `requiredScopes`. Always import from the centralized source.
+
+```typescript
+// In auth.ts (Better Auth config)
+scopes: getCanonicalScopesForProvider('{service}'),
+
+// In block credential sub-block
+requiredScopes: getScopesForService('{service}'),
+```
+
+### Common Gotchas
+
+1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration
+2. **All tool IDs MUST be snake_case** - `stripe_create_payment`, not `stripeCreatePayment`. This applies to tool `id` fields, registry keys, `tools.access` arrays, and `tools.config.tool` return values
+3. **Block type is snake_case** - `type: 'stripe'`, not `type: 'Stripe'`
+4. **Alphabetical ordering** - Keep imports and registry entries alphabetically sorted
+5. **Required can be conditional** - Use `required: { field: 'op', value: 'create' }` instead of always true
+6. **DependsOn clears options** - When a dependency changes, selector options are refetched
+7. **Never pass Buffer directly to fetch** - Convert to `new Uint8Array(buffer)` for TypeScript compatibility
+8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility
+9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields
+10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled
+11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
+12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
diff --git a/.agents/skills/add-integration/agents/openai.yaml b/.agents/skills/add-integration/agents/openai.yaml
new file mode 100644
index 00000000000..16c7ecc5ed2
--- /dev/null
+++ b/.agents/skills/add-integration/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Add Integration"
+ short_description: "Build a full Sim integration"
+ brand_color: "#7C3AED"
+ default_prompt: "Use $add-integration to add a complete Sim integration for a service."
diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md
new file mode 100644
index 00000000000..66f6f88d047
--- /dev/null
+++ b/.agents/skills/add-tools/SKILL.md
@@ -0,0 +1,321 @@
+---
+name: add-tools
+description: Create or update Sim tool configurations from service API docs, including typed params, request mapping, response transforms, outputs, and registry entries. Use when working in `apps/sim/tools/{service}/` or fixing tool definitions for an integration.
+---
+
+# Add Tools Skill
+
+You are an expert at creating tool configurations for Sim integrations. Your job is to read API documentation and create properly structured tool files.
+
+## Your Task
+
+When the user asks you to create tools for a service:
+1. Use Context7 or WebFetch to read the service's API documentation
+2. Create the tools directory structure
+3. Generate properly typed tool configurations
+
+## Directory Structure
+
+Create files in `apps/sim/tools/{service}/`:
+```
+tools/{service}/
+├── index.ts # Barrel export
+├── types.ts # Parameter & response types
+└── {action}.ts # Individual tool files (one per operation)
+```
+
+## Tool Configuration Structure
+
+Every tool MUST follow this exact structure:
+
+```typescript
+import type { {ServiceName}{Action}Params } from '@/tools/{service}/types'
+import type { ToolConfig } from '@/tools/types'
+
+interface {ServiceName}{Action}Response {
+ success: boolean
+ output: {
+ // Define output structure here
+ }
+}
+
+export const {serviceName}{Action}Tool: ToolConfig<
+ {ServiceName}{Action}Params,
+ {ServiceName}{Action}Response
+> = {
+ id: '{service}_{action}', // snake_case, matches tool name
+ name: '{Service} {Action}', // Human readable
+ description: 'Brief description', // One sentence
+ version: '1.0.0',
+
+ // OAuth config (if service uses OAuth)
+ oauth: {
+ required: true,
+ provider: '{service}', // Must match OAuth provider ID
+ },
+
+ params: {
+ // Hidden params (system-injected, only use hidden for oauth accessToken)
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'OAuth access token',
+ },
+ // User-only params (credentials, api key, IDs user must provide)
+ someId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The ID of the resource',
+ },
+ // User-or-LLM params (everything else, can be provided by user OR computed by LLM)
+ query: {
+ type: 'string',
+ required: false, // Use false for optional
+ visibility: 'user-or-llm',
+ description: 'Search query',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.service.com/v1/resource/${params.id}`,
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => ({
+ // Request body - only for POST/PUT/PATCH
+ // Trim ID fields to prevent copy-paste whitespace errors:
+ // userId: params.userId?.trim(),
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ return {
+ success: true,
+ output: {
+ // Map API response to output
+ // Use ?? null for nullable fields
+ // Use ?? [] for optional arrays
+ },
+ }
+ },
+
+ outputs: {
+ // Define each output field
+ },
+}
+```
+
+## Critical Rules for Parameters
+
+### Visibility Options
+- `'hidden'` - System-injected (OAuth tokens, internal params). User never sees.
+- `'user-only'` - User must provide (credentials, api keys, account-specific IDs)
+- `'user-or-llm'` - User provides OR LLM can compute (search queries, content, filters, most fall into this category)
+
+### Parameter Types
+- `'string'` - Text values
+- `'number'` - Numeric values
+- `'boolean'` - True/false
+- `'json'` - Complex objects (NOT 'object', use 'json')
+- `'file'` - Single file
+- `'file[]'` - Multiple files
+
+### Required vs Optional
+- Always explicitly set `required: true` or `required: false`
+- Optional params should have `required: false`
+
+## Critical Rules for Outputs
+
+### Output Types
+- `'string'`, `'number'`, `'boolean'` - Primitives
+- `'json'` - Complex objects (use this, NOT 'object')
+- `'array'` - Arrays with `items` property
+- `'object'` - Objects with `properties` property
+
+### Optional Outputs
+Add `optional: true` for fields that may not exist in the response:
+```typescript
+closedAt: {
+ type: 'string',
+ description: 'When the issue was closed',
+ optional: true,
+},
+```
+
+### Typed JSON Outputs
+
+When using `type: 'json'` and you know the object shape in advance, **always define the inner structure** using `properties` so downstream consumers know what fields are available:
+
+```typescript
+// BAD: Opaque json with no info about what's inside
+metadata: {
+ type: 'json',
+ description: 'Response metadata',
+},
+
+// GOOD: Define the known properties
+metadata: {
+ type: 'json',
+ description: 'Response metadata',
+ properties: {
+ id: { type: 'string', description: 'Unique ID' },
+ status: { type: 'string', description: 'Current status' },
+ count: { type: 'number', description: 'Total count' },
+ },
+},
+```
+
+For arrays of objects, define the item structure:
+```typescript
+items: {
+ type: 'array',
+ description: 'List of items',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Item ID' },
+ name: { type: 'string', description: 'Item name' },
+ },
+ },
+},
+```
+
+Only use bare `type: 'json'` without `properties` when the shape is truly dynamic or unknown.
+
+## Critical Rules for transformResponse
+
+### Handle Nullable Fields
+ALWAYS use `?? null` for fields that may be undefined:
+```typescript
+transformResponse: async (response: Response) => {
+ const data = await response.json()
+ return {
+ success: true,
+ output: {
+ id: data.id,
+ title: data.title,
+ body: data.body ?? null, // May be undefined
+ assignee: data.assignee ?? null, // May be undefined
+ labels: data.labels ?? [], // Default to empty array
+ closedAt: data.closed_at ?? null, // May be undefined
+ },
+ }
+}
+```
+
+### Never Output Raw JSON Dumps
+DON'T do this:
+```typescript
+output: {
+ data: data, // BAD - raw JSON dump
+}
+```
+
+DO this instead - extract meaningful fields:
+```typescript
+output: {
+ id: data.id,
+ name: data.name,
+ status: data.status,
+ metadata: {
+ createdAt: data.created_at,
+ updatedAt: data.updated_at,
+ },
+}
+```
+
+## Types File Pattern
+
+Create `types.ts` with interfaces for all params and responses:
+
+```typescript
+import type { ToolResponse } from '@/tools/types'
+
+// Parameter interfaces
+export interface {Service}{Action}Params {
+ accessToken: string
+ requiredField: string
+ optionalField?: string
+}
+
+// Response interfaces (extend ToolResponse)
+export interface {Service}{Action}Response extends ToolResponse {
+ output: {
+ field1: string
+ field2: number
+ optionalField?: string | null
+ }
+}
+```
+
+## Index.ts Barrel Export Pattern
+
+```typescript
+// Export all tools
+export { serviceTool1 } from './{action1}'
+export { serviceTool2 } from './{action2}'
+
+// Export types
+export * from './types'
+```
+
+## Registering Tools
+
+After creating tools, remind the user to:
+1. Import tools in `apps/sim/tools/registry.ts`
+2. Add to the `tools` object with snake_case keys:
+```typescript
+import { serviceActionTool } from '@/tools/{service}'
+
+export const tools = {
+ // ... existing tools ...
+ {service}_{action}: serviceActionTool,
+}
+```
+
+## V2 Tool Pattern
+
+If creating V2 tools (API-aligned outputs), use `_v2` suffix:
+- Tool ID: `{service}_{action}_v2`
+- Variable name: `{action}V2Tool`
+- Version: `'2.0.0'`
+- Outputs: Flat, API-aligned (no content/metadata wrapper)
+
+## Naming Convention
+
+All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase for tool IDs.
+
+## Checklist Before Finishing
+
+- [ ] All tool IDs use snake_case
+- [ ] All params have explicit `required: true` or `required: false`
+- [ ] All params have appropriate `visibility`
+- [ ] All nullable response fields use `?? null`
+- [ ] All optional outputs have `optional: true`
+- [ ] No raw JSON dumps in outputs
+- [ ] Types file has all interfaces
+- [ ] Index.ts exports all tools
+
+## Final Validation (Required)
+
+After creating all tools, you MUST validate every tool before finishing:
+
+1. **Read every tool file** you created — do not skip any
+2. **Cross-reference with the API docs** to verify:
+ - All required params are marked `required: true`
+ - All optional params are marked `required: false`
+ - Param types match the API (string, number, boolean, json)
+ - Request URL, method, headers, and body match the API spec
+ - `transformResponse` extracts the correct fields from the API response
+ - All output fields match what the API actually returns
+ - No fields are missing from outputs that the API provides
+ - No extra fields are defined in outputs that the API doesn't return
+3. **Verify consistency** across tools:
+ - Shared types in `types.ts` match all tools that use them
+ - Tool IDs in the barrel export match the tool file definitions
+ - Error handling is consistent (error checks, meaningful messages)
diff --git a/.agents/skills/add-tools/agents/openai.yaml b/.agents/skills/add-tools/agents/openai.yaml
new file mode 100644
index 00000000000..1acc4fd7126
--- /dev/null
+++ b/.agents/skills/add-tools/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Add Tools"
+ short_description: "Build Sim tools from API docs"
+ brand_color: "#EA580C"
+ default_prompt: "Use $add-tools to create or update Sim tool definitions from service API docs."
diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md
new file mode 100644
index 00000000000..fbf27ef625d
--- /dev/null
+++ b/.agents/skills/add-trigger/SKILL.md
@@ -0,0 +1,708 @@
+---
+name: add-trigger
+description: Create or update Sim webhook triggers using the generic trigger builder, service-specific setup instructions, outputs, and registry wiring. Use when working in `apps/sim/triggers/{service}/` or adding webhook support to an integration.
+---
+
+# Add Trigger Skill
+
+You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks.
+
+## Your Task
+
+When the user asks you to create triggers for a service:
+1. Research what webhook events the service supports
+2. Create the trigger files using the generic builder
+3. Register triggers and connect them to the block
+
+## Directory Structure
+
+```
+apps/sim/triggers/{service}/
+├── index.ts # Barrel exports
+├── utils.ts # Service-specific helpers (trigger options, setup instructions, extra fields)
+├── {event_a}.ts # Primary trigger (includes dropdown)
+├── {event_b}.ts # Secondary trigger (no dropdown)
+├── {event_c}.ts # Secondary trigger (no dropdown)
+└── webhook.ts # Generic webhook trigger (optional, for "all events")
+```
+
+## Step 1: Create utils.ts
+
+This file contains service-specific helpers used by all triggers.
+
+```typescript
+import type { SubBlockConfig } from '@/blocks/types'
+import type { TriggerOutput } from '@/triggers/types'
+
+/**
+ * Dropdown options for the trigger type selector.
+ * These appear in the primary trigger's dropdown.
+ */
+export const {service}TriggerOptions = [
+ { label: 'Event A', id: '{service}_event_a' },
+ { label: 'Event B', id: '{service}_event_b' },
+ { label: 'Event C', id: '{service}_event_c' },
+ { label: 'Generic Webhook (All Events)', id: '{service}_webhook' },
+]
+
+/**
+ * Generates HTML setup instructions for the trigger.
+ * Displayed to users to help them configure webhooks in the external service.
+ */
+export function {service}SetupInstructions(eventType: string): string {
+ const instructions = [
+ 'Copy the Webhook URL above',
+ 'Go to {Service} Settings > Webhooks ',
+ 'Click Add Webhook ',
+ 'Paste the webhook URL',
+ `Select the ${eventType} event type`,
+ 'Save the webhook configuration',
+ 'Click "Save" above to activate your trigger',
+ ]
+
+ return instructions
+ .map((instruction, index) =>
+ `${index + 1}. ${instruction}
`
+ )
+ .join('')
+}
+
+/**
+ * Service-specific extra fields to add to triggers.
+ * These are inserted between webhookUrl and triggerSave.
+ */
+export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
+ return [
+ {
+ id: 'projectId',
+ title: 'Project ID (Optional)',
+ type: 'short-input',
+ placeholder: 'Leave empty for all projects',
+ description: 'Optionally filter to a specific project',
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ },
+ ]
+}
+
+/**
+ * Build outputs for this trigger type.
+ * Outputs define what data is available to downstream blocks.
+ */
+export function build{Service}Outputs(): Record {
+ return {
+ eventType: { type: 'string', description: 'The type of event that triggered this workflow' },
+ resourceId: { type: 'string', description: 'ID of the affected resource' },
+ timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' },
+ // Nested outputs for complex data
+ resource: {
+ id: { type: 'string', description: 'Resource ID' },
+ name: { type: 'string', description: 'Resource name' },
+ status: { type: 'string', description: 'Current status' },
+ },
+ webhook: { type: 'json', description: 'Full webhook payload' },
+ }
+}
+```
+
+## Step 2: Create the Primary Trigger
+
+The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types.
+
+```typescript
+import { {Service}Icon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ build{Service}ExtraFields,
+ build{Service}Outputs,
+ {service}SetupInstructions,
+ {service}TriggerOptions,
+} from '@/triggers/{service}/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+/**
+ * {Service} Event A Trigger
+ *
+ * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type.
+ */
+export const {service}EventATrigger: TriggerConfig = {
+ id: '{service}_event_a',
+ name: '{Service} Event A',
+ provider: '{service}',
+ description: 'Trigger workflow when Event A occurs',
+ version: '1.0.0',
+ icon: {Service}Icon,
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: '{service}_event_a',
+ triggerOptions: {service}TriggerOptions,
+ includeDropdown: true, // PRIMARY TRIGGER - includes dropdown
+ setupInstructions: {service}SetupInstructions('Event A'),
+ extraFields: build{Service}ExtraFields('{service}_event_a'),
+ }),
+
+ outputs: build{Service}Outputs(),
+
+ webhook: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+}
+```
+
+## Step 3: Create Secondary Triggers
+
+Secondary triggers do NOT include the dropdown (it's already in the primary trigger).
+
+```typescript
+import { {Service}Icon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ build{Service}ExtraFields,
+ build{Service}Outputs,
+ {service}SetupInstructions,
+ {service}TriggerOptions,
+} from '@/triggers/{service}/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+/**
+ * {Service} Event B Trigger
+ */
+export const {service}EventBTrigger: TriggerConfig = {
+ id: '{service}_event_b',
+ name: '{Service} Event B',
+ provider: '{service}',
+ description: 'Trigger workflow when Event B occurs',
+ version: '1.0.0',
+ icon: {Service}Icon,
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: '{service}_event_b',
+ triggerOptions: {service}TriggerOptions,
+ // NO includeDropdown - secondary trigger
+ setupInstructions: {service}SetupInstructions('Event B'),
+ extraFields: build{Service}ExtraFields('{service}_event_b'),
+ }),
+
+ outputs: build{Service}Outputs(),
+
+ webhook: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+}
+```
+
+## Step 4: Create index.ts Barrel Export
+
+```typescript
+export { {service}EventATrigger } from './event_a'
+export { {service}EventBTrigger } from './event_b'
+export { {service}EventCTrigger } from './event_c'
+export { {service}WebhookTrigger } from './webhook'
+```
+
+## Step 5: Register Triggers
+
+### Trigger Registry (`apps/sim/triggers/registry.ts`)
+
+```typescript
+// Add import
+import {
+ {service}EventATrigger,
+ {service}EventBTrigger,
+ {service}EventCTrigger,
+ {service}WebhookTrigger,
+} from '@/triggers/{service}'
+
+// Add to TRIGGER_REGISTRY
+export const TRIGGER_REGISTRY: TriggerRegistry = {
+ // ... existing triggers ...
+ {service}_event_a: {service}EventATrigger,
+ {service}_event_b: {service}EventBTrigger,
+ {service}_event_c: {service}EventCTrigger,
+ {service}_webhook: {service}WebhookTrigger,
+}
+```
+
+## Step 6: Connect Triggers to Block
+
+In the block file (`apps/sim/blocks/blocks/{service}.ts`):
+
+```typescript
+import { {Service}Icon } from '@/components/icons'
+import { getTrigger } from '@/triggers'
+import type { BlockConfig } from '@/blocks/types'
+
+export const {Service}Block: BlockConfig = {
+ type: '{service}',
+ name: '{Service}',
+ // ... other config ...
+
+ // Enable triggers and list available trigger IDs
+ triggers: {
+ enabled: true,
+ available: [
+ '{service}_event_a',
+ '{service}_event_b',
+ '{service}_event_c',
+ '{service}_webhook',
+ ],
+ },
+
+ subBlocks: [
+ // Regular tool subBlocks first
+ { id: 'operation', /* ... */ },
+ { id: 'credential', /* ... */ },
+ // ... other tool fields ...
+
+ // Then spread ALL trigger subBlocks
+ ...getTrigger('{service}_event_a').subBlocks,
+ ...getTrigger('{service}_event_b').subBlocks,
+ ...getTrigger('{service}_event_c').subBlocks,
+ ...getTrigger('{service}_webhook').subBlocks,
+ ],
+
+ // ... tools config ...
+}
+```
+
+## Automatic Webhook Registration (Preferred)
+
+If the service's API supports programmatic webhook creation, implement automatic webhook registration instead of requiring users to manually configure webhooks. This provides a much better user experience.
+
+### When to Use Automatic Registration
+
+Check the service's API documentation for endpoints like:
+- `POST /webhooks` or `POST /hooks` - Create webhook
+- `DELETE /webhooks/{id}` - Delete webhook
+
+Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc.
+
+### Implementation Steps
+
+#### 1. Add API Key to Extra Fields
+
+Update your `build{Service}ExtraFields` function to include an API key field:
+
+```typescript
+export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
+ return [
+ {
+ id: 'apiKey',
+ title: 'API Key',
+ type: 'short-input',
+ placeholder: 'Enter your {Service} API key',
+ description: 'Required to create the webhook in {Service}.',
+ password: true,
+ required: true,
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ },
+ // Other optional fields (e.g., campaign filter, project filter)
+ {
+ id: 'projectId',
+ title: 'Project ID (Optional)',
+ type: 'short-input',
+ placeholder: 'Leave empty for all projects',
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ },
+ ]
+}
+```
+
+#### 2. Update Setup Instructions for Automatic Creation
+
+Change instructions to indicate automatic webhook creation:
+
+```typescript
+export function {service}SetupInstructions(eventType: string): string {
+ const instructions = [
+ 'Enter your {Service} API Key above.',
+ 'You can find your API key in {Service} at Settings > API .',
+ `Click "Save Configuration" to automatically create the webhook in {Service} for ${eventType} events.`,
+ 'The webhook will be automatically deleted when you remove this trigger.',
+ ]
+
+ return instructions
+ .map((instruction, index) =>
+ `${index + 1}. ${instruction}
`
+ )
+ .join('')
+}
+```
+
+#### 3. Add Webhook Creation to API Route
+
+In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save:
+
+```typescript
+// --- {Service} specific logic ---
+if (savedWebhook && provider === '{service}') {
+ logger.info(`[${requestId}] {Service} provider detected. Creating webhook subscription.`)
+ try {
+ const result = await create{Service}WebhookSubscription(
+ {
+ id: savedWebhook.id,
+ path: savedWebhook.path,
+ providerConfig: savedWebhook.providerConfig,
+ },
+ requestId
+ )
+
+ if (result) {
+ // Update the webhook record with the external webhook ID
+ const updatedConfig = {
+ ...(savedWebhook.providerConfig as Record),
+ externalId: result.id,
+ }
+ await db
+ .update(webhook)
+ .set({
+ providerConfig: updatedConfig,
+ updatedAt: new Date(),
+ })
+ .where(eq(webhook.id, savedWebhook.id))
+
+ savedWebhook.providerConfig = updatedConfig
+ logger.info(`[${requestId}] Successfully created {Service} webhook`, {
+ externalHookId: result.id,
+ webhookId: savedWebhook.id,
+ })
+ }
+ } catch (err) {
+ logger.error(
+ `[${requestId}] Error creating {Service} webhook subscription, rolling back webhook`,
+ err
+ )
+ await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
+ return NextResponse.json(
+ {
+ error: 'Failed to create webhook in {Service}',
+ details: err instanceof Error ? err.message : 'Unknown error',
+ },
+ { status: 500 }
+ )
+ }
+}
+// --- End {Service} specific logic ---
+```
+
+Then add the helper function at the end of the file:
+
+```typescript
+async function create{Service}WebhookSubscription(
+ webhookData: any,
+ requestId: string
+): Promise<{ id: string } | undefined> {
+ try {
+ const { path, providerConfig } = webhookData
+ const { apiKey, triggerId, projectId } = providerConfig || {}
+
+ if (!apiKey) {
+ throw new Error('{Service} API Key is required.')
+ }
+
+ // Map trigger IDs to service event types
+ const eventTypeMap: Record = {
+ {service}_event_a: 'eventA',
+ {service}_event_b: 'eventB',
+ {service}_webhook: undefined, // Generic - no filter
+ }
+
+ const eventType = eventTypeMap[triggerId]
+ const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
+
+ const requestBody: Record = {
+ url: notificationUrl,
+ }
+
+ if (eventType) {
+ requestBody.eventType = eventType
+ }
+
+ if (projectId) {
+ requestBody.projectId = projectId
+ }
+
+ const response = await fetch('https://api.{service}.com/webhooks', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody),
+ })
+
+ const responseBody = await response.json()
+
+ if (!response.ok) {
+ const errorMessage = responseBody.message || 'Unknown API error'
+ let userFriendlyMessage = 'Failed to create webhook in {Service}'
+
+ if (response.status === 401) {
+ userFriendlyMessage = 'Invalid API Key. Please verify and try again.'
+ } else if (errorMessage) {
+ userFriendlyMessage = `{Service} error: ${errorMessage}`
+ }
+
+ throw new Error(userFriendlyMessage)
+ }
+
+ return { id: responseBody.id }
+ } catch (error: any) {
+ logger.error(`Exception during {Service} webhook creation`, { error: error.message })
+ throw error
+ }
+}
+```
+
+#### 4. Add Webhook Deletion to Provider Subscriptions
+
+In `apps/sim/lib/webhooks/provider-subscriptions.ts`:
+
+1. Add a logger:
+```typescript
+const {service}Logger = createLogger('{Service}Webhook')
+```
+
+2. Add the delete function:
+```typescript
+export async function delete{Service}Webhook(webhook: any, requestId: string): Promise {
+ try {
+ const config = getProviderConfig(webhook)
+ const apiKey = config.apiKey as string | undefined
+ const externalId = config.externalId as string | undefined
+
+ if (!apiKey || !externalId) {
+ {service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`)
+ return
+ }
+
+ const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ })
+
+ if (!response.ok && response.status !== 404) {
+ {service}Logger.warn(`[${requestId}] Failed to delete webhook (non-fatal): ${response.status}`)
+ } else {
+ {service}Logger.info(`[${requestId}] Successfully deleted webhook ${externalId}`)
+ }
+ } catch (error) {
+ {service}Logger.warn(`[${requestId}] Error deleting webhook (non-fatal)`, error)
+ }
+}
+```
+
+3. Add to `cleanupExternalWebhook`:
+```typescript
+export async function cleanupExternalWebhook(...): Promise {
+ // ... existing providers ...
+ } else if (webhook.provider === '{service}') {
+ await delete{Service}Webhook(webhook, requestId)
+ }
+}
+```
+
+### Key Points for Automatic Registration
+
+- **API Key visibility**: Always use `password: true` for API key fields
+- **Error handling**: Roll back the database webhook if external creation fails
+- **External ID storage**: Save the external webhook ID in `providerConfig.externalId`
+- **Graceful cleanup**: Don't fail webhook deletion if cleanup fails (use non-fatal logging)
+- **User-friendly errors**: Map HTTP status codes to helpful error messages
+
+## The buildTriggerSubBlocks Helper
+
+This is the generic helper from `@/triggers` that creates consistent trigger subBlocks.
+
+### Function Signature
+
+```typescript
+interface BuildTriggerSubBlocksOptions {
+ triggerId: string // e.g., 'service_event_a'
+ triggerOptions: Array<{ label: string; id: string }> // Dropdown options
+ includeDropdown?: boolean // true only for primary trigger
+ setupInstructions: string // HTML instructions
+ extraFields?: SubBlockConfig[] // Service-specific fields
+ webhookPlaceholder?: string // Custom placeholder text
+}
+
+function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[]
+```
+
+### What It Creates
+
+The helper creates this structure:
+1. **Dropdown** (only if `includeDropdown: true`) - Trigger type selector
+2. **Webhook URL** - Read-only field with copy button
+3. **Extra Fields** - Your service-specific fields (filters, options, etc.)
+4. **Save Button** - Activates the trigger
+5. **Instructions** - Setup guide for users
+
+All fields automatically have:
+- `mode: 'trigger'` - Only shown in trigger mode
+- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected
+
+## Trigger Outputs & Webhook Input Formatting
+
+### Important: Two Sources of Truth
+
+There are two related but separate concerns:
+
+1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown.
+2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`.
+
+**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ:
+- Tag dropdown shows fields that don't exist (broken variable resolution)
+- Or actual data has fields not shown in dropdown (users can't discover them)
+
+### When to Add a formatWebhookInput Handler
+
+- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly.
+- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler.
+
+### Adding a Handler
+
+In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block:
+
+```typescript
+if (foundWebhook.provider === '{service}') {
+ // Transform raw webhook body to match trigger outputs
+ return {
+ eventType: body.type,
+ resourceId: body.data?.id || '',
+ timestamp: body.created_at,
+ resource: body.data,
+ }
+}
+```
+
+**Key rules:**
+- Return fields that match your trigger `outputs` definition exactly
+- No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }`
+- No duplication (don't spread body AND add individual fields)
+- Use `null` for missing optional data, not empty objects with empty strings
+
+### Verify Alignment
+
+Run the alignment checker:
+```bash
+bunx scripts/check-trigger-alignment.ts {service}
+```
+
+## Trigger Outputs
+
+Trigger outputs use the same schema as block outputs (NOT tool outputs).
+
+**Supported:**
+- `type` and `description` for simple fields
+- Nested object structure for complex data
+
+**NOT Supported:**
+- `optional: true` (tool outputs only)
+- `items` property (tool outputs only)
+
+```typescript
+export function buildOutputs(): Record {
+ return {
+ // Simple fields
+ eventType: { type: 'string', description: 'Event type' },
+ timestamp: { type: 'string', description: 'When it occurred' },
+
+ // Complex data - use type: 'json'
+ payload: { type: 'json', description: 'Full event payload' },
+
+ // Nested structure
+ resource: {
+ id: { type: 'string', description: 'Resource ID' },
+ name: { type: 'string', description: 'Resource name' },
+ },
+ }
+}
+```
+
+## Generic Webhook Trigger Pattern
+
+For services with many event types, create a generic webhook that accepts all events:
+
+```typescript
+export const {service}WebhookTrigger: TriggerConfig = {
+ id: '{service}_webhook',
+ name: '{Service} Webhook (All Events)',
+ // ...
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: '{service}_webhook',
+ triggerOptions: {service}TriggerOptions,
+ setupInstructions: {service}SetupInstructions('All Events'),
+ extraFields: [
+ // Event type filter (optional)
+ {
+ id: 'eventTypes',
+ title: 'Event Types',
+ type: 'dropdown',
+ multiSelect: true,
+ options: [
+ { label: 'Event A', id: 'event_a' },
+ { label: 'Event B', id: 'event_b' },
+ ],
+ placeholder: 'Leave empty for all events',
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: '{service}_webhook' },
+ },
+ // Plus any other service-specific fields
+ ...build{Service}ExtraFields('{service}_webhook'),
+ ],
+ }),
+}
+```
+
+## Checklist Before Finishing
+
+### Utils
+- [ ] Created `{service}TriggerOptions` array with all trigger IDs
+- [ ] Created `{service}SetupInstructions` function with clear steps
+- [ ] Created `build{Service}ExtraFields` for service-specific fields
+- [ ] Created output builders for each trigger type
+
+### Triggers
+- [ ] Primary trigger has `includeDropdown: true`
+- [ ] Secondary triggers do NOT have `includeDropdown`
+- [ ] All triggers use `buildTriggerSubBlocks` helper
+- [ ] All triggers have proper outputs defined
+- [ ] Created `index.ts` barrel export
+
+### Registration
+- [ ] All triggers imported in `triggers/registry.ts`
+- [ ] All triggers added to `TRIGGER_REGISTRY`
+- [ ] Block has `triggers.enabled: true`
+- [ ] Block has all trigger IDs in `triggers.available`
+- [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks`
+
+### Automatic Webhook Registration (if supported)
+- [ ] Added API key field to `build{Service}ExtraFields` with `password: true`
+- [ ] Updated setup instructions for automatic webhook creation
+- [ ] Added provider-specific logic to `apps/sim/app/api/webhooks/route.ts`
+- [ ] Added `create{Service}WebhookSubscription` helper function
+- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts`
+- [ ] Added provider to `cleanupExternalWebhook` function
+
+### Webhook Input Formatting
+- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed)
+- [ ] Handler returns fields matching trigger `outputs` exactly
+- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment
+
+### Testing
+- [ ] Run `bun run type-check` to verify no TypeScript errors
+- [ ] Restart dev server to pick up new triggers
+- [ ] Test trigger UI shows correctly in the block
+- [ ] Test automatic webhook creation works (if applicable)
diff --git a/.agents/skills/add-trigger/agents/openai.yaml b/.agents/skills/add-trigger/agents/openai.yaml
new file mode 100644
index 00000000000..a22247a70c9
--- /dev/null
+++ b/.agents/skills/add-trigger/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Add Trigger"
+ short_description: "Build Sim webhook triggers"
+ brand_color: "#DC2626"
+ default_prompt: "Use $add-trigger to create or update webhook triggers for a Sim integration."
diff --git a/.agents/skills/validate-connector/SKILL.md b/.agents/skills/validate-connector/SKILL.md
new file mode 100644
index 00000000000..4bafaa07dcb
--- /dev/null
+++ b/.agents/skills/validate-connector/SKILL.md
@@ -0,0 +1,316 @@
+---
+name: validate-connector
+description: Audit an existing Sim knowledge base connector against the service API docs and repository conventions, then report and fix issues in auth, config fields, pagination, document mapping, tags, and registry entries. Use when validating or repairing code in `apps/sim/connectors/{service}/`.
+---
+
+# Validate Connector Skill
+
+You are an expert auditor for Sim knowledge base connectors. Your job is to thoroughly validate that an existing connector is correct, complete, and follows all conventions.
+
+## Your Task
+
+When the user asks you to validate a connector:
+1. Read the service's API documentation (via Context7 or WebFetch)
+2. Read the connector implementation, OAuth config, and registry entries
+3. Cross-reference everything against the API docs and Sim conventions
+4. Report all issues found, grouped by severity (critical, warning, suggestion)
+5. Fix all issues after reporting them
+
+## Step 1: Gather All Files
+
+Read **every** file for the connector — do not skip any:
+
+```
+apps/sim/connectors/{service}/{service}.ts # Connector implementation
+apps/sim/connectors/{service}/index.ts # Barrel export
+apps/sim/connectors/registry.ts # Connector registry entry
+apps/sim/connectors/types.ts # ConnectorConfig interface, ExternalDocument, etc.
+apps/sim/connectors/utils.ts # Shared utilities (computeContentHash, htmlToPlainText, etc.)
+apps/sim/lib/oauth/oauth.ts # OAUTH_PROVIDERS — single source of truth for scopes
+apps/sim/lib/oauth/utils.ts # getCanonicalScopesForProvider, getScopesForService, SCOPE_DESCRIPTIONS
+apps/sim/lib/oauth/types.ts # OAuthService union type
+apps/sim/components/icons.tsx # Icon definition for the service
+```
+
+If the connector uses selectors, also read:
+```
+apps/sim/hooks/selectors/registry.ts # Selector key definitions
+apps/sim/hooks/selectors/types.ts # SelectorKey union type
+apps/sim/lib/workflows/subblocks/context.ts # SELECTOR_CONTEXT_FIELDS
+```
+
+## Step 2: Pull API Documentation
+
+Fetch the official API docs for the service. This is the **source of truth** for:
+- Endpoint URLs, HTTP methods, and auth headers
+- Required vs optional parameters
+- Parameter types and allowed values
+- Response shapes and field names
+- Pagination patterns (cursor, offset, next token)
+- Rate limits and error formats
+- OAuth scopes and their meanings
+
+Use Context7 (resolve-library-id → query-docs) or WebFetch to retrieve documentation. If both fail, note which claims are based on training knowledge vs verified docs.
+
+## Step 3: Validate API Endpoints
+
+For **every** API call in the connector (`listDocuments`, `getDocument`, `validateConfig`, and any helper functions), verify against the API docs:
+
+### URLs and Methods
+- [ ] Base URL is correct for the service's API version
+- [ ] Endpoint paths match the API docs exactly
+- [ ] HTTP method is correct (GET, POST, PUT, PATCH, DELETE)
+- [ ] Path parameters are correctly interpolated and URI-encoded where needed
+- [ ] Query parameters use correct names and formats per the API docs
+
+### Headers
+- [ ] Authorization header uses the correct format:
+ - OAuth: `Authorization: Bearer ${accessToken}`
+ - API Key: correct header name per the service's docs
+- [ ] `Content-Type` is set for POST/PUT/PATCH requests
+- [ ] Any service-specific headers are present (e.g., `Notion-Version`, `Dropbox-API-Arg`)
+- [ ] No headers are sent that the API doesn't support or silently ignores
+
+### Request Bodies
+- [ ] POST/PUT body fields match API parameter names exactly
+- [ ] Required fields are always sent
+- [ ] Optional fields are conditionally included (not sent as `null` or empty unless the API expects that)
+- [ ] Field value types match API expectations (string vs number vs boolean)
+
+### Input Sanitization
+- [ ] User-controlled values interpolated into query strings are properly escaped:
+ - OData `$filter`: single quotes escaped with `''` (e.g., `externalId.replace(/'/g, "''")`)
+ - SOQL: single quotes escaped with `\'`
+ - GraphQL variables: passed as variables, not interpolated into query strings
+ - URL path segments: `encodeURIComponent()` applied
+- [ ] URL-type config fields (e.g., `siteUrl`, `instanceUrl`) are normalized:
+ - Strip `https://` / `http://` prefix if the API expects bare domains
+ - Strip trailing `/`
+ - Apply `.trim()` before validation
+
+### Response Parsing
+- [ ] Response structure is correctly traversed (e.g., `data.results` vs `data.items` vs `data`)
+- [ ] Field names extracted match what the API actually returns
+- [ ] Nullable fields are handled with `?? null` or `|| undefined`
+- [ ] Error responses are checked before accessing data fields
+
+## Step 4: Validate OAuth Scopes (if OAuth connector)
+
+Scopes must be correctly declared and sufficient for all API calls the connector makes.
+
+### Connector requiredScopes
+- [ ] `requiredScopes` in the connector's `auth` config lists all scopes needed by the connector
+- [ ] Each scope in `requiredScopes` is a real, valid scope recognized by the service's API
+- [ ] No invalid, deprecated, or made-up scopes are listed
+- [ ] No unnecessary excess scopes beyond what the connector actually needs
+
+### Scope Subset Validation (CRITICAL)
+- [ ] Every scope in `requiredScopes` exists in the OAuth provider's `scopes` array in `lib/oauth/oauth.ts`
+- [ ] Find the provider in `OAUTH_PROVIDERS[providerGroup].services[serviceId].scopes`
+- [ ] Verify: `requiredScopes` ⊆ `OAUTH_PROVIDERS scopes` (every required scope is present in the provider config)
+- [ ] If a required scope is NOT in the provider config, flag as **critical** — the connector will fail at runtime
+
+### Scope Sufficiency
+For each API endpoint the connector calls:
+- [ ] Identify which scopes are required per the API docs
+- [ ] Verify those scopes are included in the connector's `requiredScopes`
+- [ ] If the connector calls endpoints requiring scopes not in `requiredScopes`, flag as **warning**
+
+### Token Refresh Config
+- [ ] Check the `getOAuthTokenRefreshConfig` function in `lib/oauth/oauth.ts` for this provider
+- [ ] `useBasicAuth` matches the service's token exchange requirements
+- [ ] `supportsRefreshTokenRotation` matches whether the service issues rotating refresh tokens
+- [ ] Token endpoint URL is correct
+
+## Step 5: Validate Pagination
+
+### listDocuments Pagination
+- [ ] Cursor/pagination parameter name matches the API docs
+- [ ] Response pagination field is correctly extracted (e.g., `next_cursor`, `nextPageToken`, `@odata.nextLink`, `offset`)
+- [ ] `hasMore` is correctly determined from the response
+- [ ] `nextCursor` is correctly passed back for the next page
+- [ ] `maxItems` / `maxRecords` cap is correctly applied across pages using `syncContext.totalDocsFetched`
+- [ ] Page size is within the API's allowed range (not exceeding max page size)
+- [ ] Last page precision: when a `maxItems` cap exists, the final page request uses `Math.min(PAGE_SIZE, remaining)` to avoid fetching more records than needed
+- [ ] No off-by-one errors in pagination tracking
+- [ ] The connector does NOT hit known API pagination limits silently (e.g., HubSpot search 10k cap)
+
+### Pagination State Across Pages
+- [ ] `syncContext` is used to cache state across pages (user names, field maps, instance URLs, portal IDs, etc.)
+- [ ] Cached state in `syncContext` is correctly initialized on first page and reused on subsequent pages
+
+## Step 6: Validate Data Transformation
+
+### ExternalDocument Construction
+- [ ] `externalId` is a stable, unique identifier from the source API
+- [ ] `title` is extracted from the correct field and has a sensible fallback (e.g., `'Untitled'`)
+- [ ] `content` is plain text — HTML content is stripped using `htmlToPlainText` from `@/connectors/utils`
+- [ ] `mimeType` is `'text/plain'`
+- [ ] `contentHash` is computed using `computeContentHash` from `@/connectors/utils`
+- [ ] `sourceUrl` is a valid, complete URL back to the original resource (not relative)
+- [ ] `metadata` contains all fields referenced by `mapTags` and `tagDefinitions`
+
+### Content Extraction
+- [ ] Rich text / HTML fields are converted to plain text before indexing
+- [ ] Important content is not silently dropped (e.g., nested blocks, table cells, code blocks)
+- [ ] Content is not silently truncated without logging a warning
+- [ ] Empty/blank documents are properly filtered out
+- [ ] Size checks use `Buffer.byteLength(text, 'utf8')` not `text.length` when comparing against byte-based limits (e.g., `MAX_FILE_SIZE` in bytes)
+
+## Step 7: Validate Tag Definitions and mapTags
+
+### tagDefinitions
+- [ ] Each `tagDefinition` has an `id`, `displayName`, and `fieldType`
+- [ ] `fieldType` matches the actual data type: `'text'` for strings, `'number'` for numbers, `'date'` for dates, `'boolean'` for booleans
+- [ ] Every `id` in `tagDefinitions` is returned by `mapTags`
+- [ ] No `tagDefinition` references a field that `mapTags` never produces
+
+### mapTags
+- [ ] Return keys match `tagDefinition` `id` values exactly
+- [ ] Date values are properly parsed using `parseTagDate` from `@/connectors/utils`
+- [ ] Array values are properly joined using `joinTagArray` from `@/connectors/utils`
+- [ ] Number values are validated (not `NaN`)
+- [ ] Metadata field names accessed in `mapTags` match what `listDocuments`/`getDocument` store in `metadata`
+
+## Step 8: Validate Config Fields and Validation
+
+### configFields
+- [ ] Every field has `id`, `title`, `type`
+- [ ] `required` is set explicitly (not omitted)
+- [ ] Dropdown fields have `options` with `label` and `id` for each option
+- [ ] Selector fields follow the canonical pair pattern:
+ - A `type: 'selector'` field with `selectorKey`, `canonicalParamId`, `mode: 'basic'`
+ - A `type: 'short-input'` field with the same `canonicalParamId`, `mode: 'advanced'`
+ - `required` is identical on both fields in the pair
+- [ ] `selectorKey` values exist in the selector registry
+- [ ] `dependsOn` references selector field `id` values, not `canonicalParamId`
+
+### validateConfig
+- [ ] Validates all required fields are present before making API calls
+- [ ] Validates optional numeric fields (checks `Number.isNaN`, positive values)
+- [ ] Makes a lightweight API call to verify access (e.g., fetch 1 record, get profile)
+- [ ] Uses `VALIDATE_RETRY_OPTIONS` for retry budget
+- [ ] Returns `{ valid: true }` on success
+- [ ] Returns `{ valid: false, error: 'descriptive message' }` on failure
+- [ ] Catches exceptions and returns user-friendly error messages
+- [ ] Does NOT make expensive calls (full data listing, large queries)
+
+## Step 9: Validate getDocument
+
+- [ ] Fetches a single document by `externalId`
+- [ ] Returns `null` for 404 / not found (does not throw)
+- [ ] Returns the same `ExternalDocument` shape as `listDocuments`
+- [ ] Handles all content types that `listDocuments` can produce (e.g., if `listDocuments` returns both pages and blogposts, `getDocument` must handle both — not hardcode one endpoint)
+- [ ] Forwards `syncContext` if it needs cached state (user names, field maps, etc.)
+- [ ] Error handling is graceful (catches, logs, returns null or throws with context)
+- [ ] Does not redundantly re-fetch data already included in the initial API response (e.g., if comments come back with the post, don't fetch them again separately)
+
+## Step 10: Validate General Quality
+
+### fetchWithRetry Usage
+- [ ] All external API calls use `fetchWithRetry` from `@/lib/knowledge/documents/utils`
+- [ ] No raw `fetch()` calls to external APIs
+- [ ] `VALIDATE_RETRY_OPTIONS` used in `validateConfig`
+- [ ] If `validateConfig` calls a shared helper (e.g., `linearGraphQL`, `resolveId`), that helper must accept and forward `retryOptions` to `fetchWithRetry`
+- [ ] Default retry options used in `listDocuments`/`getDocument`
+
+### API Efficiency
+- [ ] APIs that support field selection (e.g., `$select`, `sysparm_fields`, `fields`) should request only the fields the connector needs — in both `listDocuments` AND `getDocument`
+- [ ] No redundant API calls: if a helper already fetches data (e.g., site metadata), callers should reuse the result instead of making a second call for the same information
+- [ ] Sequential per-item API calls (fetching details for each document in a loop) should be batched with `Promise.all` and a concurrency limit of 3-5
+
+### Error Handling
+- [ ] Individual document failures are caught and logged without aborting the sync
+- [ ] API error responses include status codes in error messages
+- [ ] No unhandled promise rejections in concurrent operations
+
+### Concurrency
+- [ ] Concurrent API calls use reasonable batch sizes (3-5 is typical)
+- [ ] No unbounded `Promise.all` over large arrays
+
+### Logging
+- [ ] Uses `createLogger` from `@sim/logger` (not `console.log`)
+- [ ] Logs sync progress at `info` level
+- [ ] Logs errors at `warn` or `error` level with context
+
+### Registry
+- [ ] Connector is exported from `connectors/{service}/index.ts`
+- [ ] Connector is registered in `connectors/registry.ts`
+- [ ] Registry key matches the connector's `id` field
+
+## Step 11: Report and Fix
+
+### Report Format
+
+Group findings by severity:
+
+**Critical** (will cause runtime errors, data loss, or auth failures):
+- Wrong API endpoint URL or HTTP method
+- Invalid or missing OAuth scopes (not in provider config)
+- Incorrect response field mapping (accessing wrong path)
+- SOQL/query fields that don't exist on the target object
+- Pagination that silently hits undocumented API limits
+- Missing error handling that would crash the sync
+- `requiredScopes` not a subset of OAuth provider scopes
+- Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping
+
+**Warning** (incorrect behavior, data quality issues, or convention violations):
+- HTML content not stripped via `htmlToPlainText`
+- `getDocument` not forwarding `syncContext`
+- `getDocument` hardcoded to one content type when `listDocuments` returns multiple (e.g., only pages but not blogposts)
+- Missing `tagDefinition` for metadata fields returned by `mapTags`
+- Incorrect `useBasicAuth` or `supportsRefreshTokenRotation` in token refresh config
+- Invalid scope names that the API doesn't recognize (even if silently ignored)
+- Private resources excluded from name-based lookup despite scopes being available
+- Silent data truncation without logging
+- Size checks using `text.length` (character count) instead of `Buffer.byteLength` (byte count) for byte-based limits
+- URL-type config fields not normalized (protocol prefix, trailing slashes cause API failures)
+- `VALIDATE_RETRY_OPTIONS` not threaded through helper functions called by `validateConfig`
+
+**Suggestion** (minor improvements):
+- Missing incremental sync support despite API supporting it
+- Overly broad scopes that could be narrowed (not wrong, but could be tighter)
+- Source URL format could be more specific
+- Missing `orderBy` for deterministic pagination
+- Redundant API calls that could be cached in `syncContext`
+- Sequential per-item API calls that could be batched with `Promise.all` (concurrency 3-5)
+- API supports field selection but connector fetches all fields (e.g., missing `$select`, `sysparm_fields`, `fields`)
+- `getDocument` re-fetches data already included in the initial API response (e.g., comments returned with post)
+- Last page of pagination requests full `PAGE_SIZE` when fewer records remain (`Math.min(PAGE_SIZE, remaining)`)
+
+### Fix All Issues
+
+After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity.
+
+### Validation Output
+
+After fixing, confirm:
+1. `bun run lint` passes
+2. TypeScript compiles clean
+3. Re-read all modified files to verify fixes are correct
+
+## Checklist Summary
+
+- [ ] Read connector implementation, types, utils, registry, and OAuth config
+- [ ] Pulled and read official API documentation for the service
+- [ ] Validated every API endpoint URL, method, headers, and body against API docs
+- [ ] Validated input sanitization: no query/filter injection, URL fields normalized
+- [ ] Validated OAuth scopes: `requiredScopes` ⊆ OAuth provider `scopes` in `oauth.ts`
+- [ ] Validated each scope is real and recognized by the service's API
+- [ ] Validated scopes are sufficient for all API endpoints the connector calls
+- [ ] Validated token refresh config (`useBasicAuth`, `supportsRefreshTokenRotation`)
+- [ ] Validated pagination: cursor names, page sizes, hasMore logic, no silent caps
+- [ ] Validated data transformation: plain text extraction, HTML stripping, content hashing
+- [ ] Validated tag definitions match mapTags output, correct fieldTypes
+- [ ] Validated config fields: canonical pairs, selector keys, required flags
+- [ ] Validated validateConfig: lightweight check, error messages, retry options
+- [ ] Validated getDocument: null on 404, all content types handled, no redundant re-fetches, syncContext forwarding
+- [ ] Validated fetchWithRetry used for all external calls (no raw fetch), VALIDATE_RETRY_OPTIONS threaded through helpers
+- [ ] Validated API efficiency: field selection used, no redundant calls, sequential fetches batched
+- [ ] Validated error handling: graceful failures, no unhandled rejections
+- [ ] Validated logging: createLogger, no console.log
+- [ ] Validated registry: correct export, correct key
+- [ ] Reported all issues grouped by severity
+- [ ] Fixed all critical and warning issues
+- [ ] Ran `bun run lint` after fixes
+- [ ] Verified TypeScript compiles clean
diff --git a/.agents/skills/validate-connector/agents/openai.yaml b/.agents/skills/validate-connector/agents/openai.yaml
new file mode 100644
index 00000000000..dc4d0366da8
--- /dev/null
+++ b/.agents/skills/validate-connector/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Validate Connector"
+ short_description: "Audit a Sim knowledge connector"
+ brand_color: "#059669"
+ default_prompt: "Use $validate-connector to audit and fix a Sim knowledge connector against its API docs."
diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md
new file mode 100644
index 00000000000..63a4875798f
--- /dev/null
+++ b/.agents/skills/validate-integration/SKILL.md
@@ -0,0 +1,289 @@
+---
+name: validate-integration
+description: Audit an existing Sim integration against the service API docs and repository conventions, then report and fix issues across tools, blocks, outputs, OAuth scopes, triggers, and registry entries. Use when validating or repairing a service integration under `apps/sim/tools`, `apps/sim/blocks`, or `apps/sim/triggers`.
+---
+
+# Validate Integration Skill
+
+You are an expert auditor for Sim integrations. Your job is to thoroughly validate that an existing integration is correct, complete, and follows all conventions.
+
+## Your Task
+
+When the user asks you to validate an integration:
+1. Read the service's API documentation (via WebFetch or Context7)
+2. Read every tool, the block, and registry entries
+3. Cross-reference everything against the API docs and Sim conventions
+4. Report all issues found, grouped by severity (critical, warning, suggestion)
+5. Fix all issues after reporting them
+
+## Step 1: Gather All Files
+
+Read **every** file for the integration — do not skip any:
+
+```
+apps/sim/tools/{service}/ # All tool files, types.ts, index.ts
+apps/sim/blocks/blocks/{service}.ts # Block definition
+apps/sim/tools/registry.ts # Tool registry entries for this service
+apps/sim/blocks/registry.ts # Block registry entry for this service
+apps/sim/components/icons.tsx # Icon definition
+apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
+apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
+apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
+```
+
+## Step 2: Pull API Documentation
+
+Fetch the official API docs for the service. This is the **source of truth** for:
+- Endpoint URLs, HTTP methods, and auth headers
+- Required vs optional parameters
+- Parameter types and allowed values
+- Response shapes and field names
+- Pagination patterns (which param name, which response field)
+- Rate limits and error formats
+
+## Step 3: Validate Tools
+
+For **every** tool file, check:
+
+### Tool ID and Naming
+- [ ] Tool ID uses `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`)
+- [ ] Tool `name` is human-readable (e.g., `'X Create Tweet'`)
+- [ ] Tool `description` is a concise one-liner describing what it does
+- [ ] Tool `version` is set (`'1.0.0'` or `'2.0.0'` for V2)
+
+### Params
+- [ ] All required API params are marked `required: true`
+- [ ] All optional API params are marked `required: false`
+- [ ] Every param has explicit `required: true` or `required: false` — never omitted
+- [ ] Param types match the API (`'string'`, `'number'`, `'boolean'`, `'json'`)
+- [ ] Visibility is correct:
+ - `'hidden'` — ONLY for OAuth access tokens and system-injected params
+ - `'user-only'` — for API keys, credentials, and account-specific IDs the user must provide
+ - `'user-or-llm'` — for everything else (search queries, content, filters, IDs that could come from other blocks)
+- [ ] Every param has a `description` that explains what it does
+
+### Request
+- [ ] URL matches the API endpoint exactly (correct base URL, path segments, path params)
+- [ ] HTTP method matches the API spec (GET, POST, PUT, PATCH, DELETE)
+- [ ] Headers include correct auth pattern:
+ - OAuth: `Authorization: Bearer ${params.accessToken}`
+ - API Key: correct header name and format per the service's docs
+- [ ] `Content-Type` header is set for POST/PUT/PATCH requests
+- [ ] Body sends all required fields and only includes optional fields when provided
+- [ ] For GET requests with query params: URL is constructed correctly with query string
+- [ ] ID fields in URL paths are `.trim()`-ed to prevent copy-paste whitespace errors
+- [ ] Path params use template literals correctly: `` `https://api.service.com/v1/${params.id.trim()}` ``
+
+### Response / transformResponse
+- [ ] Correctly parses the API response (`await response.json()`)
+- [ ] Extracts the right fields from the response structure (e.g., `data.data` vs `data` vs `data.results`)
+- [ ] All nullable fields use `?? null`
+- [ ] All optional arrays use `?? []`
+- [ ] Error cases are handled: checks for missing/empty data and returns meaningful error
+- [ ] Does NOT do raw JSON dumps — extracts meaningful, individual fields
+
+### Outputs
+- [ ] All output fields match what the API actually returns
+- [ ] No fields are missing that the API provides and users would commonly need
+- [ ] No phantom fields defined that the API doesn't return
+- [ ] `optional: true` is set on fields that may not exist in all responses
+- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields
+- [ ] When using `type: 'array'`, `items` defines the item structure with `properties`
+- [ ] Field descriptions are accurate and helpful
+
+### Types (types.ts)
+- [ ] Has param interfaces for every tool (e.g., `XCreateTweetParams`)
+- [ ] Has response interfaces for every tool (extending `ToolResponse`)
+- [ ] Optional params use `?` in the interface (e.g., `replyTo?: string`)
+- [ ] Field names in types match actual API field names
+- [ ] Shared response types are properly reused (e.g., `XTweetResponse` shared across tweet tools)
+
+### Barrel Export (index.ts)
+- [ ] Every tool is exported
+- [ ] All types are re-exported (`export * from './types'`)
+- [ ] No orphaned exports (tools that don't exist)
+
+### Tool Registry (tools/registry.ts)
+- [ ] Every tool is imported and registered
+- [ ] Registry keys use snake_case and match tool IDs exactly
+- [ ] Entries are in alphabetical order within the file
+
+## Step 4: Validate Block
+
+### Block ↔ Tool Alignment (CRITICAL)
+
+This is the most important validation — the block must be perfectly aligned with every tool it references.
+
+For **each tool** in `tools.access`:
+- [ ] The operation dropdown has an option whose ID matches the tool ID (or the `tools.config.tool` function correctly maps to it)
+- [ ] Every **required** tool param (except `accessToken`) has a corresponding subBlock input that is:
+ - Shown when that operation is selected (correct `condition`)
+ - Marked as `required: true` (or conditionally required)
+- [ ] Every **optional** tool param has a corresponding subBlock input (or is intentionally omitted if truly never needed)
+- [ ] SubBlock `id` values are unique across the entire block — no duplicates even across different conditions
+- [ ] The `tools.config.tool` function returns the correct tool ID for every possible operation value
+- [ ] The `tools.config.params` function correctly maps subBlock IDs to tool param names when they differ
+
+### SubBlocks
+- [ ] Operation dropdown lists ALL tool operations available in `tools.access`
+- [ ] Dropdown option labels are human-readable and descriptive
+- [ ] Conditions use correct syntax:
+ - Single value: `{ field: 'operation', value: 'x_create_tweet' }`
+ - Multiple values (OR): `{ field: 'operation', value: ['x_create_tweet', 'x_delete_tweet'] }`
+ - Negation: `{ field: 'operation', value: 'delete', not: true }`
+ - Compound: `{ field: 'op', value: 'send', and: { field: 'type', value: 'dm' } }`
+- [ ] Condition arrays include ALL operations that use that field — none missing
+- [ ] `dependsOn` is set for fields that need other values (selectors depending on credential, cascading dropdowns)
+- [ ] SubBlock types match tool param types:
+ - Enum/fixed options → `dropdown`
+ - Free text → `short-input`
+ - Long text/content → `long-input`
+ - True/false → `dropdown` with Yes/No options (not `switch` unless purely UI toggle)
+ - Credentials → `oauth-input` with correct `serviceId`
+- [ ] Dropdown `value: () => 'default'` is set for dropdowns with a sensible default
+
+### Advanced Mode
+- [ ] Optional, rarely-used fields are set to `mode: 'advanced'`:
+ - Pagination tokens / next tokens
+ - Time range filters (start/end time)
+ - Sort order / direction options
+ - Max results / per page limits
+ - Reply settings / threading options
+ - Rarely used IDs (reply-to, quote-tweet, etc.)
+ - Exclude filters
+- [ ] **Required** fields are NEVER set to `mode: 'advanced'`
+- [ ] Fields that users fill in most of the time are NOT set to `mode: 'advanced'`
+
+### WandConfig
+- [ ] Timestamp fields have `wandConfig` with `generationType: 'timestamp'`
+- [ ] Comma-separated list fields have `wandConfig` with a descriptive prompt
+- [ ] Complex filter/query fields have `wandConfig` with format examples in the prompt
+- [ ] All `wandConfig` prompts end with "Return ONLY the [format] - no explanations, no extra text."
+- [ ] `wandConfig.placeholder` describes what to type in natural language
+
+### Tools Config
+- [ ] `tools.access` lists **every** tool ID the block can use — none missing
+- [ ] `tools.config.tool` returns the correct tool ID for each operation
+- [ ] Type coercions are in `tools.config.params` (runs at execution time), NOT in `tools.config.tool` (runs at serialization time before variable resolution)
+- [ ] `tools.config.params` handles:
+ - `Number()` conversion for numeric params that come as strings from inputs
+ - `Boolean` / string-to-boolean conversion for toggle params
+ - Empty string → `undefined` conversion for optional dropdown values
+ - Any subBlock ID → tool param name remapping
+- [ ] No `Number()`, `JSON.parse()`, or other coercions in `tools.config.tool` — these would destroy dynamic references like ``
+
+### Block Outputs
+- [ ] Outputs cover the key fields returned by ALL tools (not just one operation)
+- [ ] Output types are correct (`'string'`, `'number'`, `'boolean'`, `'json'`)
+- [ ] `type: 'json'` outputs either:
+ - Describe inner fields in the description string (GOOD): `'User profile (id, name, username, bio)'`
+ - Use nested output definitions (BEST): `{ id: { type: 'string' }, name: { type: 'string' } }`
+- [ ] No opaque `type: 'json'` with vague descriptions like `'Response data'`
+- [ ] Outputs that only appear for certain operations use `condition` if supported, or document which operations return them
+
+### Block Metadata
+- [ ] `type` is snake_case (e.g., `'x'`, `'cloudflare'`)
+- [ ] `name` is human-readable (e.g., `'X'`, `'Cloudflare'`)
+- [ ] `description` is a concise one-liner
+- [ ] `longDescription` provides detail for docs
+- [ ] `docsLink` points to `'https://docs.sim.ai/tools/{service}'`
+- [ ] `category` is `'tools'`
+- [ ] `bgColor` uses the service's brand color hex
+- [ ] `icon` references the correct icon component from `@/components/icons`
+- [ ] `authMode` is set correctly (`AuthMode.OAuth` or `AuthMode.ApiKey`)
+- [ ] Block is registered in `blocks/registry.ts` alphabetically
+
+### Block Inputs
+- [ ] `inputs` section lists all subBlock params that the block accepts
+- [ ] Input types match the subBlock types
+- [ ] When using `canonicalParamId`, inputs list the canonical ID (not the raw subBlock IDs)
+
+## Step 5: Validate OAuth Scopes (if OAuth service)
+
+Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
+
+- [ ] Scopes defined in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
+- [ ] `auth.ts` uses `getCanonicalScopesForProvider(providerId)` — NOT a hardcoded array
+- [ ] Block `requiredScopes` uses `getScopesForService(serviceId)` — NOT a hardcoded array
+- [ ] No hardcoded scope arrays in `auth.ts` or block files (should all use utility functions)
+- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
+- [ ] No excess scopes that aren't needed by any tool
+
+## Step 6: Validate Pagination Consistency
+
+If any tools support pagination:
+- [ ] Pagination param names match the API docs (e.g., `pagination_token` vs `next_token` vs `cursor`)
+- [ ] Different API endpoints that use different pagination param names have separate subBlocks in the block
+- [ ] Pagination response fields (`nextToken`, `cursor`, etc.) are included in tool outputs
+- [ ] Pagination subBlocks are set to `mode: 'advanced'`
+
+## Step 7: Validate Error Handling
+
+- [ ] `transformResponse` checks for error conditions before accessing data
+- [ ] Error responses include meaningful messages (not just generic "failed")
+- [ ] HTTP error status codes are handled (check `response.ok` or status codes)
+
+## Step 8: Report and Fix
+
+### Report Format
+
+Group findings by severity:
+
+**Critical** (will cause runtime errors or incorrect behavior):
+- Wrong endpoint URL or HTTP method
+- Missing required params or wrong `required` flag
+- Incorrect response field mapping (accessing wrong path in response)
+- Missing error handling that would cause crashes
+- Tool ID mismatch between tool file, registry, and block `tools.access`
+- OAuth scopes missing in `auth.ts` that tools need
+- `tools.config.tool` returning wrong tool ID for an operation
+- Type coercions in `tools.config.tool` instead of `tools.config.params`
+
+**Warning** (follows conventions incorrectly or has usability issues):
+- Optional field not set to `mode: 'advanced'`
+- Missing `wandConfig` on timestamp/complex fields
+- Wrong `visibility` on params (e.g., `'hidden'` instead of `'user-or-llm'`)
+- Missing `optional: true` on nullable outputs
+- Opaque `type: 'json'` without property descriptions
+- Missing `.trim()` on ID fields in request URLs
+- Missing `?? null` on nullable response fields
+- Block condition array missing an operation that uses that field
+- Hardcoded scope arrays instead of using `getScopesForService()` / `getCanonicalScopesForProvider()`
+- Missing scope description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
+
+**Suggestion** (minor improvements):
+- Better description text
+- Inconsistent naming across tools
+- Missing `longDescription` or `docsLink`
+- Pagination fields that could benefit from `wandConfig`
+
+### Fix All Issues
+
+After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity.
+
+### Validation Output
+
+After fixing, confirm:
+1. `bun run lint` passes with no fixes needed
+2. TypeScript compiles clean (no type errors)
+3. Re-read all modified files to verify fixes are correct
+
+## Checklist Summary
+
+- [ ] Read ALL tool files, block, types, index, and registries
+- [ ] Pulled and read official API documentation
+- [ ] Validated every tool's ID, params, request, response, outputs, and types against API docs
+- [ ] Validated block ↔ tool alignment (every tool param has a subBlock, every condition is correct)
+- [ ] Validated advanced mode on optional/rarely-used fields
+- [ ] Validated wandConfig on timestamps and complex inputs
+- [ ] Validated tools.config mapping, tool selector, and type coercions
+- [ ] Validated block outputs match what tools return, with typed JSON where possible
+- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
+- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
+- [ ] Validated pagination consistency across tools and block
+- [ ] Validated error handling (error checks, meaningful messages)
+- [ ] Validated registry entries (tools and block, alphabetical, correct imports)
+- [ ] Reported all issues grouped by severity
+- [ ] Fixed all critical and warning issues
+- [ ] Ran `bun run lint` after fixes
+- [ ] Verified TypeScript compiles clean
diff --git a/.agents/skills/validate-integration/agents/openai.yaml b/.agents/skills/validate-integration/agents/openai.yaml
new file mode 100644
index 00000000000..90b57edf73f
--- /dev/null
+++ b/.agents/skills/validate-integration/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Validate Integration"
+ short_description: "Audit a Sim service integration"
+ brand_color: "#B45309"
+ default_prompt: "Use $validate-integration to audit and fix a Sim integration against its API docs."
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000000..004252ac6a4
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,383 @@
+# Sim Development Guidelines
+
+You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
+
+## Global Standards
+
+- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`
+- **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments
+- **Styling**: Never update global styles. Keep all styling local to components
+- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`
+
+## Architecture
+
+### Core Principles
+1. Single Responsibility: Each component, hook, store has one clear purpose
+2. Composition Over Complexity: Break down complex logic into smaller pieces
+3. Type Safety First: TypeScript interfaces for all props, state, return types
+4. Predictable State: Zustand for global state, useState for UI-only concerns
+
+### Root Structure
+```
+apps/sim/
+├── app/ # Next.js app router (pages, API routes)
+├── blocks/ # Block definitions and registry
+├── components/ # Shared UI (emcn/, ui/)
+├── executor/ # Workflow execution engine
+├── hooks/ # Shared hooks (queries/, selectors/)
+├── lib/ # App-wide utilities
+├── providers/ # LLM provider integrations
+├── stores/ # Zustand stores
+├── tools/ # Tool definitions
+└── triggers/ # Trigger definitions
+```
+
+### Naming Conventions
+- Components: PascalCase (`WorkflowList`)
+- Hooks: `use` prefix (`useWorkflowOperations`)
+- Files: kebab-case (`workflow-list.tsx`)
+- Stores: `stores/feature/store.ts`
+- Constants: SCREAMING_SNAKE_CASE
+- Interfaces: PascalCase with suffix (`WorkflowListProps`)
+
+## Imports
+
+**Always use absolute imports.** Never use relative imports.
+
+```typescript
+// ✓ Good
+import { useWorkflowStore } from '@/stores/workflows/store'
+
+// ✗ Bad
+import { useWorkflowStore } from '../../../stores/workflows/store'
+```
+
+Use barrel exports (`index.ts`) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source.
+
+### Import Order
+1. React/core libraries
+2. External libraries
+3. UI components (`@/components/emcn`, `@/components/ui`)
+4. Utilities (`@/lib/...`)
+5. Stores (`@/stores/...`)
+6. Feature imports
+7. CSS imports
+
+Use `import type { X }` for type-only imports.
+
+## TypeScript
+
+1. No `any` - Use proper types or `unknown` with type guards
+2. Always define props interface for components
+3. `as const` for constant objects/arrays
+4. Explicit ref types: `useRef(null)`
+
+## Components
+
+```typescript
+'use client' // Only if using hooks
+
+const CONFIG = { SPACING: 8 } as const
+
+interface ComponentProps {
+ requiredProp: string
+ optionalProp?: boolean
+}
+
+export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
+ // Order: refs → external hooks → store hooks → custom hooks → state → useMemo → useCallback → useEffect → return
+}
+```
+
+Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational.
+
+## Hooks
+
+```typescript
+interface UseFeatureProps { id: string }
+
+export function useFeature({ id }: UseFeatureProps) {
+ const idRef = useRef(id)
+ const [data, setData] = useState(null)
+
+ useEffect(() => { idRef.current = id }, [id])
+
+ const fetchData = useCallback(async () => { ... }, []) // Empty deps when using refs
+
+ return { data, fetchData }
+}
+```
+
+## Zustand Stores
+
+Stores live in `stores/`. Complex stores split into `store.ts` + `types.ts`.
+
+```typescript
+import { create } from 'zustand'
+import { devtools } from 'zustand/middleware'
+
+const initialState = { items: [] as Item[] }
+
+export const useFeatureStore = create()(
+ devtools(
+ (set, get) => ({
+ ...initialState,
+ setItems: (items) => set({ items }),
+ reset: () => set(initialState),
+ }),
+ { name: 'feature-store' }
+ )
+)
+```
+
+Use `devtools` middleware. Use `persist` only when data should survive reload with `partialize` to persist only necessary state.
+
+## React Query
+
+All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations.
+
+### Query Key Factory
+
+Every file must have a hierarchical key factory with an `all` root key and intermediate plural keys for prefix invalidation:
+
+```typescript
+export const entityKeys = {
+ all: ['entity'] as const,
+ lists: () => [...entityKeys.all, 'list'] as const,
+ list: (workspaceId?: string) => [...entityKeys.lists(), workspaceId ?? ''] as const,
+ details: () => [...entityKeys.all, 'detail'] as const,
+ detail: (id?: string) => [...entityKeys.details(), id ?? ''] as const,
+}
+```
+
+### Query Hooks
+
+- Every `queryFn` must forward `signal` for request cancellation
+- Every query must have an explicit `staleTime`
+- Use `keepPreviousData` only on variable-key queries (where params change), never on static keys
+
+```typescript
+export function useEntityList(workspaceId?: string) {
+ return useQuery({
+ queryKey: entityKeys.list(workspaceId),
+ queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
+ enabled: Boolean(workspaceId),
+ staleTime: 60 * 1000,
+ placeholderData: keepPreviousData, // OK: workspaceId varies
+ })
+}
+```
+
+### Mutation Hooks
+
+- Use targeted invalidation (`entityKeys.lists()`) not broad (`entityKeys.all`) when possible
+- For optimistic updates: use `onSettled` (not `onSuccess`) for cache reconciliation — `onSettled` fires on both success and error
+- Don't include mutation objects in `useCallback` deps — `.mutate()` is stable in TanStack Query v5
+
+```typescript
+export function useUpdateEntity() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (variables) => { /* ... */ },
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) })
+ const previous = queryClient.getQueryData(entityKeys.detail(variables.id))
+ queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic */)
+ return { previous }
+ },
+ onError: (_err, variables, context) => {
+ queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous)
+ },
+ onSettled: (_data, _error, variables) => {
+ queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
+ queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) })
+ },
+ })
+}
+```
+
+## Styling
+
+Use Tailwind only, no inline styles. Use `cn()` from `@/lib/utils` for conditional classes.
+
+```typescript
+
+```
+
+## EMCN Components
+
+Import from `@/components/emcn`, never from subpaths (except CSS files). Use CVA when 2+ variants exist.
+
+## Testing
+
+Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.cursor/rules/sim-testing.mdc` for full details.
+
+### Global Mocks (vitest.setup.ts)
+
+`@sim/db`, `drizzle-orm`, `@sim/logger`, `@/blocks/registry`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior.
+
+### Standard Test Pattern
+
+```typescript
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetSession } = vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+import { GET } from '@/app/api/my-route/route'
+
+describe('my route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
+ })
+ it('returns data', async () => { ... })
+})
+```
+
+### Performance Rules
+
+- **NEVER** use `vi.resetModules()` + `vi.doMock()` + `await import()` — use `vi.hoisted()` + `vi.mock()` + static imports
+- **NEVER** use `vi.importActual()` — mock everything explicitly
+- **NEVER** use `mockAuth()`, `mockConsoleLogger()`, `setupCommonApiMocks()` from `@sim/testing` — they use `vi.doMock()` internally
+- **Mock heavy deps** (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them
+- **Use `@vitest-environment node`** unless DOM APIs are needed (`window`, `document`, `FormData`)
+- **Avoid real timers** — use 1ms delays or `vi.useFakeTimers()`
+
+Use `@sim/testing` mocks/factories over local test data.
+
+## Utils Rules
+
+- Never create `utils.ts` for single consumer - inline it
+- Create `utils.ts` when 2+ files need the same helper
+- Check existing sources in `lib/` before duplicating
+
+## Adding Integrations
+
+New integrations require: **Tools** → **Block** → **Icon** → (optional) **Trigger**
+
+Always look up the service's API docs first.
+
+### 1. Tools (`tools/{service}/`)
+
+```
+tools/{service}/
+├── index.ts # Barrel export
+├── types.ts # Params/response types
+└── {action}.ts # Tool implementation
+```
+
+**Tool structure:**
+```typescript
+export const serviceTool: ToolConfig = {
+ id: 'service_action',
+ name: 'Service Action',
+ description: '...',
+ version: '1.0.0',
+ oauth: { required: true, provider: 'service' },
+ params: { /* ... */ },
+ request: { url: '/api/tools/service/action', method: 'POST', ... },
+ transformResponse: async (response) => { /* ... */ },
+ outputs: { /* ... */ },
+}
+```
+
+Register in `tools/registry.ts`.
+
+### 2. Block (`blocks/blocks/{service}.ts`)
+
+```typescript
+export const ServiceBlock: BlockConfig = {
+ type: 'service',
+ name: 'Service',
+ description: '...',
+ category: 'tools',
+ bgColor: '#hexcolor',
+ icon: ServiceIcon,
+ subBlocks: [ /* see SubBlock Properties */ ],
+ tools: { access: ['service_action'], config: { tool: (p) => `service_${p.operation}`, params: (p) => ({ /* type coercions here */ }) } },
+ inputs: { /* ... */ },
+ outputs: { /* ... */ },
+}
+```
+
+Register in `blocks/registry.ts` (alphabetically).
+
+**Important:** `tools.config.tool` runs during serialization (before variable resolution). Never do `Number()` or other type coercions there — dynamic references like `` will be destroyed. Use `tools.config.params` for type coercions (it runs during execution, after variables are resolved).
+
+**SubBlock Properties:**
+```typescript
+{
+ id: 'field', title: 'Label', type: 'short-input', placeholder: '...',
+ required: true, // or condition object
+ condition: { field: 'op', value: 'send' }, // show/hide
+ dependsOn: ['credential'], // clear when dep changes
+ mode: 'basic', // 'basic' | 'advanced' | 'both' | 'trigger'
+}
+```
+
+**condition examples:**
+- `{ field: 'op', value: 'send' }` - show when op === 'send'
+- `{ field: 'op', value: ['a','b'] }` - show when op is 'a' OR 'b'
+- `{ field: 'op', value: 'x', not: true }` - show when op !== 'x'
+- `{ field: 'op', value: 'x', not: true, and: { field: 'type', value: 'dm', not: true } }` - complex
+
+**dependsOn:** `['field']` or `{ all: ['a'], any: ['b', 'c'] }`
+
+**File Input Pattern (basic/advanced mode):**
+```typescript
+// Basic: file-upload UI
+{ id: 'uploadFile', type: 'file-upload', canonicalParamId: 'file', mode: 'basic' },
+// Advanced: reference from other blocks
+{ id: 'fileRef', type: 'short-input', canonicalParamId: 'file', mode: 'advanced' },
+```
+
+In `tools.config.tool`, normalize with:
+```typescript
+import { normalizeFileInput } from '@/blocks/utils'
+const file = normalizeFileInput(params.uploadFile || params.fileRef, { single: true })
+if (file) params.file = file
+```
+
+For file uploads, create an internal API route (`/api/tools/{service}/upload`) that uses `downloadFileFromStorage` to get file content from `UserFile` objects.
+
+### 3. Icon (`components/icons.tsx`)
+
+```typescript
+export function ServiceIcon(props: SVGProps) {
+ return /* SVG from brand assets */
+}
+```
+
+### 4. Trigger (`triggers/{service}/`) - Optional
+
+```
+triggers/{service}/
+├── index.ts # Barrel export
+├── webhook.ts # Webhook handler
+└── {event}.ts # Event-specific handlers
+```
+
+Register in `triggers/registry.ts`.
+
+### Integration Checklist
+
+- [ ] Look up API docs
+- [ ] Create `tools/{service}/` with types and tools
+- [ ] Register tools in `tools/registry.ts`
+- [ ] Add icon to `components/icons.tsx`
+- [ ] Create block in `blocks/blocks/{service}.ts`
+- [ ] Register block in `blocks/registry.ts`
+- [ ] (Optional) Create and register triggers
+- [ ] (If file uploads) Create internal API route with `downloadFileFromStorage`
+- [ ] (If file uploads) Use `normalizeFileInput` in block config
diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md
new file mode 100644
index 00000000000..83a44c7939b
--- /dev/null
+++ b/apps/sim/AGENTS.md
@@ -0,0 +1,33 @@
+# Sim App Scope
+
+These rules apply to files under `apps/sim/` in addition to the repository root [AGENTS.md](/AGENTS.md).
+
+## Architecture
+
+- Follow the app structure already established under `app/`, `blocks/`, `components/`, `executor/`, `hooks/`, `lib/`, `providers/`, `stores/`, `tools/`, and `triggers/`.
+- Keep single responsibility for components, hooks, and stores.
+- Prefer composition over large mixed-responsibility modules.
+- Use `lib/` for app-wide helpers, feature-local `utils/` only when 2+ files share the helper, and inline single-use helpers.
+
+## Imports And Types
+
+- Always use absolute imports from `@/...`; do not add relative imports.
+- Use barrel exports only when a folder has 3+ exports; do not re-export through non-barrel files.
+- Use `import type` for type-only imports.
+- Do not use `any`; prefer precise types or `unknown` with guards.
+
+## Components And Styling
+
+- Use `'use client'` only when hooks or browser-only APIs are required.
+- Define a props interface for every component.
+- Extract constants with `as const` where appropriate.
+- Use Tailwind classes and `cn()` for conditional classes; avoid inline styles unless CSS variables are the intended mechanism.
+- Keep styling local to the component; do not modify global styles for feature work.
+
+## Testing
+
+- Use Vitest.
+- Prefer `@vitest-environment node` unless DOM APIs are required.
+- Use `vi.hoisted()` + `vi.mock()` + static imports; do not use `vi.resetModules()` + `vi.doMock()` + dynamic imports except for true module-scope singletons.
+- Do not use `vi.importActual()`.
+- Prefer mocks and factories from `@sim/testing`.
diff --git a/apps/sim/blocks/AGENTS.md b/apps/sim/blocks/AGENTS.md
new file mode 100644
index 00000000000..c82d65915ba
--- /dev/null
+++ b/apps/sim/blocks/AGENTS.md
@@ -0,0 +1,12 @@
+# Blocks Scope
+
+These rules apply to block definitions under `apps/sim/blocks/**`.
+
+- Keep block `type` values and tool mappings aligned with the actual integration tool IDs.
+- Every subblock `id` must be unique within the block, even across different conditions.
+- Use `condition`, `required`, `dependsOn`, and `mode` deliberately to reflect the UX and execution requirements.
+- Use `canonicalParamId` only to link alternative inputs for the same logical parameter; do not reuse it as a subblock `id`.
+- If one field in a canonical group is required, all alternatives in that group must also be required.
+- Put type coercion in `tools.config.params`, never in `tools.config.tool`.
+- When supporting file inputs, follow the basic/advanced pattern and normalize with `normalizeFileInput`.
+- Keep block outputs aligned with what the referenced tools actually return.
diff --git a/apps/sim/components/emcn/AGENTS.md b/apps/sim/components/emcn/AGENTS.md
new file mode 100644
index 00000000000..6050383d489
--- /dev/null
+++ b/apps/sim/components/emcn/AGENTS.md
@@ -0,0 +1,11 @@
+# EMCN Components Scope
+
+These rules apply to `apps/sim/components/emcn/**`.
+
+- Import from `@/components/emcn`, never from subpaths except CSS files.
+- Use Radix UI primitives for accessibility where applicable.
+- Use CVA when a component has 2+ variants; use direct `className` composition for single-style components.
+- Export both the component and its variants helper when using CVA.
+- Keep tokens consistent with the existing library style such as `font-medium`, `text-[12px]`, and `rounded-[4px]`.
+- Prefer `transition-colors` for interactive hover and active states.
+- Use TSDoc when documenting public components or APIs.
diff --git a/apps/sim/hooks/AGENTS.md b/apps/sim/hooks/AGENTS.md
new file mode 100644
index 00000000000..c682642a34f
--- /dev/null
+++ b/apps/sim/hooks/AGENTS.md
@@ -0,0 +1,11 @@
+# Hooks Scope
+
+These rules apply to custom hooks under `apps/sim/**/hooks/**` and `apps/sim/**/use-*.ts`.
+
+- Give each hook a single clear responsibility.
+- Define a props interface for hook inputs.
+- Use refs for stable callback dependencies when avoiding dependency churn.
+- Wrap returned operations in `useCallback` when the consumer benefits from stable function identity.
+- Track loading and error states for async hooks when relevant.
+- Use `try`/`catch` around async operations that can fail.
+- Keep hook logic separate from rendering concerns.
diff --git a/apps/sim/hooks/queries/AGENTS.md b/apps/sim/hooks/queries/AGENTS.md
new file mode 100644
index 00000000000..2a461d88a9c
--- /dev/null
+++ b/apps/sim/hooks/queries/AGENTS.md
@@ -0,0 +1,13 @@
+# React Query Scope
+
+These rules apply to `apps/sim/hooks/queries/**`.
+
+- All server state must go through React Query; do not use ad hoc `useState` + `fetch` patterns in components for server data.
+- Define a query key factory in each file with an `all` root key and intermediate plural keys such as `lists()` and `details()` for prefix invalidation.
+- Do not use inline query keys.
+- Every `queryFn` must forward `signal` for cancellation.
+- Every query must set an explicit `staleTime`.
+- Use `keepPreviousData` only for variable-key queries where params change.
+- Prefer targeted invalidation such as `entityKeys.lists()` over broad invalidation such as `entityKeys.all`.
+- For optimistic updates, reconcile caches in `onSettled`, not only `onSuccess`.
+- Do not include stable TanStack mutation objects in `useCallback` deps just to call `.mutate()`.
diff --git a/apps/sim/stores/AGENTS.md b/apps/sim/stores/AGENTS.md
new file mode 100644
index 00000000000..2e46265f94c
--- /dev/null
+++ b/apps/sim/stores/AGENTS.md
@@ -0,0 +1,12 @@
+# Stores Scope
+
+These rules apply to Zustand stores under `apps/sim/**/stores/**` and `apps/sim/**/store.ts`.
+
+- Use `devtools` middleware for stores unless there is a clear reason not to.
+- Use `persist` only when state should survive reloads.
+- When persisting, use `partialize` to store only the minimum required fields.
+- Use immutable updates only.
+- Use `set((state) => ...)` when the next value depends on previous state.
+- Expose a `reset()` action for non-trivial stores.
+- Split more complex stores into `store.ts` and `types.ts`.
+- Use hydration tracking such as `_hasHydrated` when persisted stores need it.
diff --git a/apps/sim/tools/AGENTS.md b/apps/sim/tools/AGENTS.md
new file mode 100644
index 00000000000..dcdf51aa14e
--- /dev/null
+++ b/apps/sim/tools/AGENTS.md
@@ -0,0 +1,13 @@
+# Tools Scope
+
+These rules apply to integration tool definitions under `apps/sim/tools/**`.
+
+- Start from the service API docs before adding or changing a tool.
+- Keep each service under `tools/{service}/` with `index.ts`, `types.ts`, and one file per action.
+- Tool IDs must use `snake_case` and match registry keys exactly.
+- Use `visibility: 'hidden'` only for system-injected params such as OAuth access tokens.
+- Use `visibility: 'user-only'` for credentials and account-specific values the user must provide.
+- Use `visibility: 'user-or-llm'` for ordinary operation parameters.
+- In `transformResponse`, extract meaningful fields instead of dumping raw JSON.
+- Use `?? null` for nullable response fields and `?? []` for optional arrays where appropriate.
+- Register every tool in `tools/registry.ts` and keep entries aligned with the exported tool IDs.
diff --git a/apps/sim/triggers/AGENTS.md b/apps/sim/triggers/AGENTS.md
new file mode 100644
index 00000000000..2438ea43458
--- /dev/null
+++ b/apps/sim/triggers/AGENTS.md
@@ -0,0 +1,10 @@
+# Triggers Scope
+
+These rules apply to trigger definitions under `apps/sim/triggers/**`.
+
+- Research the service webhook model before implementing triggers.
+- Keep each service under `triggers/{service}/` with barrel exports and event-specific files.
+- Use shared helpers for setup instructions, extra fields, and output definitions when multiple triggers in the same service need them.
+- Ensure the primary trigger supports switching between trigger types when the integration pattern requires it.
+- Keep trigger outputs explicit and useful for downstream blocks.
+- Register triggers in `triggers/registry.ts` and keep IDs aligned with the integration naming scheme.
From 8caaf013716b639da4334ae37759d19a53809c73 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Wed, 25 Mar 2026 10:46:57 -0700
Subject: [PATCH 03/20] fix(ui): fix kb id extraction logic for resource, sync
tags (#3763)
* fix(ui): fix kb id extraction logic for resource, sync tags
* Pass knowledge base id back on edit tag
---------
Co-authored-by: Theodore Li
---
.../resource-registry/resource-registry.tsx | 1 +
.../lib/copilot/resource-extraction.test.ts | 85 +++++++++++++++++++
apps/sim/lib/copilot/resource-extraction.ts | 6 +-
.../tools/server/knowledge/knowledge-base.ts | 13 +++
4 files changed, 103 insertions(+), 2 deletions(-)
create mode 100644 apps/sim/lib/copilot/resource-extraction.test.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
index bf3d2db19f6..41529075ad4 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
@@ -138,6 +138,7 @@ const RESOURCE_INVALIDATORS: Record<
knowledgebase: (qc, _wId, id) => {
qc.invalidateQueries({ queryKey: knowledgeKeys.lists() })
qc.invalidateQueries({ queryKey: knowledgeKeys.detail(id) })
+ qc.invalidateQueries({ queryKey: knowledgeKeys.tagDefinitions(id) })
},
}
diff --git a/apps/sim/lib/copilot/resource-extraction.test.ts b/apps/sim/lib/copilot/resource-extraction.test.ts
new file mode 100644
index 00000000000..bb1b1677098
--- /dev/null
+++ b/apps/sim/lib/copilot/resource-extraction.test.ts
@@ -0,0 +1,85 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { extractResourcesFromToolResult } from './resource-extraction'
+
+describe('extractResourcesFromToolResult', () => {
+ it('uses the knowledge base id for knowledge_base tag mutations', () => {
+ const resources = extractResourcesFromToolResult(
+ 'knowledge_base',
+ {
+ operation: 'update_tag',
+ args: {
+ knowledgeBaseId: 'kb_123',
+ tagDefinitionId: 'tag_456',
+ },
+ },
+ {
+ success: true,
+ message: 'Tag updated successfully',
+ data: {
+ id: 'tag_456',
+ displayName: 'Priority',
+ fieldType: 'text',
+ },
+ }
+ )
+
+ expect(resources).toEqual([
+ {
+ type: 'knowledgebase',
+ id: 'kb_123',
+ title: 'Knowledge Base',
+ },
+ ])
+ })
+
+ it('uses knowledgeBaseId from the tool result when update_tag args omit it', () => {
+ const resources = extractResourcesFromToolResult(
+ 'knowledge_base',
+ {
+ operation: 'update_tag',
+ args: {
+ tagDefinitionId: 'tag_456',
+ },
+ },
+ {
+ success: true,
+ message: 'Tag updated successfully',
+ data: {
+ id: 'tag_456',
+ knowledgeBaseId: 'kb_123',
+ displayName: 'Priority',
+ fieldType: 'text',
+ },
+ }
+ )
+
+ expect(resources).toEqual([
+ {
+ type: 'knowledgebase',
+ id: 'kb_123',
+ title: 'Knowledge Base',
+ },
+ ])
+ })
+
+ it('does not create resources for read-only knowledge base tag operations', () => {
+ const resources = extractResourcesFromToolResult(
+ 'knowledge_base',
+ {
+ operation: 'list_tags',
+ args: {
+ knowledgeBaseId: 'kb_123',
+ },
+ },
+ {
+ success: true,
+ data: [],
+ }
+ )
+
+ expect(resources).toEqual([])
+ })
+})
diff --git a/apps/sim/lib/copilot/resource-extraction.ts b/apps/sim/lib/copilot/resource-extraction.ts
index 94a7e9e160b..5eeda39f027 100644
--- a/apps/sim/lib/copilot/resource-extraction.ts
+++ b/apps/sim/lib/copilot/resource-extraction.ts
@@ -155,11 +155,13 @@ export function extractResourcesFromToolResult(
case 'knowledge_base': {
if (READ_ONLY_KB_OPS.has(getOperation(params) ?? '')) return []
+ const args = asRecord(params?.args)
const kbId =
- (data.id as string) ??
+ (args.knowledgeBaseId as string) ??
+ (params?.knowledgeBaseId as string) ??
(result.knowledgeBaseId as string) ??
(data.knowledgeBaseId as string) ??
- (params?.knowledgeBaseId as string)
+ (data.id as string)
if (kbId) {
const kbName =
(data.name as string) ?? (result.knowledgeBaseName as string) ?? 'Knowledge Base'
diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts
index 54f217f3b1a..a459a271957 100644
--- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts
+++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts
@@ -28,6 +28,7 @@ import {
deleteTagDefinition,
getDocumentTagDefinitions,
getNextAvailableSlot,
+ getTagDefinitionById,
getTagUsageStats,
updateTagDefinition,
} from '@/lib/knowledge/tags/service'
@@ -525,6 +526,7 @@ export const knowledgeBaseServerTool: BaseServerTool
Date: Wed, 25 Mar 2026 12:02:19 -0700
Subject: [PATCH 04/20] feat(rippling): add Rippling HR integration with 19
tools (#3764)
* feat(rippling): add Rippling HR integration with 19 tools
* fix(rippling): address PR review feedback
- Fix lint:check import ordering in icon-mapping.ts
- Build clean params object instead of spreading all UI fields to API
- Add try/catch around JSON.parse for users field
- Use != null guard for limit/offset to not drop 0 values
- Add missing tags to block config and integrations.json
* fix(rippling): guard startDate by operation and clarify totalCount descriptions
- Guard startDate/endDate with operation check to prevent candidateStartDate
from clobbering date filters on leave/activity operations
- Update totalCount output descriptions on paginated tools to clarify it
reflects page size, not total record count
* fix(rippling): use null-safe guard for groupVersion param
* fix(rippling): remove operation field from tool params payload
* fix(rippling): add input validation for action param and empty group update body
---
apps/docs/components/icons.tsx | 11 +
apps/docs/components/ui/icon-mapping.ts | 2 +
apps/docs/content/docs/en/tools/meta.json | 1 +
apps/docs/content/docs/en/tools/quiver.mdx | 5 +-
apps/docs/content/docs/en/tools/rippling.mdx | 506 ++++++++++++++++++
.../integrations/data/icon-mapping.ts | 2 +
.../integrations/data/integrations.json | 99 +++-
apps/sim/blocks/blocks/rippling.ts | 437 +++++++++++++++
apps/sim/blocks/registry.ts | 2 +
apps/sim/components/icons.tsx | 11 +
apps/sim/tools/registry.ts | 40 ++
apps/sim/tools/rippling/create_group.ts | 91 ++++
apps/sim/tools/rippling/get_company.ts | 75 +++
.../tools/rippling/get_company_activity.ts | 125 +++++
apps/sim/tools/rippling/get_current_user.ts | 57 ++
apps/sim/tools/rippling/get_employee.ts | 79 +++
apps/sim/tools/rippling/get_leave_balance.ts | 77 +++
apps/sim/tools/rippling/index.ts | 20 +
apps/sim/tools/rippling/list_custom_fields.ts | 96 ++++
apps/sim/tools/rippling/list_departments.ts | 94 ++++
apps/sim/tools/rippling/list_employees.ts | 112 ++++
.../list_employees_with_terminated.ts | 113 ++++
.../sim/tools/rippling/list_leave_balances.ts | 107 ++++
.../sim/tools/rippling/list_leave_requests.ts | 111 ++++
apps/sim/tools/rippling/list_leave_types.ts | 87 +++
apps/sim/tools/rippling/list_levels.ts | 91 ++++
apps/sim/tools/rippling/list_teams.ts | 89 +++
.../sim/tools/rippling/list_work_locations.ts | 102 ++++
.../tools/rippling/process_leave_request.ts | 79 +++
apps/sim/tools/rippling/push_candidate.ts | 124 +++++
apps/sim/tools/rippling/types.ts | 362 +++++++++++++
apps/sim/tools/rippling/update_group.ts | 115 ++++
32 files changed, 3318 insertions(+), 4 deletions(-)
create mode 100644 apps/docs/content/docs/en/tools/rippling.mdx
create mode 100644 apps/sim/blocks/blocks/rippling.ts
create mode 100644 apps/sim/tools/rippling/create_group.ts
create mode 100644 apps/sim/tools/rippling/get_company.ts
create mode 100644 apps/sim/tools/rippling/get_company_activity.ts
create mode 100644 apps/sim/tools/rippling/get_current_user.ts
create mode 100644 apps/sim/tools/rippling/get_employee.ts
create mode 100644 apps/sim/tools/rippling/get_leave_balance.ts
create mode 100644 apps/sim/tools/rippling/index.ts
create mode 100644 apps/sim/tools/rippling/list_custom_fields.ts
create mode 100644 apps/sim/tools/rippling/list_departments.ts
create mode 100644 apps/sim/tools/rippling/list_employees.ts
create mode 100644 apps/sim/tools/rippling/list_employees_with_terminated.ts
create mode 100644 apps/sim/tools/rippling/list_leave_balances.ts
create mode 100644 apps/sim/tools/rippling/list_leave_requests.ts
create mode 100644 apps/sim/tools/rippling/list_leave_types.ts
create mode 100644 apps/sim/tools/rippling/list_levels.ts
create mode 100644 apps/sim/tools/rippling/list_teams.ts
create mode 100644 apps/sim/tools/rippling/list_work_locations.ts
create mode 100644 apps/sim/tools/rippling/process_leave_request.ts
create mode 100644 apps/sim/tools/rippling/push_candidate.ts
create mode 100644 apps/sim/tools/rippling/types.ts
create mode 100644 apps/sim/tools/rippling/update_group.ts
diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx
index a7ee06c5e8d..0cf5773ca48 100644
--- a/apps/docs/components/icons.tsx
+++ b/apps/docs/components/icons.tsx
@@ -6270,6 +6270,17 @@ export function RedisIcon(props: SVGProps) {
)
}
+export function RipplingIcon(props: SVGProps) {
+ return (
+
+
+
+ )
+}
+
export function HexIcon(props: SVGProps) {
return (
diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts
index 53ae7c00371..900229eaad7 100644
--- a/apps/docs/components/ui/icon-mapping.ts
+++ b/apps/docs/components/ui/icon-mapping.ts
@@ -133,6 +133,7 @@ import {
ReductoIcon,
ResendIcon,
RevenueCatIcon,
+ RipplingIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
@@ -306,6 +307,7 @@ export const blockTypeToIconMap: Record = {
reducto_v2: ReductoIcon,
resend: ResendIcon,
revenuecat: RevenueCatIcon,
+ rippling: RipplingIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json
index a29c07b06df..9ff8f3ff78a 100644
--- a/apps/docs/content/docs/en/tools/meta.json
+++ b/apps/docs/content/docs/en/tools/meta.json
@@ -128,6 +128,7 @@
"reducto",
"resend",
"revenuecat",
+ "rippling",
"s3",
"salesforce",
"search",
diff --git a/apps/docs/content/docs/en/tools/quiver.mdx b/apps/docs/content/docs/en/tools/quiver.mdx
index 2a5df8d1ca6..14fbe522fd8 100644
--- a/apps/docs/content/docs/en/tools/quiver.mdx
+++ b/apps/docs/content/docs/en/tools/quiver.mdx
@@ -59,8 +59,9 @@ Generate SVG images from text prompts using QuiverAI
| --------- | ---- | ----------- |
| `success` | boolean | Whether the SVG generation succeeded |
| `output` | object | Generated SVG output |
-| ↳ `file` | file | Generated SVG file |
-| ↳ `svgContent` | string | Raw SVG markup content |
+| ↳ `file` | file | First generated SVG file |
+| ↳ `files` | json | All generated SVG files \(when n > 1\) |
+| ↳ `svgContent` | string | Raw SVG markup content of the first result |
| ↳ `id` | string | Generation request ID |
| ↳ `usage` | json | Token usage statistics |
| ↳ `totalTokens` | number | Total tokens used |
diff --git a/apps/docs/content/docs/en/tools/rippling.mdx b/apps/docs/content/docs/en/tools/rippling.mdx
new file mode 100644
index 00000000000..6eecade9a09
--- /dev/null
+++ b/apps/docs/content/docs/en/tools/rippling.mdx
@@ -0,0 +1,506 @@
+---
+title: Rippling
+description: Manage employees, leave, departments, and company data in Rippling
+---
+
+import { BlockInfoCard } from "@/components/ui/block-info-card"
+
+
+
+{/* MANUAL-CONTENT-START:intro */}
+[Rippling](https://www.rippling.com/) is a unified workforce management platform that brings together HR, IT, and Finance into a single system. Rippling lets companies manage payroll, benefits, devices, apps, and more — all from one place — while automating the tedious manual work that typically bogs down HR teams. Its robust API provides programmatic access to employee data, organizational structure, leave management, and onboarding workflows.
+
+**Why Rippling?**
+- **Unified Employee System of Record:** A single source of truth for employee profiles, departments, teams, levels, and work locations — no more syncing data across disconnected tools.
+- **Leave Management:** Full visibility into leave requests, balances, and types with the ability to approve or decline requests programmatically.
+- **Company Insights:** Access company activity events, custom fields, and organizational hierarchy to power reporting and compliance workflows.
+- **Onboarding Automation:** Push candidates directly into Rippling's onboarding flow, eliminating manual data entry when bringing on new hires.
+- **Group Management:** Create and update groups for third-party app provisioning via SCIM-compatible endpoints.
+
+**Using Rippling in Sim**
+
+Sim's Rippling integration connects your agentic workflows directly to your Rippling account using an API key. With 19 operations spanning employees, departments, teams, leave, groups, and candidates, you can build powerful HR automations without writing backend code.
+
+**Key benefits of using Rippling in Sim:**
+- **Employee directory automation:** List, search, and retrieve employee details — including terminated employees — to power onboarding checklists, offboarding workflows, and org chart updates.
+- **Leave workflow automation:** Monitor leave requests, check balances, and programmatically approve or decline requests based on custom business rules.
+- **Organizational intelligence:** Query departments, teams, levels, work locations, and custom fields to build dynamic org reports or trigger actions based on structural changes.
+- **Candidate onboarding:** Push candidates from your ATS or recruiting pipeline directly into Rippling's onboarding flow, complete with job title, department, and start date.
+- **Activity monitoring:** Track company activity events to build audit trails, compliance dashboards, or alert workflows when key changes occur.
+
+Whether you're automating new hire onboarding, building leave approval workflows, or syncing employee data across your tool stack, Rippling in Sim gives you direct, secure access to your HR platform — no middleware required. Simply configure your API key, select the operation you need, and let Sim handle the rest.
+{/* MANUAL-CONTENT-END */}
+
+
+## Usage Instructions
+
+Integrate Rippling into your workflow. Manage employees, departments, teams, leave requests, work locations, groups, candidates, and company information.
+
+
+
+## Tools
+
+### `rippling_list_employees`
+
+List all employees in Rippling with optional pagination
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `limit` | number | No | Maximum number of employees to return \(default 100, max 100\) |
+| `offset` | number | No | Offset for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `employees` | array | List of employees |
+| ↳ `id` | string | Employee ID |
+| ↳ `firstName` | string | First name |
+| ↳ `lastName` | string | Last name |
+| ↳ `workEmail` | string | Work email address |
+| ↳ `personalEmail` | string | Personal email address |
+| ↳ `roleState` | string | Employment status |
+| ↳ `department` | string | Department name or ID |
+| ↳ `title` | string | Job title |
+| ↳ `startDate` | string | Employment start date |
+| ↳ `endDate` | string | Employment end date |
+| ↳ `manager` | string | Manager ID or name |
+| ↳ `phone` | string | Phone number |
+| `totalCount` | number | Total number of employees returned |
+
+### `rippling_get_employee`
+
+Get details for a specific employee by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `employeeId` | string | Yes | The ID of the employee to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Employee ID |
+| `firstName` | string | First name |
+| `lastName` | string | Last name |
+| `workEmail` | string | Work email address |
+| `personalEmail` | string | Personal email address |
+| `roleState` | string | Employment status |
+| `department` | string | Department name or ID |
+| `title` | string | Job title |
+| `startDate` | string | Employment start date |
+| `endDate` | string | Employment end date |
+| `manager` | string | Manager ID or name |
+| `phone` | string | Phone number |
+
+### `rippling_list_employees_with_terminated`
+
+List all employees in Rippling including terminated employees with optional pagination
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `limit` | number | No | Maximum number of employees to return \(default 100, max 100\) |
+| `offset` | number | No | Offset for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `employees` | array | List of employees including terminated |
+| ↳ `id` | string | Employee ID |
+| ↳ `firstName` | string | First name |
+| ↳ `lastName` | string | Last name |
+| ↳ `workEmail` | string | Work email address |
+| ↳ `personalEmail` | string | Personal email address |
+| ↳ `roleState` | string | Employment status |
+| ↳ `department` | string | Department name or ID |
+| ↳ `title` | string | Job title |
+| ↳ `startDate` | string | Employment start date |
+| ↳ `endDate` | string | Employment end date |
+| ↳ `manager` | string | Manager ID or name |
+| ↳ `phone` | string | Phone number |
+| `totalCount` | number | Total number of employees returned |
+
+### `rippling_list_departments`
+
+List all departments in the Rippling organization
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `limit` | number | No | Maximum number of departments to return |
+| `offset` | number | No | Offset for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `departments` | array | List of departments |
+| ↳ `id` | string | Department ID |
+| ↳ `name` | string | Department name |
+| ↳ `parent` | string | Parent department ID |
+| `totalCount` | number | Total number of departments returned |
+
+### `rippling_list_teams`
+
+List all teams in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `limit` | number | No | Maximum number of teams to return |
+| `offset` | number | No | Offset for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `teams` | array | List of teams |
+| ↳ `id` | string | Team ID |
+| ↳ `name` | string | Team name |
+| ↳ `parent` | string | Parent team ID |
+| `totalCount` | number | Total number of teams returned |
+
+### `rippling_list_levels`
+
+List all position levels in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `limit` | number | No | Maximum number of levels to return |
+| `offset` | number | No | Offset for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `levels` | array | List of position levels |
+| ↳ `id` | string | Level ID |
+| ↳ `name` | string | Level name |
+| ↳ `parent` | string | Parent level ID |
+| `totalCount` | number | Total number of levels returned |
+
+### `rippling_list_work_locations`
+
+List all work locations in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `limit` | number | No | Maximum number of work locations to return |
+| `offset` | number | No | Offset for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `workLocations` | array | List of work locations |
+| ↳ `id` | string | Work location ID |
+| ↳ `nickname` | string | Location nickname |
+| ↳ `street` | string | Street address |
+| ↳ `city` | string | City |
+| ↳ `state` | string | State or province |
+| ↳ `zip` | string | ZIP or postal code |
+| ↳ `country` | string | Country |
+| `totalCount` | number | Total number of work locations returned |
+
+### `rippling_get_company`
+
+Get details for the current company in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Company ID |
+| `name` | string | Company name |
+| `address` | json | Company address with street, city, state, zip, country |
+| `email` | string | Company email address |
+| `phone` | string | Company phone number |
+| `workLocations` | array | List of work location IDs |
+
+### `rippling_get_company_activity`
+
+Get activity events for the current company in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `startDate` | string | No | Start date filter in ISO format \(e.g. 2024-01-01\) |
+| `endDate` | string | No | End date filter in ISO format \(e.g. 2024-12-31\) |
+| `limit` | number | No | Maximum number of activity events to return |
+| `next` | string | No | Cursor for fetching the next page of results |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `events` | array | List of company activity events |
+| ↳ `id` | string | Event ID |
+| ↳ `type` | string | Event type |
+| ↳ `description` | string | Event description |
+| ↳ `createdAt` | string | Event creation timestamp |
+| ↳ `actor` | json | Actor who triggered the event \(id, name\) |
+| `totalCount` | number | Total number of activity events returned |
+| `nextCursor` | string | Cursor for fetching the next page of results |
+
+### `rippling_list_custom_fields`
+
+List all custom fields defined in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `limit` | number | No | Maximum number of custom fields to return |
+| `offset` | number | No | Offset for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `customFields` | array | List of custom fields |
+| ↳ `id` | string | Custom field ID |
+| ↳ `type` | string | Field type |
+| ↳ `title` | string | Field title |
+| ↳ `mandatory` | boolean | Whether the field is mandatory |
+| `totalCount` | number | Total number of custom fields returned |
+
+### `rippling_get_current_user`
+
+Get the current authenticated user details
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | User ID |
+| `workEmail` | string | Work email address |
+| `company` | string | Company ID |
+
+### `rippling_list_leave_requests`
+
+List leave requests in Rippling with optional filtering by date range and status
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `startDate` | string | No | Filter by start date \(ISO date string\) |
+| `endDate` | string | No | Filter by end date \(ISO date string\) |
+| `status` | string | No | Filter by status \(e.g. pending, approved, declined\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `leaveRequests` | array | List of leave requests |
+| ↳ `id` | string | Leave request ID |
+| ↳ `requestedBy` | string | Employee ID who requested leave |
+| ↳ `status` | string | Request status \(pending/approved/declined\) |
+| ↳ `startDate` | string | Leave start date |
+| ↳ `endDate` | string | Leave end date |
+| ↳ `reason` | string | Reason for leave |
+| ↳ `leaveType` | string | Type of leave |
+| ↳ `createdAt` | string | When the request was created |
+| `totalCount` | number | Total number of leave requests returned |
+
+### `rippling_process_leave_request`
+
+Approve or decline a leave request in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `leaveRequestId` | string | Yes | The ID of the leave request to process |
+| `action` | string | Yes | Action to take on the leave request \(approve or decline\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Leave request ID |
+| `status` | string | Updated status of the leave request |
+| `requestedBy` | string | Employee ID who requested leave |
+| `startDate` | string | Leave start date |
+| `endDate` | string | Leave end date |
+
+### `rippling_list_leave_balances`
+
+List leave balances for all employees in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `limit` | number | No | Maximum number of leave balances to return |
+| `offset` | number | No | Offset for pagination |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `leaveBalances` | array | List of employee leave balances |
+| ↳ `employeeId` | string | Employee ID |
+| ↳ `balances` | array | Leave balance entries |
+| ↳ `leaveType` | string | Type of leave |
+| ↳ `minutesRemaining` | number | Minutes of leave remaining |
+| `totalCount` | number | Total number of leave balances returned |
+
+### `rippling_get_leave_balance`
+
+Get leave balance for a specific employee by role ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `roleId` | string | Yes | The employee/role ID to retrieve leave balance for |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `employeeId` | string | Employee ID |
+| `balances` | array | Leave balance entries |
+| ↳ `leaveType` | string | Type of leave |
+| ↳ `minutesRemaining` | number | Minutes of leave remaining |
+
+### `rippling_list_leave_types`
+
+List company leave types configured in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `managedBy` | string | No | Filter leave types by manager |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `leaveTypes` | array | List of company leave types |
+| ↳ `id` | string | Leave type ID |
+| ↳ `name` | string | Leave type name |
+| ↳ `managedBy` | string | Manager of this leave type |
+| `totalCount` | number | Total number of leave types returned |
+
+### `rippling_create_group`
+
+Create a new group in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `name` | string | Yes | Name of the group |
+| `spokeId` | string | Yes | Third-party app identifier |
+| `users` | json | No | Array of user ID strings to add to the group |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Group ID |
+| `name` | string | Group name |
+| `spokeId` | string | Third-party app identifier |
+| `users` | array | Array of user IDs in the group |
+| `version` | number | Group version number |
+
+### `rippling_update_group`
+
+Update an existing group in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `groupId` | string | Yes | The ID of the group to update |
+| `name` | string | No | New name for the group |
+| `spokeId` | string | No | Third-party app identifier |
+| `users` | json | No | Array of user ID strings to set for the group |
+| `version` | number | No | Group version number for optimistic concurrency |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Group ID |
+| `name` | string | Group name |
+| `spokeId` | string | Third-party app identifier |
+| `users` | array | Array of user IDs in the group |
+| `version` | number | Group version number |
+
+### `rippling_push_candidate`
+
+Push a candidate to onboarding in Rippling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Rippling API key |
+| `firstName` | string | Yes | Candidate first name |
+| `lastName` | string | Yes | Candidate last name |
+| `email` | string | Yes | Candidate email address |
+| `phone` | string | No | Candidate phone number |
+| `jobTitle` | string | No | Job title for the candidate |
+| `department` | string | No | Department for the candidate |
+| `startDate` | string | No | Start date in ISO 8601 format \(e.g., 2025-01-15\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Candidate ID |
+| `firstName` | string | Candidate first name |
+| `lastName` | string | Candidate last name |
+| `email` | string | Candidate email address |
+| `status` | string | Candidate onboarding status |
+
+
diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts
index ef6684838d5..7580b713c57 100644
--- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts
+++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts
@@ -133,6 +133,7 @@ import {
ReductoIcon,
ResendIcon,
RevenueCatIcon,
+ RipplingIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
@@ -306,6 +307,7 @@ export const blockTypeToIconMap: Record = {
reducto_v2: ReductoIcon,
resend: ResendIcon,
revenuecat: RevenueCatIcon,
+ rippling: RipplingIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json
index 1905e33e881..2b3f9e19a1c 100644
--- a/apps/sim/app/(landing)/integrations/data/integrations.json
+++ b/apps/sim/app/(landing)/integrations/data/integrations.json
@@ -904,7 +904,7 @@
},
{
"name": "Assert Record (Upsert)",
- "description": "Upsert a record in Attio — creates it if no match is found, updates it if a match exists"
+ "description": "Upsert a record in Attio \u2014 creates it if no match is found, updates it if a match exists"
},
{
"name": "List Notes",
@@ -2504,7 +2504,7 @@
"slug": "dspy",
"name": "DSPy",
"description": "Run predictions using self-hosted DSPy programs",
- "longDescription": "Integrate with your self-hosted DSPy programs for LLM-powered predictions. Supports Predict, Chain of Thought, and ReAct agents. DSPy is the framework for programming—not prompting—language models.",
+ "longDescription": "Integrate with your self-hosted DSPy programs for LLM-powered predictions. Supports Predict, Chain of Thought, and ReAct agents. DSPy is the framework for programming\u2014not prompting\u2014language models.",
"bgColor": "#E0E0E0",
"iconName": "DsPyIcon",
"docsUrl": "https://docs.sim.ai/tools/dspy",
@@ -8871,6 +8871,101 @@
"integrationType": "ecommerce",
"tags": ["payments", "subscriptions"]
},
+ {
+ "type": "rippling",
+ "slug": "rippling",
+ "name": "Rippling",
+ "description": "Manage employees, leave, departments, and company data in Rippling",
+ "longDescription": "Integrate Rippling into your workflow. Manage employees, departments, teams, leave requests, work locations, groups, candidates, and company information.",
+ "bgColor": "#FFCC1C",
+ "iconName": "RipplingIcon",
+ "docsUrl": "https://docs.sim.ai/tools/rippling",
+ "operations": [
+ {
+ "name": "List Employees",
+ "description": "List all employees in Rippling with optional pagination"
+ },
+ {
+ "name": "Get Employee",
+ "description": "Get details for a specific employee by ID"
+ },
+ {
+ "name": "List Employees (Including Terminated)",
+ "description": "List all employees in Rippling including terminated employees with optional pagination"
+ },
+ {
+ "name": "List Departments",
+ "description": "List all departments in the Rippling organization"
+ },
+ {
+ "name": "List Teams",
+ "description": "List all teams in Rippling"
+ },
+ {
+ "name": "List Levels",
+ "description": "List all position levels in Rippling"
+ },
+ {
+ "name": "List Work Locations",
+ "description": "List all work locations in Rippling"
+ },
+ {
+ "name": "Get Company",
+ "description": "Get details for the current company in Rippling"
+ },
+ {
+ "name": "Get Company Activity",
+ "description": "Get activity events for the current company in Rippling"
+ },
+ {
+ "name": "List Custom Fields",
+ "description": "List all custom fields defined in Rippling"
+ },
+ {
+ "name": "Get Current User",
+ "description": "Get the current authenticated user details"
+ },
+ {
+ "name": "List Leave Requests",
+ "description": "List leave requests in Rippling with optional filtering by date range and status"
+ },
+ {
+ "name": "Approve/Decline Leave Request",
+ "description": "Approve or decline a leave request in Rippling"
+ },
+ {
+ "name": "List Leave Balances",
+ "description": "List leave balances for all employees in Rippling"
+ },
+ {
+ "name": "Get Leave Balance",
+ "description": "Get leave balance for a specific employee by role ID"
+ },
+ {
+ "name": "List Leave Types",
+ "description": ""
+ },
+ {
+ "name": "Create Group",
+ "description": "Create a new group in Rippling"
+ },
+ {
+ "name": "Update Group",
+ "description": "Update an existing group in Rippling"
+ },
+ {
+ "name": "Push Candidate",
+ "description": "Push a candidate to onboarding in Rippling"
+ }
+ ],
+ "operationCount": 19,
+ "triggers": [],
+ "triggerCount": 0,
+ "authType": "api-key",
+ "category": "tools",
+ "integrationType": "hr",
+ "tags": ["hiring"]
+ },
{
"type": "s3",
"slug": "s3",
diff --git a/apps/sim/blocks/blocks/rippling.ts b/apps/sim/blocks/blocks/rippling.ts
new file mode 100644
index 00000000000..ad491435d00
--- /dev/null
+++ b/apps/sim/blocks/blocks/rippling.ts
@@ -0,0 +1,437 @@
+import { RipplingIcon } from '@/components/icons'
+import type { BlockConfig } from '@/blocks/types'
+import { AuthMode, IntegrationType } from '@/blocks/types'
+
+export const RipplingBlock: BlockConfig = {
+ type: 'rippling',
+ name: 'Rippling',
+ description: 'Manage employees, leave, departments, and company data in Rippling',
+ longDescription:
+ 'Integrate Rippling into your workflow. Manage employees, departments, teams, leave requests, work locations, groups, candidates, and company information.',
+ docsLink: 'https://docs.sim.ai/tools/rippling',
+ category: 'tools',
+ integrationType: IntegrationType.HR,
+ tags: ['hiring'],
+ bgColor: '#FFCC1C',
+ icon: RipplingIcon,
+ authMode: AuthMode.ApiKey,
+
+ subBlocks: [
+ {
+ id: 'operation',
+ title: 'Operation',
+ type: 'dropdown',
+ options: [
+ { label: 'List Employees', id: 'list_employees' },
+ { label: 'Get Employee', id: 'get_employee' },
+ { label: 'List Employees (Including Terminated)', id: 'list_employees_with_terminated' },
+ { label: 'List Departments', id: 'list_departments' },
+ { label: 'List Teams', id: 'list_teams' },
+ { label: 'List Levels', id: 'list_levels' },
+ { label: 'List Work Locations', id: 'list_work_locations' },
+ { label: 'Get Company', id: 'get_company' },
+ { label: 'Get Company Activity', id: 'get_company_activity' },
+ { label: 'List Custom Fields', id: 'list_custom_fields' },
+ { label: 'Get Current User', id: 'get_current_user' },
+ { label: 'List Leave Requests', id: 'list_leave_requests' },
+ { label: 'Approve/Decline Leave Request', id: 'process_leave_request' },
+ { label: 'List Leave Balances', id: 'list_leave_balances' },
+ { label: 'Get Leave Balance', id: 'get_leave_balance' },
+ { label: 'List Leave Types', id: 'list_leave_types' },
+ { label: 'Create Group', id: 'create_group' },
+ { label: 'Update Group', id: 'update_group' },
+ { label: 'Push Candidate', id: 'push_candidate' },
+ ],
+ value: () => 'list_employees',
+ },
+ // Employee ID - for get_employee
+ {
+ id: 'employeeId',
+ title: 'Employee ID',
+ type: 'short-input',
+ placeholder: 'Enter the employee ID',
+ required: { field: 'operation', value: 'get_employee' },
+ condition: { field: 'operation', value: 'get_employee' },
+ },
+ // Leave Request fields
+ {
+ id: 'leaveRequestId',
+ title: 'Leave Request ID',
+ type: 'short-input',
+ placeholder: 'Enter the leave request ID',
+ required: { field: 'operation', value: 'process_leave_request' },
+ condition: { field: 'operation', value: 'process_leave_request' },
+ },
+ {
+ id: 'action',
+ title: 'Action',
+ type: 'dropdown',
+ options: [
+ { label: 'Approve', id: 'approve' },
+ { label: 'Decline', id: 'decline' },
+ ],
+ value: () => 'approve',
+ required: { field: 'operation', value: 'process_leave_request' },
+ condition: { field: 'operation', value: 'process_leave_request' },
+ },
+ // Leave balance - role ID
+ {
+ id: 'roleId',
+ title: 'Employee/Role ID',
+ type: 'short-input',
+ placeholder: 'Enter the employee or role ID',
+ required: { field: 'operation', value: 'get_leave_balance' },
+ condition: { field: 'operation', value: 'get_leave_balance' },
+ },
+ // Group fields
+ {
+ id: 'groupName',
+ title: 'Group Name',
+ type: 'short-input',
+ placeholder: 'Enter group name',
+ required: { field: 'operation', value: 'create_group' },
+ condition: { field: 'operation', value: ['create_group', 'update_group'] },
+ },
+ {
+ id: 'spokeId',
+ title: 'Spoke ID',
+ type: 'short-input',
+ placeholder: 'Third-party app identifier',
+ required: { field: 'operation', value: 'create_group' },
+ condition: { field: 'operation', value: ['create_group', 'update_group'] },
+ },
+ {
+ id: 'groupId',
+ title: 'Group ID',
+ type: 'short-input',
+ placeholder: 'Enter the group ID to update',
+ required: { field: 'operation', value: 'update_group' },
+ condition: { field: 'operation', value: 'update_group' },
+ },
+ {
+ id: 'users',
+ title: 'User IDs',
+ type: 'long-input',
+ placeholder: '["user-id-1", "user-id-2"]',
+ mode: 'advanced',
+ condition: { field: 'operation', value: ['create_group', 'update_group'] },
+ },
+ {
+ id: 'groupVersion',
+ title: 'Version',
+ type: 'short-input',
+ placeholder: 'Group version number',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'update_group' },
+ },
+ // Candidate fields
+ {
+ id: 'firstName',
+ title: 'First Name',
+ type: 'short-input',
+ placeholder: 'Candidate first name',
+ required: { field: 'operation', value: 'push_candidate' },
+ condition: { field: 'operation', value: 'push_candidate' },
+ },
+ {
+ id: 'lastName',
+ title: 'Last Name',
+ type: 'short-input',
+ placeholder: 'Candidate last name',
+ required: { field: 'operation', value: 'push_candidate' },
+ condition: { field: 'operation', value: 'push_candidate' },
+ },
+ {
+ id: 'email',
+ title: 'Email',
+ type: 'short-input',
+ placeholder: 'Candidate email address',
+ required: { field: 'operation', value: 'push_candidate' },
+ condition: { field: 'operation', value: 'push_candidate' },
+ },
+ {
+ id: 'candidatePhone',
+ title: 'Phone',
+ type: 'short-input',
+ placeholder: 'Candidate phone number',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'push_candidate' },
+ },
+ {
+ id: 'jobTitle',
+ title: 'Job Title',
+ type: 'short-input',
+ placeholder: 'Job title for the candidate',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'push_candidate' },
+ },
+ {
+ id: 'candidateDepartment',
+ title: 'Department',
+ type: 'short-input',
+ placeholder: 'Department for the candidate',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'push_candidate' },
+ },
+ {
+ id: 'candidateStartDate',
+ title: 'Start Date',
+ type: 'short-input',
+ placeholder: 'YYYY-MM-DD',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'push_candidate' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a date in YYYY-MM-DD format for a candidate start date. Return ONLY the date string - no explanations, no extra text.',
+ generationType: 'timestamp',
+ },
+ },
+ // Date filters for leave requests and company activity
+ {
+ id: 'startDate',
+ title: 'Start Date',
+ type: 'short-input',
+ placeholder: 'YYYY-MM-DD',
+ mode: 'advanced',
+ condition: { field: 'operation', value: ['list_leave_requests', 'get_company_activity'] },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a date in YYYY-MM-DD format for filtering by start date. Return ONLY the date string - no explanations, no extra text.',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'endDate',
+ title: 'End Date',
+ type: 'short-input',
+ placeholder: 'YYYY-MM-DD',
+ mode: 'advanced',
+ condition: { field: 'operation', value: ['list_leave_requests', 'get_company_activity'] },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a date in YYYY-MM-DD format for filtering by end date. Return ONLY the date string - no explanations, no extra text.',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'status',
+ title: 'Status Filter',
+ type: 'short-input',
+ placeholder: 'e.g., pending, approved, declined',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'list_leave_requests' },
+ },
+ {
+ id: 'managedBy',
+ title: 'Managed By',
+ type: 'short-input',
+ placeholder: 'Filter by manager',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'list_leave_types' },
+ },
+ // Pagination - shared across list operations (offset-based)
+ {
+ id: 'limit',
+ title: 'Limit',
+ type: 'short-input',
+ placeholder: '100',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: [
+ 'list_employees',
+ 'list_employees_with_terminated',
+ 'list_departments',
+ 'list_teams',
+ 'list_levels',
+ 'list_work_locations',
+ 'list_custom_fields',
+ 'list_leave_balances',
+ 'get_company_activity',
+ ],
+ },
+ },
+ {
+ id: 'offset',
+ title: 'Offset',
+ type: 'short-input',
+ placeholder: '0',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: [
+ 'list_employees',
+ 'list_employees_with_terminated',
+ 'list_departments',
+ 'list_teams',
+ 'list_levels',
+ 'list_work_locations',
+ 'list_custom_fields',
+ 'list_leave_balances',
+ ],
+ },
+ },
+ // Cursor-based pagination for company activity
+ {
+ id: 'nextCursor',
+ title: 'Next Page Cursor',
+ type: 'short-input',
+ placeholder: 'Cursor from previous response',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'get_company_activity' },
+ },
+ {
+ id: 'apiKey',
+ title: 'API Key',
+ type: 'short-input',
+ placeholder: 'Enter your Rippling API key',
+ required: true,
+ password: true,
+ },
+ ],
+
+ tools: {
+ access: [
+ 'rippling_list_employees',
+ 'rippling_get_employee',
+ 'rippling_list_employees_with_terminated',
+ 'rippling_list_departments',
+ 'rippling_list_teams',
+ 'rippling_list_levels',
+ 'rippling_list_work_locations',
+ 'rippling_get_company',
+ 'rippling_get_company_activity',
+ 'rippling_list_custom_fields',
+ 'rippling_get_current_user',
+ 'rippling_list_leave_requests',
+ 'rippling_process_leave_request',
+ 'rippling_list_leave_balances',
+ 'rippling_get_leave_balance',
+ 'rippling_list_leave_types',
+ 'rippling_create_group',
+ 'rippling_update_group',
+ 'rippling_push_candidate',
+ ],
+ config: {
+ tool: (params) => `rippling_${params.operation}`,
+ params: (params) => {
+ const mapped: Record = {
+ apiKey: params.apiKey,
+ }
+
+ if (params.employeeId) mapped.employeeId = params.employeeId
+ if (params.leaveRequestId) mapped.leaveRequestId = params.leaveRequestId
+ if (params.action) mapped.action = params.action
+ if (params.roleId) mapped.roleId = params.roleId
+ if (params.spokeId) mapped.spokeId = params.spokeId
+ if (params.groupId) mapped.groupId = params.groupId
+ if (params.firstName) mapped.firstName = params.firstName
+ if (params.lastName) mapped.lastName = params.lastName
+ if (params.email) mapped.email = params.email
+ if (params.jobTitle) mapped.jobTitle = params.jobTitle
+ if (params.startDate && params.operation !== 'push_candidate')
+ mapped.startDate = params.startDate
+ if (params.endDate && params.operation !== 'push_candidate') mapped.endDate = params.endDate
+ if (params.status) mapped.status = params.status
+ if (params.managedBy) mapped.managedBy = params.managedBy
+
+ if (params.limit != null && params.limit !== '') mapped.limit = Number(params.limit)
+ if (params.offset != null && params.offset !== '') mapped.offset = Number(params.offset)
+ if (params.groupVersion != null && params.groupVersion !== '')
+ mapped.version = Number(params.groupVersion)
+ if (params.groupName) mapped.name = params.groupName
+ if (params.candidatePhone) mapped.phone = params.candidatePhone
+ if (params.candidateDepartment) mapped.department = params.candidateDepartment
+ if (params.candidateStartDate && params.operation === 'push_candidate')
+ mapped.startDate = params.candidateStartDate
+ if (params.nextCursor) mapped.next = params.nextCursor
+
+ if (params.users) {
+ try {
+ mapped.users =
+ typeof params.users === 'string' ? JSON.parse(params.users) : params.users
+ } catch {
+ throw new Error(
+ 'Invalid JSON for "User IDs" field. Expected an array like ["user-id-1", "user-id-2"].'
+ )
+ }
+ }
+
+ return mapped
+ },
+ },
+ },
+
+ inputs: {
+ operation: { type: 'string', description: 'Operation to perform' },
+ employeeId: { type: 'string', description: 'Employee ID' },
+ leaveRequestId: { type: 'string', description: 'Leave request ID' },
+ action: { type: 'string', description: 'Action to take (approve or decline)' },
+ roleId: { type: 'string', description: 'Employee/role ID for leave balance' },
+ groupName: { type: 'string', description: 'Group name' },
+ spokeId: { type: 'string', description: 'Third-party app identifier' },
+ groupId: { type: 'string', description: 'Group ID to update' },
+ users: { type: 'json', description: 'Array of user IDs' },
+ firstName: { type: 'string', description: 'Candidate first name' },
+ lastName: { type: 'string', description: 'Candidate last name' },
+ email: { type: 'string', description: 'Candidate email' },
+ candidatePhone: { type: 'string', description: 'Candidate phone number' },
+ jobTitle: { type: 'string', description: 'Job title' },
+ candidateDepartment: { type: 'string', description: 'Department' },
+ candidateStartDate: { type: 'string', description: 'Start date (ISO format)' },
+ startDate: { type: 'string', description: 'Filter start date' },
+ endDate: { type: 'string', description: 'Filter end date' },
+ status: { type: 'string', description: 'Leave request status filter' },
+ managedBy: { type: 'string', description: 'Filter leave types by manager' },
+ limit: { type: 'number', description: 'Maximum number of results' },
+ offset: { type: 'number', description: 'Pagination offset' },
+ nextCursor: { type: 'string', description: 'Cursor for next page (company activity)' },
+ apiKey: { type: 'string', description: 'Rippling API key' },
+ },
+
+ outputs: {
+ employees: {
+ type: 'array',
+ description:
+ 'List of employees (id, firstName, lastName, workEmail, roleState, department, title)',
+ },
+ departments: { type: 'array', description: 'List of departments (id, name, parent)' },
+ teams: { type: 'array', description: 'List of teams (id, name, parent)' },
+ levels: { type: 'array', description: 'List of position levels (id, name, parent)' },
+ workLocations: {
+ type: 'array',
+ description: 'List of work locations (id, nickname, street, city, state, zip, country)',
+ },
+ customFields: {
+ type: 'array',
+ description: 'List of custom fields (id, type, title, mandatory)',
+ },
+ events: {
+ type: 'array',
+ description: 'List of company activity events (id, type, description, createdAt, actor)',
+ },
+ leaveRequests: {
+ type: 'array',
+ description: 'List of leave requests (id, requestedBy, status, startDate, endDate)',
+ },
+ leaveBalances: { type: 'array', description: 'List of leave balances (employeeId, balances)' },
+ leaveTypes: { type: 'array', description: 'List of leave types (id, name, managedBy)' },
+ totalCount: { type: 'number', description: 'Total number of items returned' },
+ id: { type: 'string', description: 'Resource ID' },
+ name: { type: 'string', description: 'Resource name' },
+ workEmail: { type: 'string', description: 'Work email address' },
+ company: { type: 'string', description: 'Company ID' },
+ status: { type: 'string', description: 'Status of the resource' },
+ users: { type: 'array', description: 'Array of user IDs in a group' },
+ version: { type: 'number', description: 'Group version number' },
+ address: { type: 'json', description: 'Company address (street, city, state, zip, country)' },
+ email: { type: 'string', description: 'Email address' },
+ phone: { type: 'string', description: 'Phone number' },
+ balances: { type: 'array', description: 'Leave balance entries (leaveType, minutesRemaining)' },
+ employeeId: { type: 'string', description: 'Employee ID' },
+ nextCursor: { type: 'string', description: 'Cursor for fetching the next page of results' },
+ },
+}
diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts
index a857038e021..15363a8ad53 100644
--- a/apps/sim/blocks/registry.ts
+++ b/apps/sim/blocks/registry.ts
@@ -145,6 +145,7 @@ import { ReductoBlock, ReductoV2Block } from '@/blocks/blocks/reducto'
import { ResendBlock } from '@/blocks/blocks/resend'
import { ResponseBlock } from '@/blocks/blocks/response'
import { RevenueCatBlock } from '@/blocks/blocks/revenuecat'
+import { RipplingBlock } from '@/blocks/blocks/rippling'
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
import { RssBlock } from '@/blocks/blocks/rss'
import { S3Block } from '@/blocks/blocks/s3'
@@ -367,6 +368,7 @@ export const registry: Record = {
resend: ResendBlock,
response: ResponseBlock,
revenuecat: RevenueCatBlock,
+ rippling: RipplingBlock,
router: RouterBlock,
router_v2: RouterV2Block,
rss: RssBlock,
diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx
index a7ee06c5e8d..0cf5773ca48 100644
--- a/apps/sim/components/icons.tsx
+++ b/apps/sim/components/icons.tsx
@@ -6270,6 +6270,17 @@ export function RedisIcon(props: SVGProps) {
)
}
+export function RipplingIcon(props: SVGProps) {
+ return (
+
+
+
+ )
+}
+
export function HexIcon(props: SVGProps) {
return (
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index be98b26b3de..640cce5c39e 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -1814,6 +1814,27 @@ import {
revenuecatRevokeGoogleSubscriptionTool,
revenuecatUpdateSubscriberAttributesTool,
} from '@/tools/revenuecat'
+import {
+ ripplingCreateGroupTool,
+ ripplingGetCompanyActivityTool,
+ ripplingGetCompanyTool,
+ ripplingGetCurrentUserTool,
+ ripplingGetEmployeeTool,
+ ripplingGetLeaveBalanceTool,
+ ripplingListCustomFieldsTool,
+ ripplingListDepartmentsTool,
+ ripplingListEmployeesTool,
+ ripplingListEmployeesWithTerminatedTool,
+ ripplingListLeaveBalancesTool,
+ ripplingListLeaveRequestsTool,
+ ripplingListLeaveTypesTool,
+ ripplingListLevelsTool,
+ ripplingListTeamsTool,
+ ripplingListWorkLocationsTool,
+ ripplingProcessLeaveRequestTool,
+ ripplingPushCandidateTool,
+ ripplingUpdateGroupTool,
+} from '@/tools/rippling'
import {
s3CopyObjectTool,
s3DeleteObjectTool,
@@ -3457,6 +3478,25 @@ export const tools: Record = {
revenuecat_defer_google_subscription: revenuecatDeferGoogleSubscriptionTool,
revenuecat_refund_google_subscription: revenuecatRefundGoogleSubscriptionTool,
revenuecat_revoke_google_subscription: revenuecatRevokeGoogleSubscriptionTool,
+ rippling_create_group: ripplingCreateGroupTool,
+ rippling_get_company: ripplingGetCompanyTool,
+ rippling_get_company_activity: ripplingGetCompanyActivityTool,
+ rippling_get_current_user: ripplingGetCurrentUserTool,
+ rippling_get_employee: ripplingGetEmployeeTool,
+ rippling_get_leave_balance: ripplingGetLeaveBalanceTool,
+ rippling_list_custom_fields: ripplingListCustomFieldsTool,
+ rippling_list_departments: ripplingListDepartmentsTool,
+ rippling_list_employees: ripplingListEmployeesTool,
+ rippling_list_employees_with_terminated: ripplingListEmployeesWithTerminatedTool,
+ rippling_list_leave_balances: ripplingListLeaveBalancesTool,
+ rippling_list_leave_requests: ripplingListLeaveRequestsTool,
+ rippling_list_leave_types: ripplingListLeaveTypesTool,
+ rippling_list_levels: ripplingListLevelsTool,
+ rippling_list_teams: ripplingListTeamsTool,
+ rippling_list_work_locations: ripplingListWorkLocationsTool,
+ rippling_process_leave_request: ripplingProcessLeaveRequestTool,
+ rippling_push_candidate: ripplingPushCandidateTool,
+ rippling_update_group: ripplingUpdateGroupTool,
google_drive_copy: googleDriveCopyTool,
google_drive_create_folder: googleDriveCreateFolderTool,
google_drive_delete: googleDriveDeleteTool,
diff --git a/apps/sim/tools/rippling/create_group.ts b/apps/sim/tools/rippling/create_group.ts
new file mode 100644
index 00000000000..d4987eaacfe
--- /dev/null
+++ b/apps/sim/tools/rippling/create_group.ts
@@ -0,0 +1,91 @@
+import type { RipplingCreateGroupParams, RipplingCreateGroupResponse } from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingCreateGroupTool: ToolConfig<
+ RipplingCreateGroupParams,
+ RipplingCreateGroupResponse
+> = {
+ id: 'rippling_create_group',
+ name: 'Rippling Create Group',
+ description: 'Create a new group in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ name: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Name of the group',
+ },
+ spokeId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Third-party app identifier',
+ },
+ users: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Array of user ID strings to add to the group',
+ },
+ },
+
+ request: {
+ url: 'https://api.rippling.com/platform/api/groups',
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => {
+ const body: Record = {
+ name: params.name,
+ spokeId: params.spokeId,
+ }
+ if (params.users !== undefined) {
+ body.users = params.users
+ }
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ id: (data.id as string) ?? '',
+ name: (data.name as string) ?? null,
+ spokeId: (data.spokeId as string) ?? null,
+ users: (data.users as string[]) ?? [],
+ version: (data.version as number) ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Group ID' },
+ name: { type: 'string', description: 'Group name' },
+ spokeId: { type: 'string', description: 'Third-party app identifier' },
+ users: {
+ type: 'array',
+ description: 'Array of user IDs in the group',
+ items: { type: 'string' },
+ },
+ version: { type: 'number', description: 'Group version number' },
+ },
+}
diff --git a/apps/sim/tools/rippling/get_company.ts b/apps/sim/tools/rippling/get_company.ts
new file mode 100644
index 00000000000..fe3c0973f6b
--- /dev/null
+++ b/apps/sim/tools/rippling/get_company.ts
@@ -0,0 +1,75 @@
+import type { RipplingGetCompanyParams, RipplingGetCompanyResponse } from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingGetCompanyTool: ToolConfig<
+ RipplingGetCompanyParams,
+ RipplingGetCompanyResponse
+> = {
+ id: 'rippling_get_company',
+ name: 'Rippling Get Company',
+ description: 'Get details for the current company in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ },
+
+ request: {
+ url: 'https://api.rippling.com/platform/api/companies/current',
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ const address = data.address ?? {}
+
+ return {
+ success: true,
+ output: {
+ id: data.id ?? '',
+ name: data.name ?? null,
+ address: {
+ street: address.street ?? null,
+ city: address.city ?? null,
+ state: address.state ?? null,
+ zip: address.zip ?? null,
+ country: address.country ?? null,
+ },
+ email: data.email ?? null,
+ phone: data.phone ?? null,
+ workLocations: data.workLocations ?? [],
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Company ID' },
+ name: { type: 'string', description: 'Company name', optional: true },
+ address: {
+ type: 'json',
+ description: 'Company address with street, city, state, zip, country',
+ },
+ email: { type: 'string', description: 'Company email address', optional: true },
+ phone: { type: 'string', description: 'Company phone number', optional: true },
+ workLocations: {
+ type: 'array',
+ description: 'List of work location IDs',
+ items: { type: 'string' },
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/get_company_activity.ts b/apps/sim/tools/rippling/get_company_activity.ts
new file mode 100644
index 00000000000..41e27591407
--- /dev/null
+++ b/apps/sim/tools/rippling/get_company_activity.ts
@@ -0,0 +1,125 @@
+import type {
+ RipplingGetCompanyActivityParams,
+ RipplingGetCompanyActivityResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingGetCompanyActivityTool: ToolConfig<
+ RipplingGetCompanyActivityParams,
+ RipplingGetCompanyActivityResponse
+> = {
+ id: 'rippling_get_company_activity',
+ name: 'Rippling Get Company Activity',
+ description: 'Get activity events for the current company in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ startDate: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Start date filter in ISO format (e.g. 2024-01-01)',
+ },
+ endDate: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'End date filter in ISO format (e.g. 2024-12-31)',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of activity events to return',
+ },
+ next: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Cursor for fetching the next page of results',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.startDate) query.set('startDate', params.startDate)
+ if (params.endDate) query.set('endDate', params.endDate)
+ if (params.limit != null) query.set('limit', String(params.limit))
+ if (params.next) query.set('next', params.next)
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/company_activity${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+ const nextCursor = Array.isArray(data) ? null : ((data.next as string) ?? null)
+
+ const events = results.map((event: Record) => {
+ const actor = (event.actor as Record) ?? {}
+ return {
+ id: (event.id as string) ?? '',
+ type: (event.type as string) ?? null,
+ description: (event.description as string) ?? null,
+ createdAt: (event.createdAt as string) ?? null,
+ actor: {
+ id: (actor.id as string) ?? null,
+ name: (actor.name as string) ?? null,
+ },
+ }
+ })
+
+ return {
+ success: true,
+ output: {
+ events,
+ totalCount: events.length,
+ nextCursor,
+ },
+ }
+ },
+
+ outputs: {
+ events: {
+ type: 'array',
+ description: 'List of company activity events',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Event ID' },
+ type: { type: 'string', description: 'Event type' },
+ description: { type: 'string', description: 'Event description' },
+ createdAt: { type: 'string', description: 'Event creation timestamp' },
+ actor: { type: 'json', description: 'Actor who triggered the event (id, name)' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Number of activity events returned on this page',
+ },
+ nextCursor: {
+ type: 'string',
+ description: 'Cursor for fetching the next page of results',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/get_current_user.ts b/apps/sim/tools/rippling/get_current_user.ts
new file mode 100644
index 00000000000..3ee6ebb14c4
--- /dev/null
+++ b/apps/sim/tools/rippling/get_current_user.ts
@@ -0,0 +1,57 @@
+import type {
+ RipplingGetCurrentUserParams,
+ RipplingGetCurrentUserResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingGetCurrentUserTool: ToolConfig<
+ RipplingGetCurrentUserParams,
+ RipplingGetCurrentUserResponse
+> = {
+ id: 'rippling_get_current_user',
+ name: 'Rippling Get Current User',
+ description: 'Get the current authenticated user details',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ },
+
+ request: {
+ url: 'https://api.rippling.com/platform/api/me',
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ id: data.id ?? '',
+ workEmail: data.workEmail ?? null,
+ company: data.company ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'User ID' },
+ workEmail: { type: 'string', description: 'Work email address', optional: true },
+ company: { type: 'string', description: 'Company ID', optional: true },
+ },
+}
diff --git a/apps/sim/tools/rippling/get_employee.ts b/apps/sim/tools/rippling/get_employee.ts
new file mode 100644
index 00000000000..a9e0c569110
--- /dev/null
+++ b/apps/sim/tools/rippling/get_employee.ts
@@ -0,0 +1,79 @@
+import type { RipplingGetEmployeeParams, RipplingGetEmployeeResponse } from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingGetEmployeeTool: ToolConfig<
+ RipplingGetEmployeeParams,
+ RipplingGetEmployeeResponse
+> = {
+ id: 'rippling_get_employee',
+ name: 'Rippling Get Employee',
+ description: 'Get details for a specific employee by ID',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ employeeId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the employee to retrieve',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.rippling.com/platform/api/employees/${encodeURIComponent(params.employeeId.trim())}`,
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const emp = await response.json()
+
+ return {
+ success: true,
+ output: {
+ id: emp.id ?? '',
+ firstName: emp.firstName ?? null,
+ lastName: emp.lastName ?? null,
+ workEmail: emp.workEmail ?? null,
+ personalEmail: emp.personalEmail ?? null,
+ roleState: emp.roleState ?? null,
+ department: emp.department ?? null,
+ title: emp.title ?? null,
+ startDate: emp.startDate ?? null,
+ endDate: emp.endDate ?? null,
+ manager: emp.manager ?? null,
+ phone: emp.phone ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Employee ID' },
+ firstName: { type: 'string', description: 'First name', optional: true },
+ lastName: { type: 'string', description: 'Last name', optional: true },
+ workEmail: { type: 'string', description: 'Work email address', optional: true },
+ personalEmail: { type: 'string', description: 'Personal email address', optional: true },
+ roleState: { type: 'string', description: 'Employment status', optional: true },
+ department: { type: 'string', description: 'Department name or ID', optional: true },
+ title: { type: 'string', description: 'Job title', optional: true },
+ startDate: { type: 'string', description: 'Employment start date', optional: true },
+ endDate: { type: 'string', description: 'Employment end date', optional: true },
+ manager: { type: 'string', description: 'Manager ID or name', optional: true },
+ phone: { type: 'string', description: 'Phone number', optional: true },
+ },
+}
diff --git a/apps/sim/tools/rippling/get_leave_balance.ts b/apps/sim/tools/rippling/get_leave_balance.ts
new file mode 100644
index 00000000000..d8f8b303221
--- /dev/null
+++ b/apps/sim/tools/rippling/get_leave_balance.ts
@@ -0,0 +1,77 @@
+import type {
+ RipplingGetLeaveBalanceParams,
+ RipplingGetLeaveBalanceResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingGetLeaveBalanceTool: ToolConfig<
+ RipplingGetLeaveBalanceParams,
+ RipplingGetLeaveBalanceResponse
+> = {
+ id: 'rippling_get_leave_balance',
+ name: 'Rippling Get Leave Balance',
+ description: 'Get leave balance for a specific employee by role ID',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ roleId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The employee/role ID to retrieve leave balance for',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.rippling.com/platform/api/leave_balances/${encodeURIComponent(params.roleId.trim())}`,
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ employeeId: data.employeeId ?? '',
+ balances: (Array.isArray(data.balances) ? data.balances : []).map(
+ (b: Record) => ({
+ leaveType: (b.leaveType as string) ?? '',
+ minutesRemaining: (b.minutesRemaining as number) ?? 0,
+ })
+ ),
+ },
+ }
+ },
+
+ outputs: {
+ employeeId: { type: 'string', description: 'Employee ID' },
+ balances: {
+ type: 'array',
+ description: 'Leave balance entries',
+ items: {
+ type: 'json',
+ properties: {
+ leaveType: { type: 'string', description: 'Type of leave' },
+ minutesRemaining: { type: 'number', description: 'Minutes of leave remaining' },
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/index.ts b/apps/sim/tools/rippling/index.ts
new file mode 100644
index 00000000000..69c3a7f5c32
--- /dev/null
+++ b/apps/sim/tools/rippling/index.ts
@@ -0,0 +1,20 @@
+export { ripplingCreateGroupTool } from '@/tools/rippling/create_group'
+export { ripplingGetCompanyTool } from '@/tools/rippling/get_company'
+export { ripplingGetCompanyActivityTool } from '@/tools/rippling/get_company_activity'
+export { ripplingGetCurrentUserTool } from '@/tools/rippling/get_current_user'
+export { ripplingGetEmployeeTool } from '@/tools/rippling/get_employee'
+export { ripplingGetLeaveBalanceTool } from '@/tools/rippling/get_leave_balance'
+export { ripplingListCustomFieldsTool } from '@/tools/rippling/list_custom_fields'
+export { ripplingListDepartmentsTool } from '@/tools/rippling/list_departments'
+export { ripplingListEmployeesTool } from '@/tools/rippling/list_employees'
+export { ripplingListEmployeesWithTerminatedTool } from '@/tools/rippling/list_employees_with_terminated'
+export { ripplingListLeaveBalancesTool } from '@/tools/rippling/list_leave_balances'
+export { ripplingListLeaveRequestsTool } from '@/tools/rippling/list_leave_requests'
+export { ripplingListLeaveTypesTool } from '@/tools/rippling/list_leave_types'
+export { ripplingListLevelsTool } from '@/tools/rippling/list_levels'
+export { ripplingListTeamsTool } from '@/tools/rippling/list_teams'
+export { ripplingListWorkLocationsTool } from '@/tools/rippling/list_work_locations'
+export { ripplingProcessLeaveRequestTool } from '@/tools/rippling/process_leave_request'
+export { ripplingPushCandidateTool } from '@/tools/rippling/push_candidate'
+export * from '@/tools/rippling/types'
+export { ripplingUpdateGroupTool } from '@/tools/rippling/update_group'
diff --git a/apps/sim/tools/rippling/list_custom_fields.ts b/apps/sim/tools/rippling/list_custom_fields.ts
new file mode 100644
index 00000000000..d24204f34ff
--- /dev/null
+++ b/apps/sim/tools/rippling/list_custom_fields.ts
@@ -0,0 +1,96 @@
+import type {
+ RipplingListCustomFieldsParams,
+ RipplingListCustomFieldsResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListCustomFieldsTool: ToolConfig<
+ RipplingListCustomFieldsParams,
+ RipplingListCustomFieldsResponse
+> = {
+ id: 'rippling_list_custom_fields',
+ name: 'Rippling List Custom Fields',
+ description: 'List all custom fields defined in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of custom fields to return',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Offset for pagination',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.limit != null) query.set('limit', String(params.limit))
+ if (params.offset != null) query.set('offset', String(params.offset))
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/custom_fields${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const customFields = results.map((field: Record) => ({
+ id: (field.id as string) ?? '',
+ type: (field.type as string) ?? null,
+ title: (field.title as string) ?? null,
+ mandatory: Boolean(field.mandatory),
+ }))
+
+ return {
+ success: true,
+ output: {
+ customFields,
+ totalCount: customFields.length,
+ },
+ }
+ },
+
+ outputs: {
+ customFields: {
+ type: 'array',
+ description: 'List of custom fields',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Custom field ID' },
+ type: { type: 'string', description: 'Field type' },
+ title: { type: 'string', description: 'Field title' },
+ mandatory: { type: 'boolean', description: 'Whether the field is mandatory' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Number of custom fields returned on this page',
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/list_departments.ts b/apps/sim/tools/rippling/list_departments.ts
new file mode 100644
index 00000000000..830432af6b1
--- /dev/null
+++ b/apps/sim/tools/rippling/list_departments.ts
@@ -0,0 +1,94 @@
+import type {
+ RipplingListDepartmentsParams,
+ RipplingListDepartmentsResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListDepartmentsTool: ToolConfig<
+ RipplingListDepartmentsParams,
+ RipplingListDepartmentsResponse
+> = {
+ id: 'rippling_list_departments',
+ name: 'Rippling List Departments',
+ description: 'List all departments in the Rippling organization',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of departments to return',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Offset for pagination',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.limit != null) query.set('limit', String(params.limit))
+ if (params.offset != null) query.set('offset', String(params.offset))
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/departments${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const departments = results.map((dept: Record) => ({
+ id: (dept.id as string) ?? '',
+ name: (dept.name as string) ?? null,
+ parent: (dept.parent as string) ?? null,
+ }))
+
+ return {
+ success: true,
+ output: {
+ departments,
+ totalCount: departments.length,
+ },
+ }
+ },
+
+ outputs: {
+ departments: {
+ type: 'array',
+ description: 'List of departments',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Department ID' },
+ name: { type: 'string', description: 'Department name' },
+ parent: { type: 'string', description: 'Parent department ID' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Number of departments returned on this page',
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/list_employees.ts b/apps/sim/tools/rippling/list_employees.ts
new file mode 100644
index 00000000000..bf2782e42b6
--- /dev/null
+++ b/apps/sim/tools/rippling/list_employees.ts
@@ -0,0 +1,112 @@
+import type {
+ RipplingListEmployeesParams,
+ RipplingListEmployeesResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListEmployeesTool: ToolConfig<
+ RipplingListEmployeesParams,
+ RipplingListEmployeesResponse
+> = {
+ id: 'rippling_list_employees',
+ name: 'Rippling List Employees',
+ description: 'List all employees in Rippling with optional pagination',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of employees to return (default 100, max 100)',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Offset for pagination',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.limit != null) query.set('limit', String(params.limit))
+ if (params.offset != null) query.set('offset', String(params.offset))
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/employees${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const employees = results.map((emp: Record) => ({
+ id: (emp.id as string) ?? '',
+ firstName: (emp.firstName as string) ?? null,
+ lastName: (emp.lastName as string) ?? null,
+ workEmail: (emp.workEmail as string) ?? null,
+ personalEmail: (emp.personalEmail as string) ?? null,
+ roleState: (emp.roleState as string) ?? null,
+ department: (emp.department as string) ?? null,
+ title: (emp.title as string) ?? null,
+ startDate: (emp.startDate as string) ?? null,
+ endDate: (emp.endDate as string) ?? null,
+ manager: (emp.manager as string) ?? null,
+ phone: (emp.phone as string) ?? null,
+ }))
+
+ return {
+ success: true,
+ output: {
+ employees,
+ totalCount: employees.length,
+ },
+ }
+ },
+
+ outputs: {
+ employees: {
+ type: 'array',
+ description: 'List of employees',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Employee ID' },
+ firstName: { type: 'string', description: 'First name' },
+ lastName: { type: 'string', description: 'Last name' },
+ workEmail: { type: 'string', description: 'Work email address' },
+ personalEmail: { type: 'string', description: 'Personal email address' },
+ roleState: { type: 'string', description: 'Employment status' },
+ department: { type: 'string', description: 'Department name or ID' },
+ title: { type: 'string', description: 'Job title' },
+ startDate: { type: 'string', description: 'Employment start date' },
+ endDate: { type: 'string', description: 'Employment end date' },
+ manager: { type: 'string', description: 'Manager ID or name' },
+ phone: { type: 'string', description: 'Phone number' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Number of employees returned on this page',
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/list_employees_with_terminated.ts b/apps/sim/tools/rippling/list_employees_with_terminated.ts
new file mode 100644
index 00000000000..86924bd9440
--- /dev/null
+++ b/apps/sim/tools/rippling/list_employees_with_terminated.ts
@@ -0,0 +1,113 @@
+import type {
+ RipplingListEmployeesWithTerminatedParams,
+ RipplingListEmployeesWithTerminatedResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListEmployeesWithTerminatedTool: ToolConfig<
+ RipplingListEmployeesWithTerminatedParams,
+ RipplingListEmployeesWithTerminatedResponse
+> = {
+ id: 'rippling_list_employees_with_terminated',
+ name: 'Rippling List Employees Including Terminated',
+ description:
+ 'List all employees in Rippling including terminated employees with optional pagination',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of employees to return (default 100, max 100)',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Offset for pagination',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.limit != null) query.set('limit', String(params.limit))
+ if (params.offset != null) query.set('offset', String(params.offset))
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/employees/include_terminated${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const employees = results.map((emp: Record) => ({
+ id: (emp.id as string) ?? '',
+ firstName: (emp.firstName as string) ?? null,
+ lastName: (emp.lastName as string) ?? null,
+ workEmail: (emp.workEmail as string) ?? null,
+ personalEmail: (emp.personalEmail as string) ?? null,
+ roleState: (emp.roleState as string) ?? null,
+ department: (emp.department as string) ?? null,
+ title: (emp.title as string) ?? null,
+ startDate: (emp.startDate as string) ?? null,
+ endDate: (emp.endDate as string) ?? null,
+ manager: (emp.manager as string) ?? null,
+ phone: (emp.phone as string) ?? null,
+ }))
+
+ return {
+ success: true,
+ output: {
+ employees,
+ totalCount: employees.length,
+ },
+ }
+ },
+
+ outputs: {
+ employees: {
+ type: 'array',
+ description: 'List of employees including terminated',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Employee ID' },
+ firstName: { type: 'string', description: 'First name' },
+ lastName: { type: 'string', description: 'Last name' },
+ workEmail: { type: 'string', description: 'Work email address' },
+ personalEmail: { type: 'string', description: 'Personal email address' },
+ roleState: { type: 'string', description: 'Employment status' },
+ department: { type: 'string', description: 'Department name or ID' },
+ title: { type: 'string', description: 'Job title' },
+ startDate: { type: 'string', description: 'Employment start date' },
+ endDate: { type: 'string', description: 'Employment end date' },
+ manager: { type: 'string', description: 'Manager ID or name' },
+ phone: { type: 'string', description: 'Phone number' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Number of employees returned on this page',
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/list_leave_balances.ts b/apps/sim/tools/rippling/list_leave_balances.ts
new file mode 100644
index 00000000000..ff0e4cc23a3
--- /dev/null
+++ b/apps/sim/tools/rippling/list_leave_balances.ts
@@ -0,0 +1,107 @@
+import type {
+ RipplingListLeaveBalancesParams,
+ RipplingListLeaveBalancesResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListLeaveBalancesTool: ToolConfig<
+ RipplingListLeaveBalancesParams,
+ RipplingListLeaveBalancesResponse
+> = {
+ id: 'rippling_list_leave_balances',
+ name: 'Rippling List Leave Balances',
+ description: 'List leave balances for all employees in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of leave balances to return',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Offset for pagination',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.limit != null) query.set('limit', String(params.limit))
+ if (params.offset != null) query.set('offset', String(params.offset))
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/leave_balances${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const leaveBalances = results.map((bal: Record) => ({
+ employeeId: (bal.employeeId as string) ?? '',
+ balances: (Array.isArray(bal.balances) ? bal.balances : []).map(
+ (b: Record) => ({
+ leaveType: (b.leaveType as string) ?? '',
+ minutesRemaining: (b.minutesRemaining as number) ?? 0,
+ })
+ ),
+ }))
+
+ return {
+ success: true,
+ output: {
+ leaveBalances,
+ totalCount: leaveBalances.length,
+ },
+ }
+ },
+
+ outputs: {
+ leaveBalances: {
+ type: 'array',
+ description: 'List of employee leave balances',
+ items: {
+ type: 'json',
+ properties: {
+ employeeId: { type: 'string', description: 'Employee ID' },
+ balances: {
+ type: 'array',
+ description: 'Leave balance entries',
+ items: {
+ type: 'json',
+ properties: {
+ leaveType: { type: 'string', description: 'Type of leave' },
+ minutesRemaining: { type: 'number', description: 'Minutes of leave remaining' },
+ },
+ },
+ },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Number of leave balances returned on this page',
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/list_leave_requests.ts b/apps/sim/tools/rippling/list_leave_requests.ts
new file mode 100644
index 00000000000..2f99d267d2f
--- /dev/null
+++ b/apps/sim/tools/rippling/list_leave_requests.ts
@@ -0,0 +1,111 @@
+import type {
+ RipplingListLeaveRequestsParams,
+ RipplingListLeaveRequestsResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListLeaveRequestsTool: ToolConfig<
+ RipplingListLeaveRequestsParams,
+ RipplingListLeaveRequestsResponse
+> = {
+ id: 'rippling_list_leave_requests',
+ name: 'Rippling List Leave Requests',
+ description: 'List leave requests in Rippling with optional filtering by date range and status',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ startDate: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Filter by start date (ISO date string)',
+ },
+ endDate: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Filter by end date (ISO date string)',
+ },
+ status: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Filter by status (e.g. pending, approved, declined)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.startDate) query.set('startDate', params.startDate)
+ if (params.endDate) query.set('endDate', params.endDate)
+ if (params.status) query.set('status', params.status)
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/leave_requests${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const leaveRequests = results.map((req: Record) => ({
+ id: (req.id as string) ?? '',
+ requestedBy: (req.requestedBy as string) ?? '',
+ status: (req.status as string) ?? '',
+ startDate: (req.startDate as string) ?? '',
+ endDate: (req.endDate as string) ?? '',
+ reason: (req.reason as string) ?? null,
+ leaveType: (req.leaveType as string) ?? null,
+ createdAt: (req.createdAt as string) ?? null,
+ }))
+
+ return {
+ success: true,
+ output: {
+ leaveRequests,
+ totalCount: leaveRequests.length,
+ },
+ }
+ },
+
+ outputs: {
+ leaveRequests: {
+ type: 'array',
+ description: 'List of leave requests',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Leave request ID' },
+ requestedBy: { type: 'string', description: 'Employee ID who requested leave' },
+ status: { type: 'string', description: 'Request status (pending/approved/declined)' },
+ startDate: { type: 'string', description: 'Leave start date' },
+ endDate: { type: 'string', description: 'Leave end date' },
+ reason: { type: 'string', description: 'Reason for leave' },
+ leaveType: { type: 'string', description: 'Type of leave' },
+ createdAt: { type: 'string', description: 'When the request was created' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Total number of leave requests returned',
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/list_leave_types.ts b/apps/sim/tools/rippling/list_leave_types.ts
new file mode 100644
index 00000000000..3c44c569343
--- /dev/null
+++ b/apps/sim/tools/rippling/list_leave_types.ts
@@ -0,0 +1,87 @@
+import type {
+ RipplingListLeaveTypesParams,
+ RipplingListLeaveTypesResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListLeaveTypesTool: ToolConfig<
+ RipplingListLeaveTypesParams,
+ RipplingListLeaveTypesResponse
+> = {
+ id: 'rippling_list_leave_types',
+ name: 'Rippling List Leave Types',
+ description: 'List company leave types configured in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ managedBy: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Filter leave types by manager',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.managedBy) query.set('managedBy', params.managedBy)
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/company_leave_types${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const leaveTypes = results.map((lt: Record) => ({
+ id: (lt.id as string) ?? '',
+ name: (lt.name as string) ?? '',
+ managedBy: (lt.managedBy as string) ?? null,
+ }))
+
+ return {
+ success: true,
+ output: {
+ leaveTypes,
+ totalCount: leaveTypes.length,
+ },
+ }
+ },
+
+ outputs: {
+ leaveTypes: {
+ type: 'array',
+ description: 'List of company leave types',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Leave type ID' },
+ name: { type: 'string', description: 'Leave type name' },
+ managedBy: { type: 'string', description: 'Manager of this leave type' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Total number of leave types returned',
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/list_levels.ts b/apps/sim/tools/rippling/list_levels.ts
new file mode 100644
index 00000000000..55d679f95c4
--- /dev/null
+++ b/apps/sim/tools/rippling/list_levels.ts
@@ -0,0 +1,91 @@
+import type { RipplingListLevelsParams, RipplingListLevelsResponse } from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListLevelsTool: ToolConfig<
+ RipplingListLevelsParams,
+ RipplingListLevelsResponse
+> = {
+ id: 'rippling_list_levels',
+ name: 'Rippling List Levels',
+ description: 'List all position levels in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of levels to return',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Offset for pagination',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.limit != null) query.set('limit', String(params.limit))
+ if (params.offset != null) query.set('offset', String(params.offset))
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/levels${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const levels = results.map((level: Record) => ({
+ id: (level.id as string) ?? '',
+ name: (level.name as string) ?? null,
+ parent: (level.parent as string) ?? null,
+ }))
+
+ return {
+ success: true,
+ output: {
+ levels,
+ totalCount: levels.length,
+ },
+ }
+ },
+
+ outputs: {
+ levels: {
+ type: 'array',
+ description: 'List of position levels',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Level ID' },
+ name: { type: 'string', description: 'Level name' },
+ parent: { type: 'string', description: 'Parent level ID' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Number of levels returned on this page',
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/list_teams.ts b/apps/sim/tools/rippling/list_teams.ts
new file mode 100644
index 00000000000..5a906e609ac
--- /dev/null
+++ b/apps/sim/tools/rippling/list_teams.ts
@@ -0,0 +1,89 @@
+import type { RipplingListTeamsParams, RipplingListTeamsResponse } from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListTeamsTool: ToolConfig =
+ {
+ id: 'rippling_list_teams',
+ name: 'Rippling List Teams',
+ description: 'List all teams in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of teams to return',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Offset for pagination',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.limit != null) query.set('limit', String(params.limit))
+ if (params.offset != null) query.set('offset', String(params.offset))
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/teams${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const teams = results.map((team: Record) => ({
+ id: (team.id as string) ?? '',
+ name: (team.name as string) ?? null,
+ parent: (team.parent as string) ?? null,
+ }))
+
+ return {
+ success: true,
+ output: {
+ teams,
+ totalCount: teams.length,
+ },
+ }
+ },
+
+ outputs: {
+ teams: {
+ type: 'array',
+ description: 'List of teams',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Team ID' },
+ name: { type: 'string', description: 'Team name' },
+ parent: { type: 'string', description: 'Parent team ID' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Number of teams returned on this page',
+ },
+ },
+ }
diff --git a/apps/sim/tools/rippling/list_work_locations.ts b/apps/sim/tools/rippling/list_work_locations.ts
new file mode 100644
index 00000000000..db537ffb3c5
--- /dev/null
+++ b/apps/sim/tools/rippling/list_work_locations.ts
@@ -0,0 +1,102 @@
+import type {
+ RipplingListWorkLocationsParams,
+ RipplingListWorkLocationsResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingListWorkLocationsTool: ToolConfig<
+ RipplingListWorkLocationsParams,
+ RipplingListWorkLocationsResponse
+> = {
+ id: 'rippling_list_work_locations',
+ name: 'Rippling List Work Locations',
+ description: 'List all work locations in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of work locations to return',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Offset for pagination',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const query = new URLSearchParams()
+ if (params.limit != null) query.set('limit', String(params.limit))
+ if (params.offset != null) query.set('offset', String(params.offset))
+ const qs = query.toString()
+ return `https://api.rippling.com/platform/api/work_locations${qs ? `?${qs}` : ''}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+ const results = Array.isArray(data) ? data : (data.results ?? [])
+
+ const workLocations = results.map((loc: Record) => ({
+ id: (loc.id as string) ?? '',
+ nickname: (loc.nickname as string) ?? null,
+ street: (loc.street as string) ?? null,
+ city: (loc.city as string) ?? null,
+ state: (loc.state as string) ?? null,
+ zip: (loc.zip as string) ?? null,
+ country: (loc.country as string) ?? null,
+ }))
+
+ return {
+ success: true,
+ output: {
+ workLocations,
+ totalCount: workLocations.length,
+ },
+ }
+ },
+
+ outputs: {
+ workLocations: {
+ type: 'array',
+ description: 'List of work locations',
+ items: {
+ type: 'json',
+ properties: {
+ id: { type: 'string', description: 'Work location ID' },
+ nickname: { type: 'string', description: 'Location nickname' },
+ street: { type: 'string', description: 'Street address' },
+ city: { type: 'string', description: 'City' },
+ state: { type: 'string', description: 'State or province' },
+ zip: { type: 'string', description: 'ZIP or postal code' },
+ country: { type: 'string', description: 'Country' },
+ },
+ },
+ },
+ totalCount: {
+ type: 'number',
+ description: 'Number of work locations returned on this page',
+ },
+ },
+}
diff --git a/apps/sim/tools/rippling/process_leave_request.ts b/apps/sim/tools/rippling/process_leave_request.ts
new file mode 100644
index 00000000000..4589a3b16f4
--- /dev/null
+++ b/apps/sim/tools/rippling/process_leave_request.ts
@@ -0,0 +1,79 @@
+import type {
+ RipplingProcessLeaveRequestParams,
+ RipplingProcessLeaveRequestResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingProcessLeaveRequestTool: ToolConfig<
+ RipplingProcessLeaveRequestParams,
+ RipplingProcessLeaveRequestResponse
+> = {
+ id: 'rippling_process_leave_request',
+ name: 'Rippling Process Leave Request',
+ description: 'Approve or decline a leave request in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ leaveRequestId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the leave request to process',
+ },
+ action: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Action to take on the leave request (approve or decline)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const action = params.action.trim()
+ if (action !== 'approve' && action !== 'decline') {
+ throw new Error(`Invalid action "${action}". Must be "approve" or "decline".`)
+ }
+ return `https://api.rippling.com/platform/api/leave_requests/${encodeURIComponent(params.leaveRequestId.trim())}/process?action=${encodeURIComponent(action)}`
+ },
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ id: data.id ?? '',
+ status: data.status ?? '',
+ requestedBy: data.requestedBy ?? '',
+ startDate: data.startDate ?? '',
+ endDate: data.endDate ?? '',
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Leave request ID' },
+ status: { type: 'string', description: 'Updated status of the leave request' },
+ requestedBy: { type: 'string', description: 'Employee ID who requested leave' },
+ startDate: { type: 'string', description: 'Leave start date' },
+ endDate: { type: 'string', description: 'Leave end date' },
+ },
+}
diff --git a/apps/sim/tools/rippling/push_candidate.ts b/apps/sim/tools/rippling/push_candidate.ts
new file mode 100644
index 00000000000..c98480ba500
--- /dev/null
+++ b/apps/sim/tools/rippling/push_candidate.ts
@@ -0,0 +1,124 @@
+import type {
+ RipplingPushCandidateParams,
+ RipplingPushCandidateResponse,
+} from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingPushCandidateTool: ToolConfig<
+ RipplingPushCandidateParams,
+ RipplingPushCandidateResponse
+> = {
+ id: 'rippling_push_candidate',
+ name: 'Rippling Push Candidate',
+ description: 'Push a candidate to onboarding in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ firstName: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Candidate first name',
+ },
+ lastName: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Candidate last name',
+ },
+ email: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Candidate email address',
+ },
+ phone: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Candidate phone number',
+ },
+ jobTitle: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Job title for the candidate',
+ },
+ department: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Department for the candidate',
+ },
+ startDate: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Start date in ISO 8601 format (e.g., 2025-01-15)',
+ },
+ },
+
+ request: {
+ url: 'https://api.rippling.com/platform/api/ats_candidates/push_candidate',
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => {
+ const body: Record = {
+ firstName: params.firstName,
+ lastName: params.lastName,
+ email: params.email,
+ }
+ if (params.phone !== undefined) {
+ body.phone = params.phone
+ }
+ if (params.jobTitle !== undefined) {
+ body.jobTitle = params.jobTitle
+ }
+ if (params.department !== undefined) {
+ body.department = params.department
+ }
+ if (params.startDate !== undefined) {
+ body.startDate = params.startDate
+ }
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ id: (data.id as string) ?? '',
+ firstName: (data.firstName as string) ?? null,
+ lastName: (data.lastName as string) ?? null,
+ email: (data.email as string) ?? null,
+ status: (data.status as string) ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Candidate ID' },
+ firstName: { type: 'string', description: 'Candidate first name' },
+ lastName: { type: 'string', description: 'Candidate last name' },
+ email: { type: 'string', description: 'Candidate email address' },
+ status: { type: 'string', description: 'Candidate onboarding status', optional: true },
+ },
+}
diff --git a/apps/sim/tools/rippling/types.ts b/apps/sim/tools/rippling/types.ts
new file mode 100644
index 00000000000..7c7211de6ed
--- /dev/null
+++ b/apps/sim/tools/rippling/types.ts
@@ -0,0 +1,362 @@
+import type { ToolResponse } from '@/tools/types'
+
+export interface RipplingListEmployeesParams {
+ apiKey: string
+ limit?: number
+ offset?: number
+}
+
+export interface RipplingGetEmployeeParams {
+ apiKey: string
+ employeeId: string
+}
+
+export interface RipplingListDepartmentsParams {
+ apiKey: string
+ limit?: number
+ offset?: number
+}
+
+export interface RipplingGetCurrentUserParams {
+ apiKey: string
+}
+
+export interface RipplingEmployee {
+ id: string
+ firstName: string | null
+ lastName: string | null
+ workEmail: string | null
+ personalEmail: string | null
+ roleState: string | null
+ department: string | null
+ title: string | null
+ startDate: string | null
+ endDate: string | null
+ manager: string | null
+ phone: string | null
+}
+
+export interface RipplingDepartment {
+ id: string
+ name: string | null
+ parent: string | null
+}
+
+export interface RipplingListEmployeesResponse extends ToolResponse {
+ output: {
+ employees: RipplingEmployee[]
+ totalCount: number
+ }
+}
+
+export interface RipplingGetEmployeeResponse extends ToolResponse {
+ output: RipplingEmployee
+}
+
+export interface RipplingListDepartmentsResponse extends ToolResponse {
+ output: {
+ departments: RipplingDepartment[]
+ totalCount: number
+ }
+}
+
+export interface RipplingGetCurrentUserResponse extends ToolResponse {
+ output: {
+ id: string
+ workEmail: string | null
+ company: string | null
+ }
+}
+
+export interface RipplingGetCompanyParams {
+ apiKey: string
+}
+
+export interface RipplingCompanyAddress {
+ street: string | null
+ city: string | null
+ state: string | null
+ zip: string | null
+ country: string | null
+}
+
+export interface RipplingGetCompanyResponse extends ToolResponse {
+ output: {
+ id: string
+ name: string | null
+ address: RipplingCompanyAddress
+ email: string | null
+ phone: string | null
+ workLocations: string[]
+ }
+}
+
+export interface RipplingListCustomFieldsParams {
+ apiKey: string
+ limit?: number
+ offset?: number
+}
+
+export interface RipplingCustomField {
+ id: string
+ type: string | null
+ title: string | null
+ mandatory: boolean
+}
+
+export interface RipplingListCustomFieldsResponse extends ToolResponse {
+ output: {
+ customFields: RipplingCustomField[]
+ totalCount: number
+ }
+}
+
+export interface RipplingListLevelsParams {
+ apiKey: string
+ limit?: number
+ offset?: number
+}
+
+export interface RipplingLevel {
+ id: string
+ name: string | null
+ parent: string | null
+}
+
+export interface RipplingListLevelsResponse extends ToolResponse {
+ output: {
+ levels: RipplingLevel[]
+ totalCount: number
+ }
+}
+
+export interface RipplingListTeamsParams {
+ apiKey: string
+ limit?: number
+ offset?: number
+}
+
+export interface RipplingTeam {
+ id: string
+ name: string | null
+ parent: string | null
+}
+
+export interface RipplingListTeamsResponse extends ToolResponse {
+ output: {
+ teams: RipplingTeam[]
+ totalCount: number
+ }
+}
+
+export interface RipplingListWorkLocationsParams {
+ apiKey: string
+ limit?: number
+ offset?: number
+}
+
+export interface RipplingWorkLocation {
+ id: string
+ nickname: string | null
+ street: string | null
+ city: string | null
+ state: string | null
+ zip: string | null
+ country: string | null
+}
+
+export interface RipplingListWorkLocationsResponse extends ToolResponse {
+ output: {
+ workLocations: RipplingWorkLocation[]
+ totalCount: number
+ }
+}
+
+export interface RipplingGetCompanyActivityParams {
+ apiKey: string
+ startDate?: string
+ endDate?: string
+ limit?: number
+ next?: string
+}
+
+export interface RipplingActivityActor {
+ id: string | null
+ name: string | null
+}
+
+export interface RipplingActivityEvent {
+ id: string
+ type: string | null
+ description: string | null
+ createdAt: string | null
+ actor: RipplingActivityActor
+}
+
+export interface RipplingGetCompanyActivityResponse extends ToolResponse {
+ output: {
+ events: RipplingActivityEvent[]
+ totalCount: number
+ nextCursor: string | null
+ }
+}
+
+export interface RipplingListEmployeesWithTerminatedParams {
+ apiKey: string
+ limit?: number
+ offset?: number
+}
+
+export interface RipplingListEmployeesWithTerminatedResponse extends ToolResponse {
+ output: {
+ employees: RipplingEmployee[]
+ totalCount: number
+ }
+}
+
+export interface RipplingCreateGroupParams {
+ apiKey: string
+ name: string
+ spokeId: string
+ users?: string[]
+}
+
+export interface RipplingGroup {
+ id: string
+ name: string | null
+ spokeId: string | null
+ users: string[]
+ version: number | null
+}
+
+export interface RipplingCreateGroupResponse extends ToolResponse {
+ output: RipplingGroup
+}
+
+export interface RipplingUpdateGroupParams {
+ apiKey: string
+ groupId: string
+ name?: string
+ spokeId?: string
+ users?: string[]
+ version?: number
+}
+
+export interface RipplingUpdateGroupResponse extends ToolResponse {
+ output: RipplingGroup
+}
+
+export interface RipplingPushCandidateParams {
+ apiKey: string
+ firstName: string
+ lastName: string
+ email: string
+ phone?: string
+ jobTitle?: string
+ department?: string
+ startDate?: string
+}
+
+export interface RipplingPushCandidateResponse extends ToolResponse {
+ output: {
+ id: string
+ firstName: string | null
+ lastName: string | null
+ email: string | null
+ status: string | null
+ }
+}
+
+export interface RipplingListLeaveRequestsParams {
+ apiKey: string
+ startDate?: string
+ endDate?: string
+ status?: string
+}
+
+export interface RipplingLeaveRequest {
+ id: string
+ requestedBy: string
+ status: string
+ startDate: string
+ endDate: string
+ reason: string | null
+ leaveType: string | null
+ createdAt: string | null
+}
+
+export interface RipplingListLeaveRequestsResponse extends ToolResponse {
+ output: {
+ leaveRequests: RipplingLeaveRequest[]
+ totalCount: number
+ }
+}
+
+export interface RipplingProcessLeaveRequestParams {
+ apiKey: string
+ leaveRequestId: string
+ action: string
+}
+
+export interface RipplingProcessLeaveRequestResponse extends ToolResponse {
+ output: {
+ id: string
+ status: string
+ requestedBy: string
+ startDate: string
+ endDate: string
+ }
+}
+
+export interface RipplingListLeaveBalancesParams {
+ apiKey: string
+ limit?: number
+ offset?: number
+}
+
+export interface RipplingLeaveBalanceEntry {
+ leaveType: string
+ minutesRemaining: number
+}
+
+export interface RipplingLeaveBalance {
+ employeeId: string
+ balances: RipplingLeaveBalanceEntry[]
+}
+
+export interface RipplingListLeaveBalancesResponse extends ToolResponse {
+ output: {
+ leaveBalances: RipplingLeaveBalance[]
+ totalCount: number
+ }
+}
+
+export interface RipplingGetLeaveBalanceParams {
+ apiKey: string
+ roleId: string
+}
+
+export interface RipplingGetLeaveBalanceResponse extends ToolResponse {
+ output: {
+ employeeId: string
+ balances: RipplingLeaveBalanceEntry[]
+ }
+}
+
+export interface RipplingListLeaveTypesParams {
+ apiKey: string
+ managedBy?: string
+}
+
+export interface RipplingLeaveType {
+ id: string
+ name: string
+ managedBy: string | null
+}
+
+export interface RipplingListLeaveTypesResponse extends ToolResponse {
+ output: {
+ leaveTypes: RipplingLeaveType[]
+ totalCount: number
+ }
+}
diff --git a/apps/sim/tools/rippling/update_group.ts b/apps/sim/tools/rippling/update_group.ts
new file mode 100644
index 00000000000..c1c7bd54842
--- /dev/null
+++ b/apps/sim/tools/rippling/update_group.ts
@@ -0,0 +1,115 @@
+import type { RipplingUpdateGroupParams, RipplingUpdateGroupResponse } from '@/tools/rippling/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const ripplingUpdateGroupTool: ToolConfig<
+ RipplingUpdateGroupParams,
+ RipplingUpdateGroupResponse
+> = {
+ id: 'rippling_update_group',
+ name: 'Rippling Update Group',
+ description: 'Update an existing group in Rippling',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Rippling API key',
+ },
+ groupId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the group to update',
+ },
+ name: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'New name for the group',
+ },
+ spokeId: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Third-party app identifier',
+ },
+ users: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Array of user ID strings to set for the group',
+ },
+ version: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Group version number for optimistic concurrency',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.rippling.com/platform/api/groups/${encodeURIComponent(params.groupId.trim())}`,
+ method: 'PUT',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => {
+ const body: Record = {}
+ if (params.name !== undefined) {
+ body.name = params.name
+ }
+ if (params.spokeId !== undefined) {
+ body.spokeId = params.spokeId
+ }
+ if (params.users !== undefined) {
+ body.users = params.users
+ }
+ if (params.version !== undefined) {
+ body.version = params.version
+ }
+ if (Object.keys(body).length === 0) {
+ throw new Error(
+ 'At least one field (name, spokeId, users, or version) must be provided to update a group.'
+ )
+ }
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Rippling API error (${response.status}): ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ id: (data.id as string) ?? '',
+ name: (data.name as string) ?? null,
+ spokeId: (data.spokeId as string) ?? null,
+ users: (data.users as string[]) ?? [],
+ version: (data.version as number) ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Group ID' },
+ name: { type: 'string', description: 'Group name' },
+ spokeId: { type: 'string', description: 'Third-party app identifier' },
+ users: {
+ type: 'array',
+ description: 'Array of user IDs in the group',
+ items: { type: 'string' },
+ },
+ version: { type: 'number', description: 'Group version number' },
+ },
+}
From e0f2b8fe588450daa6a02ecf6719985d614d9c1e Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 25 Mar 2026 13:15:48 -0700
Subject: [PATCH 05/20] feat(hubspot): add 27 CRM tools and fix OAuth scope
mismatch (#3765)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(hubspot): add 27 CRM tools and fix OAuth scope mismatch
* lint
* fix(hubspot): switch marketing events to CRM Objects API and add HubSpotCrmObject base type
* chore(docs): fix import ordering and formatting lint errors
* feat(hubspot): wire all 27 new tools into block definition
* fix(hubspot): address review comments - schema mismatch, pagination, trim, descriptions
- Switch marketing event outputs to CRM envelope structure (id, properties, createdAt, updatedAt, archived) matching CRM Objects API
- Fix list_lists pagination: add offset param, map offset-based response to paging structure
- Add .trim() to contactId/companyId in pre-existing get/update tools
- Fix default limit descriptions (100 → 10) in list_contacts/list_companies
- Fix operator examples (CONTAINS → CONTAINS_TOKEN) in search_contacts/search_companies
- Remove unused params arg in get_users transformResponse
Co-Authored-By: Claude Opus 4.6
* fix(hubspot): revert to Marketing Events API and fix Lists pagination per API docs
Marketing Events:
- Revert from /crm/v3/objects/marketing_events back to /marketing/v3/marketing-events
- The Marketing Events API does NOT require appId for GET /marketing-events/{objectId}
- appId is only needed for the /events/{externalEventId} endpoint (which we don't use)
- Restore flat response schema (objectId, eventName, etc. at top level, not CRM envelope)
Lists:
- POST /crm/v3/lists/search uses offset-based pagination (not cursor-based)
- Response shape: { lists, hasMore, offset, total } — not { results, paging }
- Map offset → paging.next.after for consistent block interface
- Fix default count: 20 (not 25), max 500
- GET /crm/v3/lists/{listId} wraps response in { list: { ... } }
Co-Authored-By: Claude Opus 4.6
* fix(hubspot): final audit fixes verified against API docs
- Revert list_contacts/list_companies default limit back to 100 (confirmed by API docs)
- Add idProperty param to get_appointment.ts (was missing, inconsistent with update_appointment)
- Remove get_carts from idProperty block condition (carts don't support idProperty)
- Add get_lists to after block condition (pagination was inaccessible from UI)
- Add after pagination param to get_users.ts (was missing, users beyond first page unreachable)
Co-Authored-By: Claude Opus 4.6
* fix(hubspot): return paging in get_users and add to block after condition
- Add paging output to get_users transformResponse and outputs
- Add get_users to block after subBlock condition so cursor is accessible from UI
Co-Authored-By: Claude Opus 4.6
* fix(hubspot): align total fallback with type definitions in search tools
Use `?? 0` instead of `?? null` for search tools where the type declares
`total: number`. Also declare `total` in list_lists metadata output schema.
Co-Authored-By: Claude Opus 4.6
---------
Co-authored-by: Claude Opus 4.6
---
apps/docs/content/docs/en/tools/hubspot.mdx | 2 +-
apps/sim/blocks/blocks/hubspot.ts | 390 +++++++++-
apps/sim/connectors/hubspot/hubspot.ts | 2 +-
apps/sim/lib/oauth/oauth.ts | 2 -
apps/sim/tools/hubspot/create_appointment.ts | 94 +++
apps/sim/tools/hubspot/create_deal.ts | 91 +++
apps/sim/tools/hubspot/create_line_item.ts | 94 +++
apps/sim/tools/hubspot/create_list.ts | 88 +++
apps/sim/tools/hubspot/create_ticket.ts | 91 +++
apps/sim/tools/hubspot/get_appointment.ts | 99 +++
apps/sim/tools/hubspot/get_cart.ts | 84 ++
apps/sim/tools/hubspot/get_company.ts | 2 +-
apps/sim/tools/hubspot/get_contact.ts | 2 +-
apps/sim/tools/hubspot/get_deal.ts | 93 +++
apps/sim/tools/hubspot/get_line_item.ts | 96 +++
apps/sim/tools/hubspot/get_list.ts | 73 ++
apps/sim/tools/hubspot/get_marketing_event.ts | 76 ++
apps/sim/tools/hubspot/get_quote.ts | 93 +++
apps/sim/tools/hubspot/get_ticket.ts | 93 +++
apps/sim/tools/hubspot/get_users.ts | 15 +-
apps/sim/tools/hubspot/index.ts | 27 +
apps/sim/tools/hubspot/list_appointments.ts | 109 +++
apps/sim/tools/hubspot/list_carts.ts | 101 +++
apps/sim/tools/hubspot/list_deals.ts | 2 +-
apps/sim/tools/hubspot/list_line_items.ts | 109 +++
apps/sim/tools/hubspot/list_lists.ts | 107 +++
.../tools/hubspot/list_marketing_events.ts | 98 +++
apps/sim/tools/hubspot/list_owners.ts | 95 +++
apps/sim/tools/hubspot/list_quotes.ts | 104 +++
apps/sim/tools/hubspot/list_tickets.ts | 106 +++
apps/sim/tools/hubspot/search_companies.ts | 4 +-
apps/sim/tools/hubspot/search_contacts.ts | 4 +-
apps/sim/tools/hubspot/search_deals.ts | 152 ++++
apps/sim/tools/hubspot/search_tickets.ts | 155 ++++
apps/sim/tools/hubspot/types.ts | 720 +++++++++++++++++-
apps/sim/tools/hubspot/update_appointment.ts | 101 +++
apps/sim/tools/hubspot/update_company.ts | 2 +-
apps/sim/tools/hubspot/update_contact.ts | 2 +-
apps/sim/tools/hubspot/update_deal.ts | 98 +++
apps/sim/tools/hubspot/update_line_item.ts | 101 +++
apps/sim/tools/hubspot/update_ticket.ts | 98 +++
apps/sim/tools/registry.ts | 54 ++
42 files changed, 3892 insertions(+), 37 deletions(-)
create mode 100644 apps/sim/tools/hubspot/create_appointment.ts
create mode 100644 apps/sim/tools/hubspot/create_deal.ts
create mode 100644 apps/sim/tools/hubspot/create_line_item.ts
create mode 100644 apps/sim/tools/hubspot/create_list.ts
create mode 100644 apps/sim/tools/hubspot/create_ticket.ts
create mode 100644 apps/sim/tools/hubspot/get_appointment.ts
create mode 100644 apps/sim/tools/hubspot/get_cart.ts
create mode 100644 apps/sim/tools/hubspot/get_deal.ts
create mode 100644 apps/sim/tools/hubspot/get_line_item.ts
create mode 100644 apps/sim/tools/hubspot/get_list.ts
create mode 100644 apps/sim/tools/hubspot/get_marketing_event.ts
create mode 100644 apps/sim/tools/hubspot/get_quote.ts
create mode 100644 apps/sim/tools/hubspot/get_ticket.ts
create mode 100644 apps/sim/tools/hubspot/list_appointments.ts
create mode 100644 apps/sim/tools/hubspot/list_carts.ts
create mode 100644 apps/sim/tools/hubspot/list_line_items.ts
create mode 100644 apps/sim/tools/hubspot/list_lists.ts
create mode 100644 apps/sim/tools/hubspot/list_marketing_events.ts
create mode 100644 apps/sim/tools/hubspot/list_owners.ts
create mode 100644 apps/sim/tools/hubspot/list_quotes.ts
create mode 100644 apps/sim/tools/hubspot/list_tickets.ts
create mode 100644 apps/sim/tools/hubspot/search_deals.ts
create mode 100644 apps/sim/tools/hubspot/search_tickets.ts
create mode 100644 apps/sim/tools/hubspot/update_appointment.ts
create mode 100644 apps/sim/tools/hubspot/update_deal.ts
create mode 100644 apps/sim/tools/hubspot/update_line_item.ts
create mode 100644 apps/sim/tools/hubspot/update_ticket.ts
diff --git a/apps/docs/content/docs/en/tools/hubspot.mdx b/apps/docs/content/docs/en/tools/hubspot.mdx
index b4cfe86af81..99cba9a5343 100644
--- a/apps/docs/content/docs/en/tools/hubspot.mdx
+++ b/apps/docs/content/docs/en/tools/hubspot.mdx
@@ -499,7 +499,7 @@ Retrieve all deals from HubSpot account with pagination support
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `limit` | string | No | Maximum number of results per page \(max 100, default 100\) |
+| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "dealname,amount,dealstage"\) |
| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
diff --git a/apps/sim/blocks/blocks/hubspot.ts b/apps/sim/blocks/blocks/hubspot.ts
index ecacf1d588e..36dee5e4dc6 100644
--- a/apps/sim/blocks/blocks/hubspot.ts
+++ b/apps/sim/blocks/blocks/hubspot.ts
@@ -25,7 +25,6 @@ export const HubSpotBlock: BlockConfig = {
title: 'Operation',
type: 'dropdown',
options: [
- { label: 'Get Users', id: 'get_users' },
{ label: 'Get Contacts', id: 'get_contacts' },
{ label: 'Create Contact', id: 'create_contact' },
{ label: 'Update Contact', id: 'update_contact' },
@@ -35,6 +34,26 @@ export const HubSpotBlock: BlockConfig = {
{ label: 'Update Company', id: 'update_company' },
{ label: 'Search Companies', id: 'search_companies' },
{ label: 'Get Deals', id: 'get_deals' },
+ { label: 'Create Deal', id: 'create_deal' },
+ { label: 'Update Deal', id: 'update_deal' },
+ { label: 'Search Deals', id: 'search_deals' },
+ { label: 'Get Tickets', id: 'get_tickets' },
+ { label: 'Create Ticket', id: 'create_ticket' },
+ { label: 'Update Ticket', id: 'update_ticket' },
+ { label: 'Search Tickets', id: 'search_tickets' },
+ { label: 'Get Line Items', id: 'get_line_items' },
+ { label: 'Create Line Item', id: 'create_line_item' },
+ { label: 'Update Line Item', id: 'update_line_item' },
+ { label: 'Get Quotes', id: 'get_quotes' },
+ { label: 'Get Appointments', id: 'get_appointments' },
+ { label: 'Create Appointment', id: 'create_appointment' },
+ { label: 'Update Appointment', id: 'update_appointment' },
+ { label: 'Get Carts', id: 'get_carts' },
+ { label: 'List Owners', id: 'list_owners' },
+ { label: 'Get Marketing Events', id: 'get_marketing_events' },
+ { label: 'Get Lists', id: 'get_lists' },
+ { label: 'Create List', id: 'create_list' },
+ { label: 'Get Users', id: 'get_users' },
],
value: () => 'get_contacts',
},
@@ -88,6 +107,121 @@ export const HubSpotBlock: BlockConfig = {
condition: { field: 'operation', value: 'update_company' },
required: true,
},
+ {
+ id: 'dealId',
+ title: 'Deal ID',
+ type: 'short-input',
+ placeholder: 'Leave empty to list all deals',
+ condition: { field: 'operation', value: 'get_deals' },
+ },
+ {
+ id: 'dealId',
+ title: 'Deal ID',
+ type: 'short-input',
+ placeholder: 'Numeric ID, or custom ID (requires ID Property below)',
+ condition: { field: 'operation', value: 'update_deal' },
+ required: true,
+ },
+ {
+ id: 'ticketId',
+ title: 'Ticket ID',
+ type: 'short-input',
+ placeholder: 'Leave empty to list all tickets',
+ condition: { field: 'operation', value: 'get_tickets' },
+ },
+ {
+ id: 'ticketId',
+ title: 'Ticket ID',
+ type: 'short-input',
+ placeholder: 'Numeric ID, or custom ID (requires ID Property below)',
+ condition: { field: 'operation', value: 'update_ticket' },
+ required: true,
+ },
+ {
+ id: 'lineItemId',
+ title: 'Line Item ID',
+ type: 'short-input',
+ placeholder: 'Leave empty to list all line items',
+ condition: { field: 'operation', value: 'get_line_items' },
+ },
+ {
+ id: 'lineItemId',
+ title: 'Line Item ID',
+ type: 'short-input',
+ placeholder: 'Numeric ID, or custom ID (requires ID Property below)',
+ condition: { field: 'operation', value: 'update_line_item' },
+ required: true,
+ },
+ {
+ id: 'quoteId',
+ title: 'Quote ID',
+ type: 'short-input',
+ placeholder: 'Leave empty to list all quotes',
+ condition: { field: 'operation', value: 'get_quotes' },
+ },
+ {
+ id: 'appointmentId',
+ title: 'Appointment ID',
+ type: 'short-input',
+ placeholder: 'Leave empty to list all appointments',
+ condition: { field: 'operation', value: 'get_appointments' },
+ },
+ {
+ id: 'appointmentId',
+ title: 'Appointment ID',
+ type: 'short-input',
+ placeholder: 'Numeric ID, or custom ID (requires ID Property below)',
+ condition: { field: 'operation', value: 'update_appointment' },
+ required: true,
+ },
+ {
+ id: 'cartId',
+ title: 'Cart ID',
+ type: 'short-input',
+ placeholder: 'Leave empty to list all carts',
+ condition: { field: 'operation', value: 'get_carts' },
+ },
+ {
+ id: 'eventId',
+ title: 'Marketing Event ID',
+ type: 'short-input',
+ placeholder: 'Leave empty to list all marketing events',
+ condition: { field: 'operation', value: 'get_marketing_events' },
+ },
+ {
+ id: 'listId',
+ title: 'List ID',
+ type: 'short-input',
+ placeholder: 'Leave empty to search all lists',
+ condition: { field: 'operation', value: 'get_lists' },
+ },
+ {
+ id: 'listName',
+ title: 'List Name',
+ type: 'short-input',
+ placeholder: 'Name for the new list',
+ condition: { field: 'operation', value: 'create_list' },
+ required: true,
+ },
+ {
+ id: 'objectTypeId',
+ title: 'Object Type ID',
+ type: 'short-input',
+ placeholder: 'e.g., "0-1" for contacts, "0-2" for companies',
+ condition: { field: 'operation', value: 'create_list' },
+ required: true,
+ },
+ {
+ id: 'processingType',
+ title: 'Processing Type',
+ type: 'dropdown',
+ options: [
+ { label: 'Manual (Static)', id: 'MANUAL' },
+ { label: 'Dynamic (Active)', id: 'DYNAMIC' },
+ ],
+ condition: { field: 'operation', value: 'create_list' },
+ required: true,
+ },
{
id: 'idProperty',
title: 'ID Property',
@@ -95,7 +229,21 @@ export const HubSpotBlock: BlockConfig = {
placeholder: 'Required if using email/domain (e.g., "email" or "domain")',
condition: {
field: 'operation',
- value: ['get_contacts', 'update_contact', 'get_companies', 'update_company'],
+ value: [
+ 'get_contacts',
+ 'update_contact',
+ 'get_companies',
+ 'update_company',
+ 'get_deals',
+ 'update_deal',
+ 'get_tickets',
+ 'update_ticket',
+ 'get_line_items',
+ 'update_line_item',
+ 'get_quotes',
+ 'get_appointments',
+ 'update_appointment',
+ ],
},
},
{
@@ -106,7 +254,20 @@ export const HubSpotBlock: BlockConfig = {
'JSON object with properties (e.g., {"email": "test@example.com", "firstname": "John"})',
condition: {
field: 'operation',
- value: ['create_contact', 'update_contact', 'create_company', 'update_company'],
+ value: [
+ 'create_contact',
+ 'update_contact',
+ 'create_company',
+ 'update_company',
+ 'create_deal',
+ 'update_deal',
+ 'create_ticket',
+ 'update_ticket',
+ 'create_line_item',
+ 'update_line_item',
+ 'create_appointment',
+ 'update_appointment',
+ ],
},
wandConfig: {
enabled: true,
@@ -235,7 +396,19 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
title: 'Properties to Return',
type: 'short-input',
placeholder: 'Comma-separated list (e.g., "email,firstname,lastname")',
- condition: { field: 'operation', value: ['get_contacts', 'get_companies', 'get_deals'] },
+ condition: {
+ field: 'operation',
+ value: [
+ 'get_contacts',
+ 'get_companies',
+ 'get_deals',
+ 'get_tickets',
+ 'get_line_items',
+ 'get_quotes',
+ 'get_appointments',
+ 'get_carts',
+ ],
+ },
},
{
id: 'associations',
@@ -244,7 +417,22 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
placeholder: 'Comma-separated object types (e.g., "companies,deals")',
condition: {
field: 'operation',
- value: ['get_contacts', 'get_companies', 'get_deals', 'create_contact', 'create_company'],
+ value: [
+ 'get_contacts',
+ 'get_companies',
+ 'get_deals',
+ 'get_tickets',
+ 'get_line_items',
+ 'get_quotes',
+ 'get_appointments',
+ 'get_carts',
+ 'create_contact',
+ 'create_company',
+ 'create_deal',
+ 'create_ticket',
+ 'create_line_item',
+ 'create_appointment',
+ ],
},
},
{
@@ -259,8 +447,18 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
'get_contacts',
'get_companies',
'get_deals',
+ 'get_tickets',
+ 'get_line_items',
+ 'get_quotes',
+ 'get_appointments',
+ 'get_carts',
+ 'list_owners',
+ 'get_marketing_events',
+ 'get_lists',
'search_contacts',
'search_companies',
+ 'search_deals',
+ 'search_tickets',
],
},
},
@@ -275,8 +473,19 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
'get_contacts',
'get_companies',
'get_deals',
+ 'get_tickets',
+ 'get_line_items',
+ 'get_quotes',
+ 'get_appointments',
+ 'get_carts',
+ 'list_owners',
+ 'get_users',
+ 'get_marketing_events',
+ 'get_lists',
'search_contacts',
'search_companies',
+ 'search_deals',
+ 'search_tickets',
],
},
},
@@ -285,7 +494,16 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
title: 'Search Query',
type: 'short-input',
placeholder: 'Search term (e.g., company name, contact email)',
- condition: { field: 'operation', value: ['search_contacts', 'search_companies'] },
+ condition: {
+ field: 'operation',
+ value: [
+ 'search_contacts',
+ 'search_companies',
+ 'search_deals',
+ 'search_tickets',
+ 'get_lists',
+ ],
+ },
},
{
id: 'filterGroups',
@@ -293,7 +511,10 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
type: 'long-input',
placeholder:
'JSON array of filter groups (e.g., [{"filters":[{"propertyName":"email","operator":"EQ","value":"test@example.com"}]}])',
- condition: { field: 'operation', value: ['search_contacts', 'search_companies'] },
+ condition: {
+ field: 'operation',
+ value: ['search_contacts', 'search_companies', 'search_deals', 'search_tickets'],
+ },
wandConfig: {
enabled: true,
maintainHistory: true,
@@ -493,7 +714,10 @@ Return ONLY the JSON array of filter groups - no explanations, no markdown, no e
type: 'long-input',
placeholder:
'JSON array of sort objects (e.g., [{"propertyName":"createdate","direction":"DESCENDING"}])',
- condition: { field: 'operation', value: ['search_contacts', 'search_companies'] },
+ condition: {
+ field: 'operation',
+ value: ['search_contacts', 'search_companies', 'search_deals', 'search_tickets'],
+ },
wandConfig: {
enabled: true,
maintainHistory: true,
@@ -614,7 +838,10 @@ Return ONLY the JSON array of sort objects - no explanations, no markdown, no ex
title: 'Properties to Return',
type: 'long-input',
placeholder: 'JSON array of properties (e.g., ["email","firstname","lastname"])',
- condition: { field: 'operation', value: ['search_contacts', 'search_companies'] },
+ condition: {
+ field: 'operation',
+ value: ['search_contacts', 'search_companies', 'search_deals', 'search_tickets'],
+ },
wandConfig: {
enabled: true,
maintainHistory: true,
@@ -783,6 +1010,33 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
'hubspot_update_company',
'hubspot_search_companies',
'hubspot_list_deals',
+ 'hubspot_get_deal',
+ 'hubspot_create_deal',
+ 'hubspot_update_deal',
+ 'hubspot_search_deals',
+ 'hubspot_list_tickets',
+ 'hubspot_get_ticket',
+ 'hubspot_create_ticket',
+ 'hubspot_update_ticket',
+ 'hubspot_search_tickets',
+ 'hubspot_list_line_items',
+ 'hubspot_get_line_item',
+ 'hubspot_create_line_item',
+ 'hubspot_update_line_item',
+ 'hubspot_list_quotes',
+ 'hubspot_get_quote',
+ 'hubspot_list_appointments',
+ 'hubspot_get_appointment',
+ 'hubspot_create_appointment',
+ 'hubspot_update_appointment',
+ 'hubspot_list_carts',
+ 'hubspot_get_cart',
+ 'hubspot_list_owners',
+ 'hubspot_list_marketing_events',
+ 'hubspot_get_marketing_event',
+ 'hubspot_list_lists',
+ 'hubspot_get_list',
+ 'hubspot_create_list',
],
config: {
tool: (params) => {
@@ -806,7 +1060,45 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
case 'search_companies':
return 'hubspot_search_companies'
case 'get_deals':
- return 'hubspot_list_deals'
+ return params.dealId ? 'hubspot_get_deal' : 'hubspot_list_deals'
+ case 'create_deal':
+ return 'hubspot_create_deal'
+ case 'update_deal':
+ return 'hubspot_update_deal'
+ case 'search_deals':
+ return 'hubspot_search_deals'
+ case 'get_tickets':
+ return params.ticketId ? 'hubspot_get_ticket' : 'hubspot_list_tickets'
+ case 'create_ticket':
+ return 'hubspot_create_ticket'
+ case 'update_ticket':
+ return 'hubspot_update_ticket'
+ case 'search_tickets':
+ return 'hubspot_search_tickets'
+ case 'get_line_items':
+ return params.lineItemId ? 'hubspot_get_line_item' : 'hubspot_list_line_items'
+ case 'create_line_item':
+ return 'hubspot_create_line_item'
+ case 'update_line_item':
+ return 'hubspot_update_line_item'
+ case 'get_quotes':
+ return params.quoteId ? 'hubspot_get_quote' : 'hubspot_list_quotes'
+ case 'get_appointments':
+ return params.appointmentId ? 'hubspot_get_appointment' : 'hubspot_list_appointments'
+ case 'create_appointment':
+ return 'hubspot_create_appointment'
+ case 'update_appointment':
+ return 'hubspot_update_appointment'
+ case 'get_carts':
+ return params.cartId ? 'hubspot_get_cart' : 'hubspot_list_carts'
+ case 'list_owners':
+ return 'hubspot_list_owners'
+ case 'get_marketing_events':
+ return params.eventId ? 'hubspot_get_marketing_event' : 'hubspot_list_marketing_events'
+ case 'get_lists':
+ return params.listId ? 'hubspot_get_list' : 'hubspot_list_lists'
+ case 'create_list':
+ return 'hubspot_create_list'
default:
throw new Error(`Unknown operation: ${params.operation}`)
}
@@ -821,6 +1113,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
filterGroups,
sorts,
associations,
+ listName,
...rest
} = params
@@ -833,17 +1126,34 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
'update_contact',
'create_company',
'update_company',
+ 'create_deal',
+ 'update_deal',
+ 'create_ticket',
+ 'update_ticket',
+ 'create_line_item',
+ 'update_line_item',
+ 'create_appointment',
+ 'update_appointment',
]
if (propertiesToSet && createUpdateOps.includes(operation as string)) {
cleanParams.properties = propertiesToSet
}
- const getListOps = ['get_contacts', 'get_companies', 'get_deals']
+ const getListOps = [
+ 'get_contacts',
+ 'get_companies',
+ 'get_deals',
+ 'get_tickets',
+ 'get_line_items',
+ 'get_quotes',
+ 'get_appointments',
+ 'get_carts',
+ ]
if (properties && !searchProperties && getListOps.includes(operation as string)) {
cleanParams.properties = properties
}
- const searchOps = ['search_contacts', 'search_companies']
+ const searchOps = ['search_contacts', 'search_companies', 'search_deals', 'search_tickets']
if (searchProperties && searchOps.includes(operation as string)) {
cleanParams.properties = searchProperties
}
@@ -856,10 +1166,34 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
cleanParams.sorts = sorts
}
- if (associations && ['create_contact', 'create_company'].includes(operation as string)) {
+ const associationOps = [
+ ...getListOps,
+ 'create_contact',
+ 'create_company',
+ 'create_deal',
+ 'create_ticket',
+ 'create_line_item',
+ 'create_appointment',
+ ]
+ if (associations && associationOps.includes(operation as string)) {
cleanParams.associations = associations
}
+ if (listName && operation === 'create_list') {
+ cleanParams.name = listName
+ }
+
+ if (operation === 'get_lists') {
+ if (rest.limit) {
+ cleanParams.count = rest.limit
+ rest.limit = undefined
+ }
+ if (rest.after) {
+ cleanParams.offset = rest.after
+ rest.after = undefined
+ }
+ }
+
const excludeKeys = [
'propertiesToSet',
'properties',
@@ -867,6 +1201,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
'filterGroups',
'sorts',
'associations',
+ 'listName',
]
Object.entries(rest).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '' && !excludeKeys.includes(key)) {
@@ -883,6 +1218,14 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
oauthCredential: { type: 'string', description: 'HubSpot access token' },
contactId: { type: 'string', description: 'Contact ID or email' },
companyId: { type: 'string', description: 'Company ID or domain' },
+ dealId: { type: 'string', description: 'Deal ID' },
+ ticketId: { type: 'string', description: 'Ticket ID' },
+ lineItemId: { type: 'string', description: 'Line item ID' },
+ quoteId: { type: 'string', description: 'Quote ID' },
+ appointmentId: { type: 'string', description: 'Appointment ID' },
+ cartId: { type: 'string', description: 'Cart ID' },
+ eventId: { type: 'string', description: 'Marketing event ID' },
+ listId: { type: 'string', description: 'List ID' },
idProperty: { type: 'string', description: 'Property name to use as unique identifier' },
propertiesToSet: { type: 'json', description: 'Properties to create/update (JSON object)' },
properties: {
@@ -890,12 +1233,15 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
description: 'Comma-separated properties to return (for list/get)',
},
associations: { type: 'string', description: 'Comma-separated object types for associations' },
- limit: { type: 'string', description: 'Maximum results (list: 100, search: 200)' },
+ limit: { type: 'string', description: 'Maximum results per page' },
after: { type: 'string', description: 'Pagination cursor' },
query: { type: 'string', description: 'Search query string' },
filterGroups: { type: 'json', description: 'Filter groups for search (JSON array)' },
sorts: { type: 'json', description: 'Sort order (JSON array of strings or objects)' },
searchProperties: { type: 'json', description: 'Properties to return in search (JSON array)' },
+ listName: { type: 'string', description: 'Name for new list' },
+ objectTypeId: { type: 'string', description: 'Object type ID for list' },
+ processingType: { type: 'string', description: 'List processing type (MANUAL or DYNAMIC)' },
},
outputs: {
users: { type: 'json', description: 'Array of user objects' },
@@ -904,6 +1250,22 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
companies: { type: 'json', description: 'Array of company objects' },
company: { type: 'json', description: 'Single company object' },
deals: { type: 'json', description: 'Array of deal objects' },
+ deal: { type: 'json', description: 'Single deal object' },
+ tickets: { type: 'json', description: 'Array of ticket objects' },
+ ticket: { type: 'json', description: 'Single ticket object' },
+ lineItems: { type: 'json', description: 'Array of line item objects' },
+ lineItem: { type: 'json', description: 'Single line item object' },
+ quotes: { type: 'json', description: 'Array of quote objects' },
+ quote: { type: 'json', description: 'Single quote object' },
+ appointments: { type: 'json', description: 'Array of appointment objects' },
+ appointment: { type: 'json', description: 'Single appointment object' },
+ carts: { type: 'json', description: 'Array of cart objects' },
+ cart: { type: 'json', description: 'Single cart object' },
+ owners: { type: 'json', description: 'Array of owner objects' },
+ events: { type: 'json', description: 'Array of marketing event objects' },
+ event: { type: 'json', description: 'Single marketing event object' },
+ lists: { type: 'json', description: 'Array of list objects' },
+ list: { type: 'json', description: 'Single list object' },
total: { type: 'number', description: 'Total number of matching results (for search)' },
paging: { type: 'json', description: 'Pagination info with next/prev cursors' },
metadata: { type: 'json', description: 'Operation metadata' },
diff --git a/apps/sim/connectors/hubspot/hubspot.ts b/apps/sim/connectors/hubspot/hubspot.ts
index 2461ec8ac82..0a8a669284d 100644
--- a/apps/sim/connectors/hubspot/hubspot.ts
+++ b/apps/sim/connectors/hubspot/hubspot.ts
@@ -185,7 +185,7 @@ export const hubspotConnector: ConnectorConfig = {
'crm.objects.contacts.read',
'crm.objects.companies.read',
'crm.objects.deals.read',
- 'crm.objects.tickets.read',
+ 'tickets',
],
},
diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts
index d7ba3a2cc4d..31f7d290fc4 100644
--- a/apps/sim/lib/oauth/oauth.ts
+++ b/apps/sim/lib/oauth/oauth.ts
@@ -886,8 +886,6 @@ export const OAUTH_PROVIDERS: Record = {
'crm.import',
'crm.lists.read',
'crm.lists.write',
- 'crm.objects.tickets.read',
- 'crm.objects.tickets.write',
'tickets',
'oauth',
],
diff --git a/apps/sim/tools/hubspot/create_appointment.ts b/apps/sim/tools/hubspot/create_appointment.ts
new file mode 100644
index 00000000000..c3d742ee028
--- /dev/null
+++ b/apps/sim/tools/hubspot/create_appointment.ts
@@ -0,0 +1,94 @@
+import { createLogger } from '@sim/logger'
+import type {
+ HubSpotCreateAppointmentParams,
+ HubSpotCreateAppointmentResponse,
+} from '@/tools/hubspot/types'
+import { APPOINTMENT_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotCreateAppointment')
+
+export const hubspotCreateAppointmentTool: ToolConfig<
+ HubSpotCreateAppointmentParams,
+ HubSpotCreateAppointmentResponse
+> = {
+ id: 'hubspot_create_appointment',
+ name: 'Create Appointment in HubSpot',
+ description: 'Create a new appointment in HubSpot',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ properties: {
+ type: 'object',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Appointment properties as JSON object (e.g., {"hs_meeting_title": "Discovery Call", "hs_meeting_start_time": "2024-01-15T10:00:00Z", "hs_meeting_end_time": "2024-01-15T11:00:00Z"})',
+ },
+ associations: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of associations to create with the appointment as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId"',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.hubapi.com/crm/v3/objects/appointments',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ let properties = params.properties
+ if (typeof properties === 'string') {
+ try {
+ properties = JSON.parse(properties)
+ } catch (e) {
+ throw new Error('Invalid JSON format for properties. Please provide a valid JSON object.')
+ }
+ }
+ const body: Record = { properties }
+ if (params.associations && params.associations.length > 0) {
+ body.associations = params.associations
+ }
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to create appointment in HubSpot')
+ }
+ return {
+ success: true,
+ output: { appointment: data, appointmentId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ appointment: APPOINTMENT_OBJECT_OUTPUT,
+ appointmentId: { type: 'string', description: 'The created appointment ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/create_deal.ts b/apps/sim/tools/hubspot/create_deal.ts
new file mode 100644
index 00000000000..5781a383362
--- /dev/null
+++ b/apps/sim/tools/hubspot/create_deal.ts
@@ -0,0 +1,91 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotCreateDealParams, HubSpotCreateDealResponse } from '@/tools/hubspot/types'
+import { DEAL_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotCreateDeal')
+
+export const hubspotCreateDealTool: ToolConfig =
+ {
+ id: 'hubspot_create_deal',
+ name: 'Create Deal in HubSpot',
+ description: 'Create a new deal in HubSpot. Requires at least a dealname property',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ properties: {
+ type: 'object',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Deal properties as JSON object. Must include dealname (e.g., {"dealname": "New Deal", "amount": "5000", "dealstage": "appointmentscheduled"})',
+ },
+ associations: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of associations to create with the deal as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId"',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.hubapi.com/crm/v3/objects/deals',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ let properties = params.properties
+ if (typeof properties === 'string') {
+ try {
+ properties = JSON.parse(properties)
+ } catch (e) {
+ throw new Error(
+ 'Invalid JSON format for properties. Please provide a valid JSON object.'
+ )
+ }
+ }
+ const body: Record = { properties }
+ if (params.associations && params.associations.length > 0) {
+ body.associations = params.associations
+ }
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to create deal in HubSpot')
+ }
+ return {
+ success: true,
+ output: { deal: data, dealId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ deal: DEAL_OBJECT_OUTPUT,
+ dealId: { type: 'string', description: 'The created deal ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+ }
diff --git a/apps/sim/tools/hubspot/create_line_item.ts b/apps/sim/tools/hubspot/create_line_item.ts
new file mode 100644
index 00000000000..ebf26c412a1
--- /dev/null
+++ b/apps/sim/tools/hubspot/create_line_item.ts
@@ -0,0 +1,94 @@
+import { createLogger } from '@sim/logger'
+import type {
+ HubSpotCreateLineItemParams,
+ HubSpotCreateLineItemResponse,
+} from '@/tools/hubspot/types'
+import { LINE_ITEM_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotCreateLineItem')
+
+export const hubspotCreateLineItemTool: ToolConfig<
+ HubSpotCreateLineItemParams,
+ HubSpotCreateLineItemResponse
+> = {
+ id: 'hubspot_create_line_item',
+ name: 'Create Line Item in HubSpot',
+ description: 'Create a new line item in HubSpot. Requires at least a name property',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ properties: {
+ type: 'object',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Line item properties as JSON object (e.g., {"name": "Product A", "quantity": "2", "price": "50.00", "hs_sku": "SKU-001"})',
+ },
+ associations: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of associations to create with the line item as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId"',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.hubapi.com/crm/v3/objects/line_items',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ let properties = params.properties
+ if (typeof properties === 'string') {
+ try {
+ properties = JSON.parse(properties)
+ } catch (e) {
+ throw new Error('Invalid JSON format for properties. Please provide a valid JSON object.')
+ }
+ }
+ const body: Record = { properties }
+ if (params.associations && params.associations.length > 0) {
+ body.associations = params.associations
+ }
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to create line item in HubSpot')
+ }
+ return {
+ success: true,
+ output: { lineItem: data, lineItemId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ lineItem: LINE_ITEM_OBJECT_OUTPUT,
+ lineItemId: { type: 'string', description: 'The created line item ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/create_list.ts b/apps/sim/tools/hubspot/create_list.ts
new file mode 100644
index 00000000000..40bc6a68d2a
--- /dev/null
+++ b/apps/sim/tools/hubspot/create_list.ts
@@ -0,0 +1,88 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotCreateListParams, HubSpotCreateListResponse } from '@/tools/hubspot/types'
+import { LIST_OUTPUT_PROPERTIES } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotCreateList')
+
+export const hubspotCreateListTool: ToolConfig =
+ {
+ id: 'hubspot_create_list',
+ name: 'Create List in HubSpot',
+ description:
+ 'Create a new list in HubSpot. Specify the object type and processing type (MANUAL or DYNAMIC)',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ name: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Name of the list',
+ },
+ objectTypeId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Object type ID (e.g., "0-1" for contacts, "0-2" for companies)',
+ },
+ processingType: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Processing type: "MANUAL" for static lists or "DYNAMIC" for active lists',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.hubapi.com/crm/v3/lists',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => ({
+ name: params.name,
+ objectTypeId: params.objectTypeId,
+ processingType: params.processingType,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to create list in HubSpot')
+ }
+ return {
+ success: true,
+ output: { list: data, listId: data.listId ?? data.id, success: true },
+ }
+ },
+
+ outputs: {
+ list: {
+ type: 'object',
+ description: 'HubSpot list',
+ properties: LIST_OUTPUT_PROPERTIES,
+ },
+ listId: { type: 'string', description: 'The created list ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+ }
diff --git a/apps/sim/tools/hubspot/create_ticket.ts b/apps/sim/tools/hubspot/create_ticket.ts
new file mode 100644
index 00000000000..b6ebaa1fa3f
--- /dev/null
+++ b/apps/sim/tools/hubspot/create_ticket.ts
@@ -0,0 +1,91 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotCreateTicketParams, HubSpotCreateTicketResponse } from '@/tools/hubspot/types'
+import { TICKET_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotCreateTicket')
+
+export const hubspotCreateTicketTool: ToolConfig<
+ HubSpotCreateTicketParams,
+ HubSpotCreateTicketResponse
+> = {
+ id: 'hubspot_create_ticket',
+ name: 'Create Ticket in HubSpot',
+ description: 'Create a new ticket in HubSpot. Requires subject and hs_pipeline_stage properties',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ properties: {
+ type: 'object',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Ticket properties as JSON object. Must include subject and hs_pipeline_stage (e.g., {"subject": "Support request", "hs_pipeline_stage": "1", "hs_ticket_priority": "HIGH"})',
+ },
+ associations: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of associations to create with the ticket as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId"',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.hubapi.com/crm/v3/objects/tickets',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ let properties = params.properties
+ if (typeof properties === 'string') {
+ try {
+ properties = JSON.parse(properties)
+ } catch (e) {
+ throw new Error('Invalid JSON format for properties. Please provide a valid JSON object.')
+ }
+ }
+ const body: Record = { properties }
+ if (params.associations && params.associations.length > 0) {
+ body.associations = params.associations
+ }
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to create ticket in HubSpot')
+ }
+ return {
+ success: true,
+ output: { ticket: data, ticketId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ ticket: TICKET_OBJECT_OUTPUT,
+ ticketId: { type: 'string', description: 'The created ticket ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/get_appointment.ts b/apps/sim/tools/hubspot/get_appointment.ts
new file mode 100644
index 00000000000..5dceb1953d1
--- /dev/null
+++ b/apps/sim/tools/hubspot/get_appointment.ts
@@ -0,0 +1,99 @@
+import { createLogger } from '@sim/logger'
+import type {
+ HubSpotGetAppointmentParams,
+ HubSpotGetAppointmentResponse,
+} from '@/tools/hubspot/types'
+import { APPOINTMENT_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotGetAppointment')
+
+export const hubspotGetAppointmentTool: ToolConfig<
+ HubSpotGetAppointmentParams,
+ HubSpotGetAppointmentResponse
+> = {
+ id: 'hubspot_get_appointment',
+ name: 'Get Appointment from HubSpot',
+ description: 'Retrieve a single appointment by ID from HubSpot',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ appointmentId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot appointment ID to retrieve',
+ },
+ idProperty: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Property to use as unique identifier. If not specified, uses record ID',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of HubSpot property names to return (e.g., "hs_meeting_title,hs_meeting_start_time")',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of object types to retrieve associated IDs for (e.g., "contacts,companies")',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/appointments/${params.appointmentId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.idProperty) queryParams.append('idProperty', params.idProperty)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to get appointment from HubSpot')
+ }
+ return {
+ success: true,
+ output: { appointment: data, appointmentId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ appointment: APPOINTMENT_OBJECT_OUTPUT,
+ appointmentId: { type: 'string', description: 'The retrieved appointment ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/get_cart.ts b/apps/sim/tools/hubspot/get_cart.ts
new file mode 100644
index 00000000000..147cfd1c7b7
--- /dev/null
+++ b/apps/sim/tools/hubspot/get_cart.ts
@@ -0,0 +1,84 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotGetCartParams, HubSpotGetCartResponse } from '@/tools/hubspot/types'
+import { GENERIC_CRM_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotGetCart')
+
+export const hubspotGetCartTool: ToolConfig = {
+ id: 'hubspot_get_cart',
+ name: 'Get Cart from HubSpot',
+ description: 'Retrieve a single cart by ID from HubSpot',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ cartId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot cart ID to retrieve',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Comma-separated list of HubSpot property names to return',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Comma-separated list of object types to retrieve associated IDs for',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/carts/${params.cartId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to get cart from HubSpot')
+ }
+ return {
+ success: true,
+ output: { cart: data, cartId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ cart: GENERIC_CRM_OBJECT_OUTPUT,
+ cartId: { type: 'string', description: 'The retrieved cart ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/get_company.ts b/apps/sim/tools/hubspot/get_company.ts
index 74c2594a222..d1cc6cb7915 100644
--- a/apps/sim/tools/hubspot/get_company.ts
+++ b/apps/sim/tools/hubspot/get_company.ts
@@ -55,7 +55,7 @@ export const hubspotGetCompanyTool: ToolConfig {
- const baseUrl = `https://api.hubapi.com/crm/v3/objects/companies/${params.companyId}`
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/companies/${params.companyId.trim()}`
const queryParams = new URLSearchParams()
if (params.idProperty) {
diff --git a/apps/sim/tools/hubspot/get_contact.ts b/apps/sim/tools/hubspot/get_contact.ts
index 42684ecd541..811c4b1ac04 100644
--- a/apps/sim/tools/hubspot/get_contact.ts
+++ b/apps/sim/tools/hubspot/get_contact.ts
@@ -55,7 +55,7 @@ export const hubspotGetContactTool: ToolConfig {
- const baseUrl = `https://api.hubapi.com/crm/v3/objects/contacts/${params.contactId}`
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/contacts/${params.contactId.trim()}`
const queryParams = new URLSearchParams()
if (params.idProperty) {
diff --git a/apps/sim/tools/hubspot/get_deal.ts b/apps/sim/tools/hubspot/get_deal.ts
new file mode 100644
index 00000000000..b42dec365aa
--- /dev/null
+++ b/apps/sim/tools/hubspot/get_deal.ts
@@ -0,0 +1,93 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotGetDealParams, HubSpotGetDealResponse } from '@/tools/hubspot/types'
+import { DEAL_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotGetDeal')
+
+export const hubspotGetDealTool: ToolConfig = {
+ id: 'hubspot_get_deal',
+ name: 'Get Deal from HubSpot',
+ description: 'Retrieve a single deal by ID from HubSpot',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ dealId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot deal ID to retrieve',
+ },
+ idProperty: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Property to use as unique identifier. If not specified, uses record ID',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of HubSpot property names to return (e.g., "dealname,amount,dealstage")',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of object types to retrieve associated IDs for (e.g., "contacts,companies")',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/deals/${params.dealId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.idProperty) queryParams.append('idProperty', params.idProperty)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to get deal from HubSpot')
+ }
+ return {
+ success: true,
+ output: { deal: data, dealId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ deal: DEAL_OBJECT_OUTPUT,
+ dealId: { type: 'string', description: 'The retrieved deal ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/get_line_item.ts b/apps/sim/tools/hubspot/get_line_item.ts
new file mode 100644
index 00000000000..7bc6925a5ad
--- /dev/null
+++ b/apps/sim/tools/hubspot/get_line_item.ts
@@ -0,0 +1,96 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotGetLineItemParams, HubSpotGetLineItemResponse } from '@/tools/hubspot/types'
+import { LINE_ITEM_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotGetLineItem')
+
+export const hubspotGetLineItemTool: ToolConfig<
+ HubSpotGetLineItemParams,
+ HubSpotGetLineItemResponse
+> = {
+ id: 'hubspot_get_line_item',
+ name: 'Get Line Item from HubSpot',
+ description: 'Retrieve a single line item by ID from HubSpot',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ lineItemId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot line item ID to retrieve',
+ },
+ idProperty: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Property to use as unique identifier. If not specified, uses record ID',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of HubSpot property names to return (e.g., "name,quantity,price,amount")',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of object types to retrieve associated IDs for (e.g., "deals,quotes")',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/line_items/${params.lineItemId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.idProperty) queryParams.append('idProperty', params.idProperty)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to get line item from HubSpot')
+ }
+ return {
+ success: true,
+ output: { lineItem: data, lineItemId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ lineItem: LINE_ITEM_OBJECT_OUTPUT,
+ lineItemId: { type: 'string', description: 'The retrieved line item ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/get_list.ts b/apps/sim/tools/hubspot/get_list.ts
new file mode 100644
index 00000000000..2edfd253635
--- /dev/null
+++ b/apps/sim/tools/hubspot/get_list.ts
@@ -0,0 +1,73 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotGetListParams, HubSpotGetListResponse } from '@/tools/hubspot/types'
+import { LIST_OUTPUT_PROPERTIES } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotGetList')
+
+export const hubspotGetListTool: ToolConfig = {
+ id: 'hubspot_get_list',
+ name: 'Get List from HubSpot',
+ description: 'Retrieve a single list by ID from HubSpot',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ listId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot list ID to retrieve',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.hubapi.com/crm/v3/lists/${params.listId.trim()}`,
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to get list from HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ list: data.list ?? data,
+ listId: data.list?.listId ?? data.listId ?? data.id,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ list: {
+ type: 'object',
+ description: 'HubSpot list',
+ properties: LIST_OUTPUT_PROPERTIES,
+ },
+ listId: { type: 'string', description: 'The retrieved list ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/get_marketing_event.ts b/apps/sim/tools/hubspot/get_marketing_event.ts
new file mode 100644
index 00000000000..1df13f27ebb
--- /dev/null
+++ b/apps/sim/tools/hubspot/get_marketing_event.ts
@@ -0,0 +1,76 @@
+import { createLogger } from '@sim/logger'
+import type {
+ HubSpotGetMarketingEventParams,
+ HubSpotGetMarketingEventResponse,
+} from '@/tools/hubspot/types'
+import { MARKETING_EVENT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotGetMarketingEvent')
+
+export const hubspotGetMarketingEventTool: ToolConfig<
+ HubSpotGetMarketingEventParams,
+ HubSpotGetMarketingEventResponse
+> = {
+ id: 'hubspot_get_marketing_event',
+ name: 'Get Marketing Event from HubSpot',
+ description: 'Retrieve a single marketing event by ID from HubSpot',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ eventId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot marketing event objectId to retrieve',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.hubapi.com/marketing/v3/marketing-events/${params.eventId.trim()}`,
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to get marketing event from HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ event: data,
+ eventId: data.objectId ?? data.id,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ event: MARKETING_EVENT_OUTPUT,
+ eventId: { type: 'string', description: 'The retrieved marketing event ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/get_quote.ts b/apps/sim/tools/hubspot/get_quote.ts
new file mode 100644
index 00000000000..e02c846e947
--- /dev/null
+++ b/apps/sim/tools/hubspot/get_quote.ts
@@ -0,0 +1,93 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotGetQuoteParams, HubSpotGetQuoteResponse } from '@/tools/hubspot/types'
+import { QUOTE_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotGetQuote')
+
+export const hubspotGetQuoteTool: ToolConfig = {
+ id: 'hubspot_get_quote',
+ name: 'Get Quote from HubSpot',
+ description: 'Retrieve a single quote by ID from HubSpot',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ quoteId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot quote ID to retrieve',
+ },
+ idProperty: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Property to use as unique identifier. If not specified, uses record ID',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of HubSpot property names to return (e.g., "hs_title,hs_expiration_date,hs_status")',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of object types to retrieve associated IDs for (e.g., "deals,line_items")',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/quotes/${params.quoteId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.idProperty) queryParams.append('idProperty', params.idProperty)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to get quote from HubSpot')
+ }
+ return {
+ success: true,
+ output: { quote: data, quoteId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ quote: QUOTE_OBJECT_OUTPUT,
+ quoteId: { type: 'string', description: 'The retrieved quote ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/get_ticket.ts b/apps/sim/tools/hubspot/get_ticket.ts
new file mode 100644
index 00000000000..4d53c722c11
--- /dev/null
+++ b/apps/sim/tools/hubspot/get_ticket.ts
@@ -0,0 +1,93 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotGetTicketParams, HubSpotGetTicketResponse } from '@/tools/hubspot/types'
+import { TICKET_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotGetTicket')
+
+export const hubspotGetTicketTool: ToolConfig = {
+ id: 'hubspot_get_ticket',
+ name: 'Get Ticket from HubSpot',
+ description: 'Retrieve a single ticket by ID from HubSpot',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ ticketId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot ticket ID to retrieve',
+ },
+ idProperty: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Property to use as unique identifier. If not specified, uses record ID',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of HubSpot property names to return (e.g., "subject,content,hs_ticket_priority")',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of object types to retrieve associated IDs for (e.g., "contacts,companies")',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/tickets/${params.ticketId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.idProperty) queryParams.append('idProperty', params.idProperty)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to get ticket from HubSpot')
+ }
+ return {
+ success: true,
+ output: { ticket: data, ticketId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ ticket: TICKET_OBJECT_OUTPUT,
+ ticketId: { type: 'string', description: 'The retrieved ticket ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/get_users.ts b/apps/sim/tools/hubspot/get_users.ts
index ad9ed869655..5a23a6d2c41 100644
--- a/apps/sim/tools/hubspot/get_users.ts
+++ b/apps/sim/tools/hubspot/get_users.ts
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import type { HubSpotGetUsersParams, HubSpotGetUsersResponse } from '@/tools/hubspot/types'
-import { USERS_ARRAY_OUTPUT } from '@/tools/hubspot/types'
+import { PAGING_OUTPUT, USERS_ARRAY_OUTPUT } from '@/tools/hubspot/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('HubSpotGetUsers')
@@ -29,6 +29,12 @@ export const hubspotGetUsersTool: ToolConfig {
+ transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok) {
@@ -70,6 +79,7 @@ export const hubspotGetUsersTool: ToolConfig = {
+ id: 'hubspot_list_appointments',
+ name: 'List Appointments from HubSpot',
+ description: 'Retrieve all appointments from HubSpot account with pagination support',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ limit: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results per page (max 100, default 10)',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor for next page of results (from previous response)',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of HubSpot property names to return (e.g., "hs_meeting_title,hs_meeting_start_time")',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of object types to retrieve associated IDs for (e.g., "contacts,companies")',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = 'https://api.hubapi.com/crm/v3/objects/appointments'
+ const queryParams = new URLSearchParams()
+ if (params.limit) queryParams.append('limit', params.limit)
+ if (params.after) queryParams.append('after', params.after)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to list appointments from HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ appointments: data.results || [],
+ paging: data.paging ?? null,
+ metadata: {
+ totalReturned: data.results?.length || 0,
+ hasMore: !!data.paging?.next,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ appointments: APPOINTMENTS_ARRAY_OUTPUT,
+ paging: PAGING_OUTPUT,
+ metadata: METADATA_OUTPUT,
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/list_carts.ts b/apps/sim/tools/hubspot/list_carts.ts
new file mode 100644
index 00000000000..14a6f75bb92
--- /dev/null
+++ b/apps/sim/tools/hubspot/list_carts.ts
@@ -0,0 +1,101 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotListCartsParams, HubSpotListCartsResponse } from '@/tools/hubspot/types'
+import { GENERIC_CRM_ARRAY_OUTPUT, METADATA_OUTPUT, PAGING_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotListCarts')
+
+export const hubspotListCartsTool: ToolConfig = {
+ id: 'hubspot_list_carts',
+ name: 'List Carts from HubSpot',
+ description: 'Retrieve all carts from HubSpot account with pagination support',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ limit: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results per page (max 100, default 10)',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor for next page of results (from previous response)',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Comma-separated list of HubSpot property names to return',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Comma-separated list of object types to retrieve associated IDs for',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = 'https://api.hubapi.com/crm/v3/objects/carts'
+ const queryParams = new URLSearchParams()
+ if (params.limit) queryParams.append('limit', params.limit)
+ if (params.after) queryParams.append('after', params.after)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to list carts from HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ carts: data.results || [],
+ paging: data.paging ?? null,
+ metadata: {
+ totalReturned: data.results?.length || 0,
+ hasMore: !!data.paging?.next,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ carts: GENERIC_CRM_ARRAY_OUTPUT,
+ paging: PAGING_OUTPUT,
+ metadata: METADATA_OUTPUT,
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/list_deals.ts b/apps/sim/tools/hubspot/list_deals.ts
index b661de80f2e..1b4f146222d 100644
--- a/apps/sim/tools/hubspot/list_deals.ts
+++ b/apps/sim/tools/hubspot/list_deals.ts
@@ -27,7 +27,7 @@ export const hubspotListDealsTool: ToolConfig = {
+ id: 'hubspot_list_line_items',
+ name: 'List Line Items from HubSpot',
+ description: 'Retrieve all line items from HubSpot account with pagination support',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ limit: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results per page (max 100, default 10)',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor for next page of results (from previous response)',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of HubSpot property names to return (e.g., "name,quantity,price,amount")',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of object types to retrieve associated IDs for (e.g., "deals,quotes")',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = 'https://api.hubapi.com/crm/v3/objects/line_items'
+ const queryParams = new URLSearchParams()
+ if (params.limit) queryParams.append('limit', params.limit)
+ if (params.after) queryParams.append('after', params.after)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to list line items from HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ lineItems: data.results || [],
+ paging: data.paging ?? null,
+ metadata: {
+ totalReturned: data.results?.length || 0,
+ hasMore: !!data.paging?.next,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ lineItems: LINE_ITEMS_ARRAY_OUTPUT,
+ paging: PAGING_OUTPUT,
+ metadata: METADATA_OUTPUT,
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/list_lists.ts b/apps/sim/tools/hubspot/list_lists.ts
new file mode 100644
index 00000000000..7fa3fbabb52
--- /dev/null
+++ b/apps/sim/tools/hubspot/list_lists.ts
@@ -0,0 +1,107 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotListListsParams, HubSpotListListsResponse } from '@/tools/hubspot/types'
+import { LISTS_ARRAY_OUTPUT, METADATA_OUTPUT_PROPERTIES, PAGING_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotListLists')
+
+export const hubspotListListsTool: ToolConfig = {
+ id: 'hubspot_list_lists',
+ name: 'List Lists from HubSpot',
+ description: 'Search and retrieve lists from HubSpot account',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ query: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Search query to filter lists by name. Leave empty to return all lists.',
+ },
+ count: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results to return (default 20, max 500)',
+ },
+ offset: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Pagination offset for next page of results (use the offset value from previous response)',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.hubapi.com/crm/v3/lists/search',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ const body: Record = {
+ offset: params.offset ? Number(params.offset) : 0,
+ }
+ if (params.query) body.query = params.query
+ if (params.count) body.count = Number(params.count)
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to list lists from HubSpot')
+ }
+ const lists = data.lists ?? []
+ return {
+ success: true,
+ output: {
+ lists,
+ paging:
+ data.hasMore === true && data.offset != null
+ ? { next: { after: String(data.offset) } }
+ : undefined,
+ metadata: {
+ totalReturned: lists.length,
+ total: data.total ?? null,
+ hasMore: data.hasMore === true,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ lists: LISTS_ARRAY_OUTPUT,
+ paging: PAGING_OUTPUT,
+ metadata: {
+ type: 'object',
+ description: 'Response metadata',
+ properties: {
+ ...METADATA_OUTPUT_PROPERTIES,
+ total: { type: 'number', description: 'Total number of lists matching the query', optional: true },
+ },
+ },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/list_marketing_events.ts b/apps/sim/tools/hubspot/list_marketing_events.ts
new file mode 100644
index 00000000000..2d77bec60ec
--- /dev/null
+++ b/apps/sim/tools/hubspot/list_marketing_events.ts
@@ -0,0 +1,98 @@
+import { createLogger } from '@sim/logger'
+import type {
+ HubSpotListMarketingEventsParams,
+ HubSpotListMarketingEventsResponse,
+} from '@/tools/hubspot/types'
+import {
+ MARKETING_EVENTS_ARRAY_OUTPUT,
+ METADATA_OUTPUT,
+ PAGING_OUTPUT,
+} from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotListMarketingEvents')
+
+export const hubspotListMarketingEventsTool: ToolConfig<
+ HubSpotListMarketingEventsParams,
+ HubSpotListMarketingEventsResponse
+> = {
+ id: 'hubspot_list_marketing_events',
+ name: 'List Marketing Events from HubSpot',
+ description: 'Retrieve all marketing events from HubSpot account with pagination support',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ limit: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results per page (max 100, default 10)',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor for next page of results (from previous response)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = 'https://api.hubapi.com/marketing/v3/marketing-events'
+ const queryParams = new URLSearchParams()
+ if (params.limit) queryParams.append('limit', params.limit)
+ if (params.after) queryParams.append('after', params.after)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to list marketing events from HubSpot')
+ }
+ const results = data.results || []
+ return {
+ success: true,
+ output: {
+ events: results,
+ paging: data.paging ?? null,
+ metadata: {
+ totalReturned: results.length,
+ hasMore: !!data.paging?.next,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ events: MARKETING_EVENTS_ARRAY_OUTPUT,
+ paging: PAGING_OUTPUT,
+ metadata: METADATA_OUTPUT,
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/list_owners.ts b/apps/sim/tools/hubspot/list_owners.ts
new file mode 100644
index 00000000000..c7f2c6cf6e6
--- /dev/null
+++ b/apps/sim/tools/hubspot/list_owners.ts
@@ -0,0 +1,95 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotListOwnersParams, HubSpotListOwnersResponse } from '@/tools/hubspot/types'
+import { METADATA_OUTPUT, OWNERS_ARRAY_OUTPUT, PAGING_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotListOwners')
+
+export const hubspotListOwnersTool: ToolConfig =
+ {
+ id: 'hubspot_list_owners',
+ name: 'List Owners from HubSpot',
+ description: 'Retrieve all owners from HubSpot account with pagination support',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ limit: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results per page (max 100, default 100)',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor for next page of results (from previous response)',
+ },
+ email: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Filter owners by email address',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = 'https://api.hubapi.com/crm/v3/owners'
+ const queryParams = new URLSearchParams()
+ if (params.limit) queryParams.append('limit', params.limit)
+ if (params.after) queryParams.append('after', params.after)
+ if (params.email) queryParams.append('email', params.email)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to list owners from HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ owners: data.results || [],
+ paging: data.paging ?? null,
+ metadata: {
+ totalReturned: data.results?.length || 0,
+ hasMore: !!data.paging?.next,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ owners: OWNERS_ARRAY_OUTPUT,
+ paging: PAGING_OUTPUT,
+ metadata: METADATA_OUTPUT,
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+ }
diff --git a/apps/sim/tools/hubspot/list_quotes.ts b/apps/sim/tools/hubspot/list_quotes.ts
new file mode 100644
index 00000000000..4983a47b230
--- /dev/null
+++ b/apps/sim/tools/hubspot/list_quotes.ts
@@ -0,0 +1,104 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotListQuotesParams, HubSpotListQuotesResponse } from '@/tools/hubspot/types'
+import { METADATA_OUTPUT, PAGING_OUTPUT, QUOTES_ARRAY_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotListQuotes')
+
+export const hubspotListQuotesTool: ToolConfig =
+ {
+ id: 'hubspot_list_quotes',
+ name: 'List Quotes from HubSpot',
+ description: 'Retrieve all quotes from HubSpot account with pagination support',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ limit: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results per page (max 100, default 10)',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor for next page of results (from previous response)',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of HubSpot property names to return (e.g., "hs_title,hs_expiration_date,hs_status")',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of object types to retrieve associated IDs for (e.g., "deals,line_items")',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = 'https://api.hubapi.com/crm/v3/objects/quotes'
+ const queryParams = new URLSearchParams()
+ if (params.limit) queryParams.append('limit', params.limit)
+ if (params.after) queryParams.append('after', params.after)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to list quotes from HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ quotes: data.results || [],
+ paging: data.paging ?? null,
+ metadata: {
+ totalReturned: data.results?.length || 0,
+ hasMore: !!data.paging?.next,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ quotes: QUOTES_ARRAY_OUTPUT,
+ paging: PAGING_OUTPUT,
+ metadata: METADATA_OUTPUT,
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+ }
diff --git a/apps/sim/tools/hubspot/list_tickets.ts b/apps/sim/tools/hubspot/list_tickets.ts
new file mode 100644
index 00000000000..098af1d0888
--- /dev/null
+++ b/apps/sim/tools/hubspot/list_tickets.ts
@@ -0,0 +1,106 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotListTicketsParams, HubSpotListTicketsResponse } from '@/tools/hubspot/types'
+import { METADATA_OUTPUT, PAGING_OUTPUT, TICKETS_ARRAY_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotListTickets')
+
+export const hubspotListTicketsTool: ToolConfig<
+ HubSpotListTicketsParams,
+ HubSpotListTicketsResponse
+> = {
+ id: 'hubspot_list_tickets',
+ name: 'List Tickets from HubSpot',
+ description: 'Retrieve all tickets from HubSpot account with pagination support',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ limit: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results per page (max 100, default 10)',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor for next page of results (from previous response)',
+ },
+ properties: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of HubSpot property names to return (e.g., "subject,content,hs_ticket_priority")',
+ },
+ associations: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Comma-separated list of object types to retrieve associated IDs for (e.g., "contacts,companies")',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = 'https://api.hubapi.com/crm/v3/objects/tickets'
+ const queryParams = new URLSearchParams()
+ if (params.limit) queryParams.append('limit', params.limit)
+ if (params.after) queryParams.append('after', params.after)
+ if (params.properties) queryParams.append('properties', params.properties)
+ if (params.associations) queryParams.append('associations', params.associations)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to list tickets from HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ tickets: data.results || [],
+ paging: data.paging ?? null,
+ metadata: {
+ totalReturned: data.results?.length || 0,
+ hasMore: !!data.paging?.next,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ tickets: TICKETS_ARRAY_OUTPUT,
+ paging: PAGING_OUTPUT,
+ metadata: METADATA_OUTPUT,
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/search_companies.ts b/apps/sim/tools/hubspot/search_companies.ts
index ec2f92457a8..e05ddb8c169 100644
--- a/apps/sim/tools/hubspot/search_companies.ts
+++ b/apps/sim/tools/hubspot/search_companies.ts
@@ -34,7 +34,7 @@ export const hubspotSearchCompaniesTool: ToolConfig<
required: false,
visibility: 'user-or-llm',
description:
- 'Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" (e.g., "EQ", "CONTAINS"), and "value"',
+ 'Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" (e.g., "EQ", "CONTAINS_TOKEN", "GT"), and "value"',
},
sorts: {
type: 'array',
@@ -152,7 +152,7 @@ export const hubspotSearchCompaniesTool: ToolConfig<
success: true,
output: {
companies: data.results || [],
- total: data.total ?? null,
+ total: data.total ?? 0,
paging: data.paging ?? null,
metadata: {
totalReturned: data.results?.length || 0,
diff --git a/apps/sim/tools/hubspot/search_contacts.ts b/apps/sim/tools/hubspot/search_contacts.ts
index f7b375bd544..44fd75f2687 100644
--- a/apps/sim/tools/hubspot/search_contacts.ts
+++ b/apps/sim/tools/hubspot/search_contacts.ts
@@ -34,7 +34,7 @@ export const hubspotSearchContactsTool: ToolConfig<
required: false,
visibility: 'user-or-llm',
description:
- 'Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" (e.g., "EQ", "CONTAINS"), and "value"',
+ 'Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" (e.g., "EQ", "CONTAINS_TOKEN", "GT"), and "value"',
},
sorts: {
type: 'array',
@@ -152,7 +152,7 @@ export const hubspotSearchContactsTool: ToolConfig<
success: true,
output: {
contacts: data.results || [],
- total: data.total ?? null,
+ total: data.total ?? 0,
paging: data.paging ?? null,
metadata: {
totalReturned: data.results?.length || 0,
diff --git a/apps/sim/tools/hubspot/search_deals.ts b/apps/sim/tools/hubspot/search_deals.ts
new file mode 100644
index 00000000000..873aa52d4cb
--- /dev/null
+++ b/apps/sim/tools/hubspot/search_deals.ts
@@ -0,0 +1,152 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotSearchDealsParams, HubSpotSearchDealsResponse } from '@/tools/hubspot/types'
+import { DEALS_ARRAY_OUTPUT, METADATA_OUTPUT, PAGING_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotSearchDeals')
+
+export const hubspotSearchDealsTool: ToolConfig<
+ HubSpotSearchDealsParams,
+ HubSpotSearchDealsResponse
+> = {
+ id: 'hubspot_search_deals',
+ name: 'Search Deals in HubSpot',
+ description: 'Search for deals in HubSpot using filters, sorting, and queries',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ filterGroups: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" (e.g., "EQ", "NEQ", "CONTAINS_TOKEN", "NOT_CONTAINS_TOKEN"), and "value"',
+ },
+ sorts: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of sort objects as JSON with "propertyName" and "direction" ("ASCENDING" or "DESCENDING")',
+ },
+ query: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Search query string to match against deal name and other text fields',
+ },
+ properties: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of HubSpot property names to return (e.g., ["dealname", "amount", "dealstage"])',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results to return (max 200)',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor for next page (from previous response)',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.hubapi.com/crm/v3/objects/deals/search',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ const body: Record = {}
+ if (params.filterGroups) {
+ let parsed = params.filterGroups
+ if (typeof parsed === 'string') {
+ try {
+ parsed = JSON.parse(parsed)
+ } catch (e) {
+ throw new Error(`Invalid JSON for filterGroups: ${(e as Error).message}`)
+ }
+ }
+ if (Array.isArray(parsed) && parsed.length > 0) body.filterGroups = parsed
+ }
+ if (params.sorts) {
+ let parsed = params.sorts
+ if (typeof parsed === 'string') {
+ try {
+ parsed = JSON.parse(parsed)
+ } catch (e) {
+ throw new Error(`Invalid JSON for sorts: ${(e as Error).message}`)
+ }
+ }
+ if (Array.isArray(parsed) && parsed.length > 0) body.sorts = parsed
+ }
+ if (params.query) body.query = params.query
+ if (params.properties) {
+ let parsed = params.properties
+ if (typeof parsed === 'string') {
+ try {
+ parsed = JSON.parse(parsed)
+ } catch (e) {
+ throw new Error(`Invalid JSON for properties: ${(e as Error).message}`)
+ }
+ }
+ if (Array.isArray(parsed) && parsed.length > 0) body.properties = parsed
+ }
+ if (params.limit) body.limit = params.limit
+ if (params.after) body.after = params.after
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to search deals in HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ deals: data.results || [],
+ total: data.total ?? 0,
+ paging: data.paging ?? null,
+ metadata: {
+ totalReturned: data.results?.length || 0,
+ hasMore: !!data.paging?.next,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ deals: DEALS_ARRAY_OUTPUT,
+ total: { type: 'number', description: 'Total number of matching deals', optional: true },
+ paging: PAGING_OUTPUT,
+ metadata: METADATA_OUTPUT,
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/search_tickets.ts b/apps/sim/tools/hubspot/search_tickets.ts
new file mode 100644
index 00000000000..f7d7530f7d4
--- /dev/null
+++ b/apps/sim/tools/hubspot/search_tickets.ts
@@ -0,0 +1,155 @@
+import { createLogger } from '@sim/logger'
+import type {
+ HubSpotSearchTicketsParams,
+ HubSpotSearchTicketsResponse,
+} from '@/tools/hubspot/types'
+import { METADATA_OUTPUT, PAGING_OUTPUT, TICKETS_ARRAY_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotSearchTickets')
+
+export const hubspotSearchTicketsTool: ToolConfig<
+ HubSpotSearchTicketsParams,
+ HubSpotSearchTicketsResponse
+> = {
+ id: 'hubspot_search_tickets',
+ name: 'Search Tickets in HubSpot',
+ description: 'Search for tickets in HubSpot using filters, sorting, and queries',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ filterGroups: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" (e.g., "EQ", "NEQ", "CONTAINS_TOKEN", "NOT_CONTAINS_TOKEN"), and "value"',
+ },
+ sorts: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of sort objects as JSON with "propertyName" and "direction" ("ASCENDING" or "DESCENDING")',
+ },
+ query: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Search query string to match against ticket subject and other text fields',
+ },
+ properties: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of HubSpot property names to return (e.g., ["subject", "content", "hs_ticket_priority"])',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of results to return (max 200)',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor for next page (from previous response)',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.hubapi.com/crm/v3/objects/tickets/search',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ const body: Record = {}
+ if (params.filterGroups) {
+ let parsed = params.filterGroups
+ if (typeof parsed === 'string') {
+ try {
+ parsed = JSON.parse(parsed)
+ } catch (e) {
+ throw new Error(`Invalid JSON for filterGroups: ${(e as Error).message}`)
+ }
+ }
+ if (Array.isArray(parsed) && parsed.length > 0) body.filterGroups = parsed
+ }
+ if (params.sorts) {
+ let parsed = params.sorts
+ if (typeof parsed === 'string') {
+ try {
+ parsed = JSON.parse(parsed)
+ } catch (e) {
+ throw new Error(`Invalid JSON for sorts: ${(e as Error).message}`)
+ }
+ }
+ if (Array.isArray(parsed) && parsed.length > 0) body.sorts = parsed
+ }
+ if (params.query) body.query = params.query
+ if (params.properties) {
+ let parsed = params.properties
+ if (typeof parsed === 'string') {
+ try {
+ parsed = JSON.parse(parsed)
+ } catch (e) {
+ throw new Error(`Invalid JSON for properties: ${(e as Error).message}`)
+ }
+ }
+ if (Array.isArray(parsed) && parsed.length > 0) body.properties = parsed
+ }
+ if (params.limit) body.limit = params.limit
+ if (params.after) body.after = params.after
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to search tickets in HubSpot')
+ }
+ return {
+ success: true,
+ output: {
+ tickets: data.results || [],
+ total: data.total ?? 0,
+ paging: data.paging ?? null,
+ metadata: {
+ totalReturned: data.results?.length || 0,
+ hasMore: !!data.paging?.next,
+ },
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ tickets: TICKETS_ARRAY_OUTPUT,
+ total: { type: 'number', description: 'Total number of matching tickets', optional: true },
+ paging: PAGING_OUTPUT,
+ metadata: METADATA_OUTPUT,
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/types.ts b/apps/sim/tools/hubspot/types.ts
index 89fd757117d..ba0bd5e4e53 100644
--- a/apps/sim/tools/hubspot/types.ts
+++ b/apps/sim/tools/hubspot/types.ts
@@ -291,6 +291,394 @@ export const DEALS_ARRAY_OUTPUT: OutputProperty = {
},
}
+/**
+ * Common ticket properties returned by HubSpot API.
+ * Default properties: subject, content, hs_pipeline, hs_pipeline_stage, hs_ticket_priority, hs_ticket_category.
+ * @see https://developers.hubspot.com/docs/api/crm/tickets
+ */
+export const TICKET_PROPERTIES_OUTPUT = {
+ subject: { type: 'string', description: 'Ticket subject/name' },
+ content: { type: 'string', description: 'Ticket content/description' },
+ hs_pipeline: { type: 'string', description: 'Pipeline the ticket is in' },
+ hs_pipeline_stage: { type: 'string', description: 'Current pipeline stage' },
+ hs_ticket_priority: { type: 'string', description: 'Ticket priority (LOW, MEDIUM, HIGH)' },
+ hs_ticket_category: { type: 'string', description: 'Ticket category' },
+ hubspot_owner_id: { type: 'string', description: 'HubSpot owner ID' },
+ hs_object_id: { type: 'string', description: 'HubSpot object ID (same as record ID)' },
+ createdate: { type: 'string', description: 'Ticket creation date (ISO 8601)' },
+ hs_lastmodifieddate: { type: 'string', description: 'Last modified date (ISO 8601)' },
+} as const satisfies Record
+
+/**
+ * Common line item properties returned by HubSpot API.
+ * @see https://developers.hubspot.com/docs/api/crm/line-items
+ */
+export const LINE_ITEM_PROPERTIES_OUTPUT = {
+ name: { type: 'string', description: 'Line item name' },
+ description: { type: 'string', description: 'Full description of the product' },
+ hs_sku: { type: 'string', description: 'Unique product identifier (SKU)' },
+ quantity: { type: 'string', description: 'Number of units included' },
+ price: { type: 'string', description: 'Unit price' },
+ amount: { type: 'string', description: 'Total cost (quantity * unit price)' },
+ hs_line_item_currency_code: { type: 'string', description: 'Currency code' },
+ recurringbillingfrequency: { type: 'string', description: 'Recurring billing frequency' },
+ hs_recurring_billing_start_date: {
+ type: 'string',
+ description: 'Recurring billing start date',
+ },
+ hs_recurring_billing_end_date: { type: 'string', description: 'Recurring billing end date' },
+ hs_object_id: { type: 'string', description: 'HubSpot object ID (same as record ID)' },
+ createdate: { type: 'string', description: 'Creation date (ISO 8601)' },
+ hs_lastmodifieddate: { type: 'string', description: 'Last modified date (ISO 8601)' },
+} as const satisfies Record
+
+/**
+ * Common quote properties returned by HubSpot API.
+ * @see https://developers.hubspot.com/docs/api/crm/quotes
+ */
+export const QUOTE_PROPERTIES_OUTPUT = {
+ hs_title: { type: 'string', description: 'Quote name/title' },
+ hs_expiration_date: { type: 'string', description: 'Expiration date' },
+ hs_status: { type: 'string', description: 'Quote status' },
+ hs_esign_enabled: { type: 'string', description: 'Whether e-signatures are enabled' },
+ hs_object_id: { type: 'string', description: 'HubSpot object ID (same as record ID)' },
+ createdate: { type: 'string', description: 'Creation date (ISO 8601)' },
+ hs_lastmodifieddate: { type: 'string', description: 'Last modified date (ISO 8601)' },
+} as const satisfies Record
+
+/**
+ * Common appointment properties returned by HubSpot API.
+ * @see https://developers.hubspot.com/docs/api/crm/appointments
+ */
+export const APPOINTMENT_PROPERTIES_OUTPUT = {
+ hs_appointment_type: { type: 'string', description: 'Appointment type' },
+ hs_meeting_title: { type: 'string', description: 'Meeting title' },
+ hs_meeting_start_time: { type: 'string', description: 'Start time (ISO 8601)' },
+ hs_meeting_end_time: { type: 'string', description: 'End time (ISO 8601)' },
+ hs_meeting_location: { type: 'string', description: 'Meeting location' },
+ hubspot_owner_id: { type: 'string', description: 'HubSpot owner ID' },
+ hs_object_id: { type: 'string', description: 'HubSpot object ID (same as record ID)' },
+ hs_createdate: { type: 'string', description: 'Creation date (ISO 8601)' },
+ hs_lastmodifieddate: { type: 'string', description: 'Last modified date (ISO 8601)' },
+} as const satisfies Record
+
+/**
+ * Owner properties returned by HubSpot Owners API v3.
+ * @see https://developers.hubspot.com/docs/api/crm/owners
+ */
+export const OWNER_OUTPUT_PROPERTIES = {
+ id: { type: 'string', description: 'Owner ID' },
+ email: { type: 'string', description: 'Owner email address' },
+ firstName: { type: 'string', description: 'Owner first name' },
+ lastName: { type: 'string', description: 'Owner last name' },
+ userId: { type: 'number', description: 'Associated user ID', optional: true },
+ teams: {
+ type: 'array',
+ description: 'Teams the owner belongs to',
+ optional: true,
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Team ID' },
+ name: { type: 'string', description: 'Team name' },
+ },
+ },
+ },
+ createdAt: { type: 'string', description: 'Creation date (ISO 8601)' },
+ updatedAt: { type: 'string', description: 'Last updated date (ISO 8601)' },
+ archived: { type: 'boolean', description: 'Whether the owner is archived' },
+} as const satisfies Record
+
+/**
+ * Marketing event properties returned by HubSpot Marketing Events API.
+ * Response is flat (not CRM envelope) — fields are at the top level.
+ * @see https://developers.hubspot.com/docs/api/marketing/marketing-events
+ */
+export const MARKETING_EVENT_OUTPUT_PROPERTIES = {
+ objectId: { type: 'string', description: 'Unique event ID (HubSpot internal)' },
+ eventName: { type: 'string', description: 'Event name' },
+ eventType: { type: 'string', description: 'Event type', optional: true },
+ eventStatus: { type: 'string', description: 'Event status', optional: true },
+ eventDescription: { type: 'string', description: 'Event description', optional: true },
+ eventUrl: { type: 'string', description: 'Event URL', optional: true },
+ eventOrganizer: { type: 'string', description: 'Event organizer', optional: true },
+ startDateTime: { type: 'string', description: 'Start date/time (ISO 8601)', optional: true },
+ endDateTime: { type: 'string', description: 'End date/time (ISO 8601)', optional: true },
+ eventCancelled: { type: 'boolean', description: 'Whether event is cancelled', optional: true },
+ eventCompleted: { type: 'boolean', description: 'Whether event is completed', optional: true },
+ registrants: { type: 'number', description: 'Number of registrants', optional: true },
+ attendees: { type: 'number', description: 'Number of attendees', optional: true },
+ cancellations: { type: 'number', description: 'Number of cancellations', optional: true },
+ noShows: { type: 'number', description: 'Number of no-shows', optional: true },
+ externalEventId: { type: 'string', description: 'External event ID', optional: true },
+ createdAt: { type: 'string', description: 'Creation date (ISO 8601)' },
+ updatedAt: { type: 'string', description: 'Last updated date (ISO 8601)' },
+} as const satisfies Record
+
+/**
+ * Single marketing event output definition.
+ */
+export const MARKETING_EVENT_OUTPUT: OutputProperty = {
+ type: 'object',
+ description: 'HubSpot marketing event',
+ properties: MARKETING_EVENT_OUTPUT_PROPERTIES,
+}
+
+/**
+ * List properties returned by HubSpot Lists API v3.
+ * @see https://developers.hubspot.com/docs/api/crm/lists
+ */
+export const LIST_OUTPUT_PROPERTIES = {
+ listId: { type: 'string', description: 'List ID' },
+ name: { type: 'string', description: 'List name' },
+ objectTypeId: { type: 'string', description: 'Object type ID (e.g., 0-1 for contacts)' },
+ processingType: { type: 'string', description: 'Processing type (MANUAL, DYNAMIC, SNAPSHOT)' },
+ processingStatus: {
+ type: 'string',
+ description: 'Processing status (COMPLETE, PROCESSING)',
+ optional: true,
+ },
+ listVersion: { type: 'number', description: 'List version number', optional: true },
+ createdAt: { type: 'string', description: 'Creation date (ISO 8601)', optional: true },
+ updatedAt: { type: 'string', description: 'Last updated date (ISO 8601)', optional: true },
+} as const satisfies Record
+
+/**
+ * Ticket object output definition with nested properties.
+ */
+export const TICKET_OBJECT_OUTPUT: OutputProperty = {
+ type: 'object',
+ description: 'HubSpot ticket record',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: {
+ type: 'object',
+ description: 'Ticket properties',
+ properties: TICKET_PROPERTIES_OUTPUT,
+ },
+ associations: {
+ type: 'object',
+ description: 'Associated records (contacts, companies, etc.)',
+ optional: true,
+ },
+ },
+}
+
+/**
+ * Line item object output definition with nested properties.
+ */
+export const LINE_ITEM_OBJECT_OUTPUT: OutputProperty = {
+ type: 'object',
+ description: 'HubSpot line item record',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: {
+ type: 'object',
+ description: 'Line item properties',
+ properties: LINE_ITEM_PROPERTIES_OUTPUT,
+ },
+ associations: {
+ type: 'object',
+ description: 'Associated records (deals, quotes, etc.)',
+ optional: true,
+ },
+ },
+}
+
+/**
+ * Quote object output definition with nested properties.
+ */
+export const QUOTE_OBJECT_OUTPUT: OutputProperty = {
+ type: 'object',
+ description: 'HubSpot quote record',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: {
+ type: 'object',
+ description: 'Quote properties',
+ properties: QUOTE_PROPERTIES_OUTPUT,
+ },
+ associations: {
+ type: 'object',
+ description: 'Associated records (deals, line items, etc.)',
+ optional: true,
+ },
+ },
+}
+
+/**
+ * Appointment object output definition with nested properties.
+ */
+export const APPOINTMENT_OBJECT_OUTPUT: OutputProperty = {
+ type: 'object',
+ description: 'HubSpot appointment record',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: {
+ type: 'object',
+ description: 'Appointment properties',
+ properties: APPOINTMENT_PROPERTIES_OUTPUT,
+ },
+ associations: {
+ type: 'object',
+ description: 'Associated records (contacts, companies, etc.)',
+ optional: true,
+ },
+ },
+}
+
+/**
+ * Generic CRM object output for objects with dynamic properties (carts).
+ */
+export const GENERIC_CRM_OBJECT_OUTPUT: OutputProperty = {
+ type: 'object',
+ description: 'HubSpot CRM record',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: {
+ type: 'object',
+ description: 'Record properties',
+ },
+ associations: {
+ type: 'object',
+ description: 'Associated records',
+ optional: true,
+ },
+ },
+}
+
+/**
+ * Tickets array output definition for list endpoints.
+ */
+export const TICKETS_ARRAY_OUTPUT: OutputProperty = {
+ type: 'array',
+ description: 'Array of HubSpot ticket records',
+ items: {
+ type: 'object',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: {
+ type: 'object',
+ description: 'Ticket properties',
+ properties: TICKET_PROPERTIES_OUTPUT,
+ },
+ associations: { type: 'object', description: 'Associated records', optional: true },
+ },
+ },
+}
+
+/**
+ * Line items array output definition for list endpoints.
+ */
+export const LINE_ITEMS_ARRAY_OUTPUT: OutputProperty = {
+ type: 'array',
+ description: 'Array of HubSpot line item records',
+ items: {
+ type: 'object',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: {
+ type: 'object',
+ description: 'Line item properties',
+ properties: LINE_ITEM_PROPERTIES_OUTPUT,
+ },
+ associations: { type: 'object', description: 'Associated records', optional: true },
+ },
+ },
+}
+
+/**
+ * Quotes array output definition for list endpoints.
+ */
+export const QUOTES_ARRAY_OUTPUT: OutputProperty = {
+ type: 'array',
+ description: 'Array of HubSpot quote records',
+ items: {
+ type: 'object',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: {
+ type: 'object',
+ description: 'Quote properties',
+ properties: QUOTE_PROPERTIES_OUTPUT,
+ },
+ associations: { type: 'object', description: 'Associated records', optional: true },
+ },
+ },
+}
+
+/**
+ * Appointments array output definition for list endpoints.
+ */
+export const APPOINTMENTS_ARRAY_OUTPUT: OutputProperty = {
+ type: 'array',
+ description: 'Array of HubSpot appointment records',
+ items: {
+ type: 'object',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: {
+ type: 'object',
+ description: 'Appointment properties',
+ properties: APPOINTMENT_PROPERTIES_OUTPUT,
+ },
+ associations: { type: 'object', description: 'Associated records', optional: true },
+ },
+ },
+}
+
+/**
+ * Generic CRM objects array output for objects with dynamic properties (carts).
+ */
+export const GENERIC_CRM_ARRAY_OUTPUT: OutputProperty = {
+ type: 'array',
+ description: 'Array of HubSpot CRM records',
+ items: {
+ type: 'object',
+ properties: {
+ ...CRM_RECORD_BASE_OUTPUT_PROPERTIES,
+ properties: { type: 'object', description: 'Record properties' },
+ associations: { type: 'object', description: 'Associated records', optional: true },
+ },
+ },
+}
+
+/**
+ * Owners array output definition for list endpoints.
+ */
+export const OWNERS_ARRAY_OUTPUT: OutputProperty = {
+ type: 'array',
+ description: 'Array of HubSpot owner objects',
+ items: {
+ type: 'object',
+ properties: OWNER_OUTPUT_PROPERTIES,
+ },
+}
+
+/**
+ * Marketing events array output definition for list endpoints.
+ */
+export const MARKETING_EVENTS_ARRAY_OUTPUT: OutputProperty = {
+ type: 'array',
+ description: 'Array of HubSpot marketing event objects',
+ items: {
+ type: 'object',
+ properties: MARKETING_EVENT_OUTPUT_PROPERTIES,
+ },
+}
+
+/**
+ * Lists array output definition for list endpoints.
+ */
+export const LISTS_ARRAY_OUTPUT: OutputProperty = {
+ type: 'array',
+ description: 'Array of HubSpot list objects',
+ items: {
+ type: 'object',
+ properties: LIST_OUTPUT_PROPERTIES,
+ },
+}
+
/**
* User properties returned by HubSpot Settings API v3.
* Note: firstName and lastName are NOT returned by the Settings API.
@@ -334,7 +722,7 @@ export interface HubSpotUser {
superAdmin?: boolean
}
-export interface HubSpotContact {
+export interface HubSpotCrmObject {
id: string
properties: Record
createdAt: string
@@ -343,6 +731,9 @@ export interface HubSpotContact {
associations?: Record
}
+/** @deprecated Use HubSpotCrmObject instead */
+export type HubSpotContact = HubSpotCrmObject
+
export interface HubSpotPaging {
next?: {
after: string
@@ -354,6 +745,7 @@ export interface HubSpotPaging {
export interface HubSpotGetUsersResponse extends ToolResponse {
output: {
users: HubSpotUser[]
+ paging: HubSpotPaging | null
totalItems?: number
success: boolean
}
@@ -362,6 +754,7 @@ export interface HubSpotGetUsersResponse extends ToolResponse {
export interface HubSpotGetUsersParams {
accessToken: string
limit?: string
+ after?: string
}
// List Contacts
@@ -473,7 +866,7 @@ export interface HubSpotSearchContactsParams {
}
// Companies (same structure as contacts)
-export type HubSpotCompany = HubSpotContact
+export type HubSpotCompany = HubSpotCrmObject
export type HubSpotListCompaniesParams = HubSpotListContactsParams
export type HubSpotListCompaniesResponse = Omit & {
output: {
@@ -524,17 +917,301 @@ export interface HubSpotSearchCompaniesResponse extends ToolResponse {
}
}
-// Deals (same structure as contacts)
-export type HubSpotDeal = HubSpotContact
+// Deals
+export type HubSpotDeal = HubSpotCrmObject
export type HubSpotListDealsParams = HubSpotListContactsParams
export type HubSpotListDealsResponse = Omit & {
output: {
deals: HubSpotContact[]
paging?: HubSpotPaging
- metadata: {
- totalReturned: number
- hasMore: boolean
- }
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+export type HubSpotGetDealParams = Omit & { dealId: string }
+export type HubSpotGetDealResponse = ToolResponse & {
+ output: { deal: HubSpotContact; dealId: string; success: boolean }
+}
+export type HubSpotCreateDealParams = HubSpotCreateContactParams
+export type HubSpotCreateDealResponse = ToolResponse & {
+ output: { deal: HubSpotContact; dealId: string; success: boolean }
+}
+export type HubSpotUpdateDealParams = Omit & {
+ dealId: string
+}
+export type HubSpotUpdateDealResponse = ToolResponse & {
+ output: { deal: HubSpotContact; dealId: string; success: boolean }
+}
+export type HubSpotSearchDealsParams = HubSpotSearchContactsParams
+export interface HubSpotSearchDealsResponse extends ToolResponse {
+ output: {
+ deals: HubSpotContact[]
+ total: number
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+
+// Tickets
+export type HubSpotTicket = HubSpotCrmObject
+export type HubSpotListTicketsParams = HubSpotListContactsParams
+export type HubSpotListTicketsResponse = ToolResponse & {
+ output: {
+ tickets: HubSpotContact[]
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+export type HubSpotGetTicketParams = Omit & {
+ ticketId: string
+}
+export type HubSpotGetTicketResponse = ToolResponse & {
+ output: { ticket: HubSpotContact; ticketId: string; success: boolean }
+}
+export type HubSpotCreateTicketParams = HubSpotCreateContactParams
+export type HubSpotCreateTicketResponse = ToolResponse & {
+ output: { ticket: HubSpotContact; ticketId: string; success: boolean }
+}
+export type HubSpotUpdateTicketParams = Omit & {
+ ticketId: string
+}
+export type HubSpotUpdateTicketResponse = ToolResponse & {
+ output: { ticket: HubSpotContact; ticketId: string; success: boolean }
+}
+export type HubSpotSearchTicketsParams = HubSpotSearchContactsParams
+export interface HubSpotSearchTicketsResponse extends ToolResponse {
+ output: {
+ tickets: HubSpotContact[]
+ total: number
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+
+// Line Items
+export type HubSpotLineItem = HubSpotCrmObject
+export type HubSpotListLineItemsParams = HubSpotListContactsParams
+export type HubSpotListLineItemsResponse = ToolResponse & {
+ output: {
+ lineItems: HubSpotContact[]
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+export type HubSpotGetLineItemParams = Omit & {
+ lineItemId: string
+}
+export type HubSpotGetLineItemResponse = ToolResponse & {
+ output: { lineItem: HubSpotContact; lineItemId: string; success: boolean }
+}
+export type HubSpotCreateLineItemParams = HubSpotCreateContactParams
+export type HubSpotCreateLineItemResponse = ToolResponse & {
+ output: { lineItem: HubSpotContact; lineItemId: string; success: boolean }
+}
+export type HubSpotUpdateLineItemParams = Omit & {
+ lineItemId: string
+}
+export type HubSpotUpdateLineItemResponse = ToolResponse & {
+ output: { lineItem: HubSpotContact; lineItemId: string; success: boolean }
+}
+
+// Quotes
+export type HubSpotQuote = HubSpotCrmObject
+export type HubSpotListQuotesParams = HubSpotListContactsParams
+export type HubSpotListQuotesResponse = ToolResponse & {
+ output: {
+ quotes: HubSpotContact[]
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+export type HubSpotGetQuoteParams = Omit & {
+ quoteId: string
+}
+export type HubSpotGetQuoteResponse = ToolResponse & {
+ output: { quote: HubSpotContact; quoteId: string; success: boolean }
+}
+
+// Appointments
+export type HubSpotAppointment = HubSpotCrmObject
+export type HubSpotListAppointmentsParams = HubSpotListContactsParams
+export type HubSpotListAppointmentsResponse = ToolResponse & {
+ output: {
+ appointments: HubSpotContact[]
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+export type HubSpotGetAppointmentParams = Omit & {
+ appointmentId: string
+}
+export type HubSpotGetAppointmentResponse = ToolResponse & {
+ output: { appointment: HubSpotContact; appointmentId: string; success: boolean }
+}
+export type HubSpotCreateAppointmentParams = HubSpotCreateContactParams
+export type HubSpotCreateAppointmentResponse = ToolResponse & {
+ output: { appointment: HubSpotContact; appointmentId: string; success: boolean }
+}
+export type HubSpotUpdateAppointmentParams = Omit & {
+ appointmentId: string
+}
+export type HubSpotUpdateAppointmentResponse = ToolResponse & {
+ output: { appointment: HubSpotContact; appointmentId: string; success: boolean }
+}
+
+// Carts
+export type HubSpotCart = HubSpotCrmObject
+export type HubSpotListCartsParams = HubSpotListContactsParams
+export type HubSpotListCartsResponse = ToolResponse & {
+ output: {
+ carts: HubSpotContact[]
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+export type HubSpotGetCartParams = Omit & {
+ cartId: string
+}
+export type HubSpotGetCartResponse = ToolResponse & {
+ output: { cart: HubSpotContact; cartId: string; success: boolean }
+}
+
+// Owners
+export interface HubSpotOwner {
+ id: string
+ email: string
+ firstName: string
+ lastName: string
+ userId?: number
+ teams?: Array<{ id: string; name: string }>
+ createdAt: string
+ updatedAt: string
+ archived: boolean
+}
+
+export interface HubSpotListOwnersParams {
+ accessToken: string
+ limit?: string
+ after?: string
+ email?: string
+}
+
+export interface HubSpotListOwnersResponse extends ToolResponse {
+ output: {
+ owners: HubSpotOwner[]
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+
+// Marketing Events
+export interface HubSpotMarketingEvent {
+ objectId: string
+ eventName: string
+ eventType?: string
+ eventStatus?: string
+ eventDescription?: string
+ eventUrl?: string
+ eventOrganizer?: string
+ startDateTime?: string
+ endDateTime?: string
+ eventCancelled: boolean
+ eventCompleted: boolean
+ registrants: number
+ attendees: number
+ cancellations: number
+ noShows: number
+ createdAt: string
+ updatedAt: string
+}
+
+export interface HubSpotListMarketingEventsParams {
+ accessToken: string
+ limit?: string
+ after?: string
+}
+
+export interface HubSpotListMarketingEventsResponse extends ToolResponse {
+ output: {
+ events: HubSpotMarketingEvent[]
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; hasMore: boolean }
+ success: boolean
+ }
+}
+
+export interface HubSpotGetMarketingEventParams {
+ accessToken: string
+ eventId: string
+}
+
+export interface HubSpotGetMarketingEventResponse extends ToolResponse {
+ output: {
+ event: HubSpotMarketingEvent
+ eventId: string
+ success: boolean
+ }
+}
+
+// Lists
+export interface HubSpotList {
+ listId: string
+ name: string
+ objectTypeId: string
+ processingType: string
+ size?: number
+ createdAt?: string
+ updatedAt?: string
+}
+
+export interface HubSpotListListsParams {
+ accessToken: string
+ query?: string
+ count?: string
+ offset?: string
+}
+
+export interface HubSpotListListsResponse extends ToolResponse {
+ output: {
+ lists: HubSpotList[]
+ paging?: HubSpotPaging
+ metadata: { totalReturned: number; total: number | null; hasMore: boolean }
+ success: boolean
+ }
+}
+
+export interface HubSpotGetListParams {
+ accessToken: string
+ listId: string
+}
+
+export interface HubSpotGetListResponse extends ToolResponse {
+ output: {
+ list: HubSpotList
+ listId: string
+ success: boolean
+ }
+}
+
+export interface HubSpotCreateListParams {
+ accessToken: string
+ name: string
+ objectTypeId: string
+ processingType: string
+}
+
+export interface HubSpotCreateListResponse extends ToolResponse {
+ output: {
+ list: HubSpotList
+ listId: string
success: boolean
}
}
@@ -553,3 +1230,30 @@ export type HubSpotResponse =
| HubSpotUpdateCompanyResponse
| HubSpotSearchCompaniesResponse
| HubSpotListDealsResponse
+ | HubSpotGetDealResponse
+ | HubSpotCreateDealResponse
+ | HubSpotUpdateDealResponse
+ | HubSpotSearchDealsResponse
+ | HubSpotListTicketsResponse
+ | HubSpotGetTicketResponse
+ | HubSpotCreateTicketResponse
+ | HubSpotUpdateTicketResponse
+ | HubSpotSearchTicketsResponse
+ | HubSpotListLineItemsResponse
+ | HubSpotGetLineItemResponse
+ | HubSpotCreateLineItemResponse
+ | HubSpotUpdateLineItemResponse
+ | HubSpotListQuotesResponse
+ | HubSpotGetQuoteResponse
+ | HubSpotListAppointmentsResponse
+ | HubSpotGetAppointmentResponse
+ | HubSpotCreateAppointmentResponse
+ | HubSpotUpdateAppointmentResponse
+ | HubSpotListCartsResponse
+ | HubSpotGetCartResponse
+ | HubSpotListOwnersResponse
+ | HubSpotListMarketingEventsResponse
+ | HubSpotGetMarketingEventResponse
+ | HubSpotListListsResponse
+ | HubSpotGetListResponse
+ | HubSpotCreateListResponse
diff --git a/apps/sim/tools/hubspot/update_appointment.ts b/apps/sim/tools/hubspot/update_appointment.ts
new file mode 100644
index 00000000000..54854d1aadc
--- /dev/null
+++ b/apps/sim/tools/hubspot/update_appointment.ts
@@ -0,0 +1,101 @@
+import { createLogger } from '@sim/logger'
+import type {
+ HubSpotUpdateAppointmentParams,
+ HubSpotUpdateAppointmentResponse,
+} from '@/tools/hubspot/types'
+import { APPOINTMENT_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotUpdateAppointment')
+
+export const hubspotUpdateAppointmentTool: ToolConfig<
+ HubSpotUpdateAppointmentParams,
+ HubSpotUpdateAppointmentResponse
+> = {
+ id: 'hubspot_update_appointment',
+ name: 'Update Appointment in HubSpot',
+ description: 'Update an existing appointment in HubSpot by ID',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ appointmentId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot appointment ID to update',
+ },
+ idProperty: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Property to use as unique identifier. If not specified, uses record ID',
+ },
+ properties: {
+ type: 'object',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Appointment properties to update as JSON object (e.g., {"hs_meeting_title": "Updated Call", "hs_meeting_location": "Zoom"})',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/appointments/${params.appointmentId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.idProperty) queryParams.append('idProperty', params.idProperty)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'PATCH',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ let properties = params.properties
+ if (typeof properties === 'string') {
+ try {
+ properties = JSON.parse(properties)
+ } catch (e) {
+ throw new Error('Invalid JSON format for properties. Please provide a valid JSON object.')
+ }
+ }
+ return { properties }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to update appointment in HubSpot')
+ }
+ return {
+ success: true,
+ output: { appointment: data, appointmentId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ appointment: APPOINTMENT_OBJECT_OUTPUT,
+ appointmentId: { type: 'string', description: 'The updated appointment ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/update_company.ts b/apps/sim/tools/hubspot/update_company.ts
index 866a9717bd4..949b1ad3b65 100644
--- a/apps/sim/tools/hubspot/update_company.ts
+++ b/apps/sim/tools/hubspot/update_company.ts
@@ -53,7 +53,7 @@ export const hubspotUpdateCompanyTool: ToolConfig<
request: {
url: (params) => {
- const baseUrl = `https://api.hubapi.com/crm/v3/objects/companies/${params.companyId}`
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/companies/${params.companyId.trim()}`
if (params.idProperty) {
return `${baseUrl}?idProperty=${params.idProperty}`
}
diff --git a/apps/sim/tools/hubspot/update_contact.ts b/apps/sim/tools/hubspot/update_contact.ts
index a5f3c4d68f8..3324d97a955 100644
--- a/apps/sim/tools/hubspot/update_contact.ts
+++ b/apps/sim/tools/hubspot/update_contact.ts
@@ -53,7 +53,7 @@ export const hubspotUpdateContactTool: ToolConfig<
request: {
url: (params) => {
- const baseUrl = `https://api.hubapi.com/crm/v3/objects/contacts/${params.contactId}`
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/contacts/${params.contactId.trim()}`
if (params.idProperty) {
return `${baseUrl}?idProperty=${params.idProperty}`
}
diff --git a/apps/sim/tools/hubspot/update_deal.ts b/apps/sim/tools/hubspot/update_deal.ts
new file mode 100644
index 00000000000..a9a9f3571b6
--- /dev/null
+++ b/apps/sim/tools/hubspot/update_deal.ts
@@ -0,0 +1,98 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotUpdateDealParams, HubSpotUpdateDealResponse } from '@/tools/hubspot/types'
+import { DEAL_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotUpdateDeal')
+
+export const hubspotUpdateDealTool: ToolConfig =
+ {
+ id: 'hubspot_update_deal',
+ name: 'Update Deal in HubSpot',
+ description: 'Update an existing deal in HubSpot by ID',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ dealId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot deal ID to update',
+ },
+ idProperty: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Property to use as unique identifier. If not specified, uses record ID',
+ },
+ properties: {
+ type: 'object',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Deal properties to update as JSON object (e.g., {"amount": "10000", "dealstage": "closedwon"})',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/deals/${params.dealId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.idProperty) queryParams.append('idProperty', params.idProperty)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'PATCH',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ let properties = params.properties
+ if (typeof properties === 'string') {
+ try {
+ properties = JSON.parse(properties)
+ } catch (e) {
+ throw new Error(
+ 'Invalid JSON format for properties. Please provide a valid JSON object.'
+ )
+ }
+ }
+ return { properties }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to update deal in HubSpot')
+ }
+ return {
+ success: true,
+ output: { deal: data, dealId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ deal: DEAL_OBJECT_OUTPUT,
+ dealId: { type: 'string', description: 'The updated deal ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+ }
diff --git a/apps/sim/tools/hubspot/update_line_item.ts b/apps/sim/tools/hubspot/update_line_item.ts
new file mode 100644
index 00000000000..2bfe86615bc
--- /dev/null
+++ b/apps/sim/tools/hubspot/update_line_item.ts
@@ -0,0 +1,101 @@
+import { createLogger } from '@sim/logger'
+import type {
+ HubSpotUpdateLineItemParams,
+ HubSpotUpdateLineItemResponse,
+} from '@/tools/hubspot/types'
+import { LINE_ITEM_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotUpdateLineItem')
+
+export const hubspotUpdateLineItemTool: ToolConfig<
+ HubSpotUpdateLineItemParams,
+ HubSpotUpdateLineItemResponse
+> = {
+ id: 'hubspot_update_line_item',
+ name: 'Update Line Item in HubSpot',
+ description: 'Update an existing line item in HubSpot by ID',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ lineItemId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot line item ID to update',
+ },
+ idProperty: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Property to use as unique identifier. If not specified, uses record ID',
+ },
+ properties: {
+ type: 'object',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Line item properties to update as JSON object (e.g., {"quantity": "5", "price": "25.00"})',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/line_items/${params.lineItemId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.idProperty) queryParams.append('idProperty', params.idProperty)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'PATCH',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ let properties = params.properties
+ if (typeof properties === 'string') {
+ try {
+ properties = JSON.parse(properties)
+ } catch (e) {
+ throw new Error('Invalid JSON format for properties. Please provide a valid JSON object.')
+ }
+ }
+ return { properties }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to update line item in HubSpot')
+ }
+ return {
+ success: true,
+ output: { lineItem: data, lineItemId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ lineItem: LINE_ITEM_OBJECT_OUTPUT,
+ lineItemId: { type: 'string', description: 'The updated line item ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/hubspot/update_ticket.ts b/apps/sim/tools/hubspot/update_ticket.ts
new file mode 100644
index 00000000000..2741de503df
--- /dev/null
+++ b/apps/sim/tools/hubspot/update_ticket.ts
@@ -0,0 +1,98 @@
+import { createLogger } from '@sim/logger'
+import type { HubSpotUpdateTicketParams, HubSpotUpdateTicketResponse } from '@/tools/hubspot/types'
+import { TICKET_OBJECT_OUTPUT } from '@/tools/hubspot/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('HubSpotUpdateTicket')
+
+export const hubspotUpdateTicketTool: ToolConfig<
+ HubSpotUpdateTicketParams,
+ HubSpotUpdateTicketResponse
+> = {
+ id: 'hubspot_update_ticket',
+ name: 'Update Ticket in HubSpot',
+ description: 'Update an existing ticket in HubSpot by ID',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'hubspot',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the HubSpot API',
+ },
+ ticketId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The HubSpot ticket ID to update',
+ },
+ idProperty: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Property to use as unique identifier. If not specified, uses record ID',
+ },
+ properties: {
+ type: 'object',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Ticket properties to update as JSON object (e.g., {"subject": "Updated subject", "hs_ticket_priority": "HIGH"})',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const baseUrl = `https://api.hubapi.com/crm/v3/objects/tickets/${params.ticketId.trim()}`
+ const queryParams = new URLSearchParams()
+ if (params.idProperty) queryParams.append('idProperty', params.idProperty)
+ const queryString = queryParams.toString()
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl
+ },
+ method: 'PATCH',
+ headers: (params) => {
+ if (!params.accessToken) {
+ throw new Error('Access token is required')
+ }
+ return {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ let properties = params.properties
+ if (typeof properties === 'string') {
+ try {
+ properties = JSON.parse(properties)
+ } catch (e) {
+ throw new Error('Invalid JSON format for properties. Please provide a valid JSON object.')
+ }
+ }
+ return { properties }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ logger.error('HubSpot API request failed', { data, status: response.status })
+ throw new Error(data.message || 'Failed to update ticket in HubSpot')
+ }
+ return {
+ success: true,
+ output: { ticket: data, ticketId: data.id, success: true },
+ }
+ },
+
+ outputs: {
+ ticket: TICKET_OBJECT_OUTPUT,
+ ticketId: { type: 'string', description: 'The updated ticket ID' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 640cce5c39e..99efb6dcbbc 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -1020,18 +1020,45 @@ import {
} from '@/tools/hex'
import { httpRequestTool, webhookRequestTool } from '@/tools/http'
import {
+ hubspotCreateAppointmentTool,
hubspotCreateCompanyTool,
hubspotCreateContactTool,
+ hubspotCreateDealTool,
+ hubspotCreateLineItemTool,
+ hubspotCreateListTool,
+ hubspotCreateTicketTool,
+ hubspotGetAppointmentTool,
+ hubspotGetCartTool,
hubspotGetCompanyTool,
hubspotGetContactTool,
+ hubspotGetDealTool,
+ hubspotGetLineItemTool,
+ hubspotGetListTool,
+ hubspotGetMarketingEventTool,
+ hubspotGetQuoteTool,
+ hubspotGetTicketTool,
hubspotGetUsersTool,
+ hubspotListAppointmentsTool,
+ hubspotListCartsTool,
hubspotListCompaniesTool,
hubspotListContactsTool,
hubspotListDealsTool,
+ hubspotListLineItemsTool,
+ hubspotListListsTool,
+ hubspotListMarketingEventsTool,
+ hubspotListOwnersTool,
+ hubspotListQuotesTool,
+ hubspotListTicketsTool,
hubspotSearchCompaniesTool,
hubspotSearchContactsTool,
+ hubspotSearchDealsTool,
+ hubspotSearchTicketsTool,
+ hubspotUpdateAppointmentTool,
hubspotUpdateCompanyTool,
hubspotUpdateContactTool,
+ hubspotUpdateDealTool,
+ hubspotUpdateLineItemTool,
+ hubspotUpdateTicketTool,
} from '@/tools/hubspot'
import { huggingfaceChatTool } from '@/tools/huggingface'
import {
@@ -4378,18 +4405,45 @@ export const tools: Record = {
infisical_create_secret: infisicalCreateSecretTool,
infisical_update_secret: infisicalUpdateSecretTool,
infisical_delete_secret: infisicalDeleteSecretTool,
+ hubspot_create_appointment: hubspotCreateAppointmentTool,
hubspot_create_company: hubspotCreateCompanyTool,
hubspot_create_contact: hubspotCreateContactTool,
+ hubspot_create_deal: hubspotCreateDealTool,
+ hubspot_create_line_item: hubspotCreateLineItemTool,
+ hubspot_create_list: hubspotCreateListTool,
+ hubspot_create_ticket: hubspotCreateTicketTool,
+ hubspot_get_appointment: hubspotGetAppointmentTool,
+ hubspot_get_cart: hubspotGetCartTool,
hubspot_get_company: hubspotGetCompanyTool,
hubspot_get_contact: hubspotGetContactTool,
+ hubspot_get_deal: hubspotGetDealTool,
+ hubspot_get_line_item: hubspotGetLineItemTool,
+ hubspot_get_list: hubspotGetListTool,
+ hubspot_get_marketing_event: hubspotGetMarketingEventTool,
+ hubspot_get_quote: hubspotGetQuoteTool,
+ hubspot_get_ticket: hubspotGetTicketTool,
hubspot_get_users: hubspotGetUsersTool,
+ hubspot_list_appointments: hubspotListAppointmentsTool,
+ hubspot_list_carts: hubspotListCartsTool,
hubspot_list_companies: hubspotListCompaniesTool,
hubspot_list_contacts: hubspotListContactsTool,
hubspot_list_deals: hubspotListDealsTool,
+ hubspot_list_line_items: hubspotListLineItemsTool,
+ hubspot_list_lists: hubspotListListsTool,
+ hubspot_list_marketing_events: hubspotListMarketingEventsTool,
+ hubspot_list_owners: hubspotListOwnersTool,
+ hubspot_list_quotes: hubspotListQuotesTool,
+ hubspot_list_tickets: hubspotListTicketsTool,
hubspot_search_companies: hubspotSearchCompaniesTool,
hubspot_search_contacts: hubspotSearchContactsTool,
+ hubspot_search_deals: hubspotSearchDealsTool,
+ hubspot_search_tickets: hubspotSearchTicketsTool,
+ hubspot_update_appointment: hubspotUpdateAppointmentTool,
hubspot_update_company: hubspotUpdateCompanyTool,
hubspot_update_contact: hubspotUpdateContactTool,
+ hubspot_update_deal: hubspotUpdateDealTool,
+ hubspot_update_line_item: hubspotUpdateLineItemTool,
+ hubspot_update_ticket: hubspotUpdateTicketTool,
sharepoint_create_page: sharepointCreatePageTool,
sharepoint_read_page: sharepointReadPageTool,
sharepoint_list_sites: sharepointListSitesTool,
From 54a862d5b084980a1adcded3f3275ee2c15981f2 Mon Sep 17 00:00:00 2001
From: Adithya Krishna
Date: Thu, 26 Mar 2026 02:00:50 +0530
Subject: [PATCH 06/20] fix(user-input): fix multiple re-renders on user-input
and split the file (#3768)
* feat: fix rerenders
* chore: split user-input
---
.../animated-placeholder-effect.tsx | 25 +
.../_components/attached-files-list.tsx | 77 +++
.../user-input/_components/constants.ts | 93 +++
.../user-input/_components/drop-overlay.tsx | 41 ++
.../user-input/_components/index.ts | 22 +
.../user-input/_components/mic-button.tsx | 28 +
.../_components/plus-menu-dropdown.tsx | 251 ++++++++
.../user-input/_components/send-button.tsx | 52 ++
.../home/components/user-input/user-input.tsx | 594 ++++--------------
9 files changed, 718 insertions(+), 465 deletions(-)
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/animated-placeholder-effect.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/attached-files-list.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/animated-placeholder-effect.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/animated-placeholder-effect.tsx
new file mode 100644
index 00000000000..857ed1d4e0d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/animated-placeholder-effect.tsx
@@ -0,0 +1,25 @@
+'use client'
+
+import { useEffect } from 'react'
+import { useAnimatedPlaceholder } from '@/app/workspace/[workspaceId]/home/hooks'
+
+interface AnimatedPlaceholderEffectProps {
+ textareaRef: React.RefObject
+ isInitialView: boolean
+}
+
+export function AnimatedPlaceholderEffect({
+ textareaRef,
+ isInitialView,
+}: AnimatedPlaceholderEffectProps) {
+ const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
+ const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
+
+ useEffect(() => {
+ if (textareaRef.current) {
+ textareaRef.current.placeholder = placeholder
+ }
+ }, [placeholder, textareaRef])
+
+ return null
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/attached-files-list.tsx
new file mode 100644
index 00000000000..3e00e43cf66
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/attached-files-list.tsx
@@ -0,0 +1,77 @@
+'use client'
+
+import React from 'react'
+import { Loader2, X } from 'lucide-react'
+import { Tooltip } from '@/components/emcn'
+import { getDocumentIcon } from '@/components/icons/document-icons'
+import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
+
+interface AttachedFilesListProps {
+ attachedFiles: AttachedFile[]
+ onFileClick: (file: AttachedFile) => void
+ onRemoveFile: (id: string) => void
+}
+
+export const AttachedFilesList = React.memo(function AttachedFilesList({
+ attachedFiles,
+ onFileClick,
+ onRemoveFile,
+}: AttachedFilesListProps) {
+ if (attachedFiles.length === 0) return null
+
+ return (
+
+ {attachedFiles.map((file) => {
+ const isImage = file.type.startsWith('image/')
+ return (
+
+
+ onFileClick(file)}
+ >
+ {isImage && file.previewUrl ? (
+
+ ) : (
+
+ {(() => {
+ const Icon = getDocumentIcon(file.type, file.name)
+ return
+ })()}
+
+ {file.name.split('.').pop()}
+
+
+ )}
+ {file.uploading && (
+
+
+
+ )}
+ {!file.uploading && (
+
{
+ e.stopPropagation()
+ onRemoveFile(file.id)
+ }}
+ className='absolute top-[2px] right-[2px] flex h-[16px] w-[16px] items-center justify-center rounded-full bg-black/60 opacity-0 group-hover:opacity-100'
+ >
+
+
+ )}
+
+
+
+ {file.name}
+
+
+ )
+ })}
+
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts
new file mode 100644
index 00000000000..3c0f5971d63
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/constants.ts
@@ -0,0 +1,93 @@
+import { cn } from '@/lib/core/utils/cn'
+import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
+import type { ChatContext } from '@/stores/panel'
+
+export interface SpeechRecognitionEvent extends Event {
+ resultIndex: number
+ results: SpeechRecognitionResultList
+}
+
+export interface SpeechRecognitionErrorEvent extends Event {
+ error: string
+}
+
+export interface SpeechRecognitionInstance extends EventTarget {
+ continuous: boolean
+ interimResults: boolean
+ lang: string
+ start(): void
+ stop(): void
+ abort(): void
+ onstart: ((ev: Event) => void) | null
+ onend: ((ev: Event) => void) | null
+ onresult: ((ev: SpeechRecognitionEvent) => void) | null
+ onerror: ((ev: SpeechRecognitionErrorEvent) => void) | null
+}
+
+export interface SpeechRecognitionStatic {
+ new (): SpeechRecognitionInstance
+}
+
+export type WindowWithSpeech = Window & {
+ SpeechRecognition?: SpeechRecognitionStatic
+ webkitSpeechRecognition?: SpeechRecognitionStatic
+}
+
+export interface PlusMenuHandle {
+ open: () => void
+}
+
+export const TEXTAREA_BASE_CLASSES = cn(
+ 'm-0 box-border h-auto min-h-[24px] w-full resize-none',
+ 'overflow-y-auto overflow-x-hidden break-all border-0 bg-transparent',
+ 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]',
+ 'text-transparent caret-[var(--text-primary)] outline-none',
+ 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
+ 'focus-visible:ring-0 focus-visible:ring-offset-0',
+ '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
+)
+
+export const OVERLAY_CLASSES = cn(
+ 'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none',
+ 'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all border-0 bg-transparent',
+ 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]',
+ 'text-[var(--text-primary)] outline-none',
+ '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
+)
+
+export const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors'
+export const SEND_BUTTON_ACTIVE =
+ 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
+export const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
+
+export const MAX_CHAT_TEXTAREA_HEIGHT = 200
+export const SPEECH_RECOGNITION_LANG = 'en-US'
+
+export function autoResizeTextarea(e: React.FormEvent, maxHeight: number) {
+ const target = e.target as HTMLTextAreaElement
+ target.style.height = 'auto'
+ target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
+}
+
+export function mapResourceToContext(resource: MothershipResource): ChatContext {
+ switch (resource.type) {
+ case 'workflow':
+ return {
+ kind: 'workflow',
+ workflowId: resource.id,
+ label: resource.title,
+ }
+ case 'knowledgebase':
+ return {
+ kind: 'knowledge',
+ knowledgeId: resource.id,
+ label: resource.title,
+ }
+ case 'table':
+ return { kind: 'table', tableId: resource.id, label: resource.title }
+ case 'file':
+ return { kind: 'file', fileId: resource.id, label: resource.title }
+ default:
+ return { kind: 'docs', label: resource.title }
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx
new file mode 100644
index 00000000000..5d0af330c42
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/drop-overlay.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import React from 'react'
+import {
+ AudioIcon,
+ CsvIcon,
+ DocxIcon,
+ JsonIcon,
+ MarkdownIcon,
+ PdfIcon,
+ TxtIcon,
+ VideoIcon,
+ XlsxIcon,
+} from '@/components/icons/document-icons'
+
+const DROP_OVERLAY_ICONS = [
+ PdfIcon,
+ DocxIcon,
+ XlsxIcon,
+ CsvIcon,
+ TxtIcon,
+ MarkdownIcon,
+ JsonIcon,
+ AudioIcon,
+ VideoIcon,
+] as const
+
+export const DropOverlay = React.memo(function DropOverlay() {
+ return (
+
+
+
Drop files
+
+ {DROP_OVERLAY_ICONS.map((Icon, i) => (
+
+ ))}
+
+
+
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts
new file mode 100644
index 00000000000..a0e71aee024
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/index.ts
@@ -0,0 +1,22 @@
+export { AnimatedPlaceholderEffect } from './animated-placeholder-effect'
+export { AttachedFilesList } from './attached-files-list'
+export type {
+ PlusMenuHandle,
+ SpeechRecognitionErrorEvent,
+ SpeechRecognitionEvent,
+ SpeechRecognitionInstance,
+ WindowWithSpeech,
+} from './constants'
+export {
+ autoResizeTextarea,
+ MAX_CHAT_TEXTAREA_HEIGHT,
+ mapResourceToContext,
+ OVERLAY_CLASSES,
+ SPEECH_RECOGNITION_LANG,
+ TEXTAREA_BASE_CLASSES,
+} from './constants'
+export { DropOverlay } from './drop-overlay'
+export { MicButton } from './mic-button'
+export type { AvailableResourceGroup } from './plus-menu-dropdown'
+export { PlusMenuDropdown } from './plus-menu-dropdown'
+export { SendButton } from './send-button'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx
new file mode 100644
index 00000000000..9053013d431
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/mic-button.tsx
@@ -0,0 +1,28 @@
+'use client'
+
+import React from 'react'
+import { Mic } from 'lucide-react'
+import { cn } from '@/lib/core/utils/cn'
+
+interface MicButtonProps {
+ isListening: boolean
+ onToggle: () => void
+}
+
+export const MicButton = React.memo(function MicButton({ isListening, onToggle }: MicButtonProps) {
+ return (
+
+
+
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx
new file mode 100644
index 00000000000..4e057a04c59
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/plus-menu-dropdown.tsx
@@ -0,0 +1,251 @@
+'use client'
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Paperclip } from 'lucide-react'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSearchInput,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '@/components/emcn'
+import { Plus, Sim } from '@/components/emcn/icons'
+import { cn } from '@/lib/core/utils/cn'
+import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
+import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
+import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/_components/constants'
+import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
+
+export type AvailableResourceGroup = ReturnType[number]
+
+interface PlusMenuDropdownProps {
+ availableResources: AvailableResourceGroup[]
+ onResourceSelect: (resource: MothershipResource) => void
+ onFileSelect: () => void
+ onClose: () => void
+ textareaRef: React.RefObject
+ pendingCursorRef: React.MutableRefObject
+}
+
+export const PlusMenuDropdown = React.memo(
+ React.forwardRef(function PlusMenuDropdown(
+ { availableResources, onResourceSelect, onFileSelect, onClose, textareaRef, pendingCursorRef },
+ ref
+ ) {
+ const [open, setOpen] = useState(false)
+ const [search, setSearch] = useState('')
+ const [activeIndex, setActiveIndex] = useState(0)
+ const activeIndexRef = useRef(activeIndex)
+
+ useEffect(() => {
+ activeIndexRef.current = activeIndex
+ }, [activeIndex])
+
+ React.useImperativeHandle(
+ ref,
+ () => ({
+ open: () => {
+ setOpen(true)
+ setSearch('')
+ setActiveIndex(0)
+ },
+ }),
+ []
+ )
+
+ const filteredItems = useMemo(() => {
+ const q = search.toLowerCase().trim()
+ if (!q) return null
+ return availableResources.flatMap(({ type, items }) =>
+ items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item }))
+ )
+ }, [search, availableResources])
+
+ const handleSelect = useCallback(
+ (resource: MothershipResource) => {
+ onResourceSelect(resource)
+ setOpen(false)
+ setSearch('')
+ setActiveIndex(0)
+ },
+ [onResourceSelect]
+ )
+
+ const filteredItemsRef = useRef(filteredItems)
+ filteredItemsRef.current = filteredItems
+
+ const handleSearchKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ const items = filteredItemsRef.current
+ if (!items) return
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setActiveIndex((prev) => Math.min(prev + 1, items.length - 1))
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setActiveIndex((prev) => Math.max(prev - 1, 0))
+ } else if (e.key === 'Enter') {
+ e.preventDefault()
+ const idx = activeIndexRef.current
+ if (items.length > 0 && items[idx]) {
+ const { type, item } = items[idx]
+ handleSelect({ type, id: item.id, title: item.name })
+ }
+ }
+ },
+ [handleSelect]
+ )
+
+ const handleOpenChange = useCallback(
+ (isOpen: boolean) => {
+ setOpen(isOpen)
+ if (!isOpen) {
+ setSearch('')
+ setActiveIndex(0)
+ onClose()
+ }
+ },
+ [onClose]
+ )
+
+ const handleCloseAutoFocus = useCallback(
+ (e: Event) => {
+ e.preventDefault()
+ const textarea = textareaRef.current
+ if (!textarea) return
+ if (pendingCursorRef.current !== null) {
+ textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
+ pendingCursorRef.current = null
+ }
+ textarea.focus()
+ },
+ [textareaRef, pendingCursorRef]
+ )
+
+ return (
+
+
+
+
+
+
+
+ {
+ setSearch(e.target.value)
+ setActiveIndex(0)
+ }}
+ onKeyDown={handleSearchKeyDown}
+ />
+
+ {filteredItems ? (
+ filteredItems.length > 0 ? (
+ filteredItems.map(({ type, item }, index) => {
+ const config = getResourceConfig(type)
+ return (
+
setActiveIndex(index)}
+ onClick={() => {
+ handleSelect({
+ type,
+ id: item.id,
+ title: item.name,
+ })
+ }}
+ >
+ {config.renderDropdownItem({ item })}
+
+ {config.label}
+
+
+ )
+ })
+ ) : (
+
+ No results
+
+ )
+ ) : (
+ <>
+
{
+ setOpen(false)
+ onFileSelect()
+ }}
+ >
+
+ Attachments
+
+
+
+
+ Workspace
+
+
+ {availableResources.map(({ type, items }) => {
+ if (items.length === 0) return null
+ const config = getResourceConfig(type)
+ const Icon = config.icon
+ return (
+
+
+ {type === 'workflow' ? (
+
+ ) : (
+
+ )}
+ {config.label}
+
+
+ {items.map((item) => (
+ {
+ handleSelect({
+ type,
+ id: item.id,
+ title: item.name,
+ })
+ }}
+ >
+ {config.renderDropdownItem({ item })}
+
+ ))}
+
+
+ )
+ })}
+
+
+ >
+ )}
+
+
+
+ )
+ })
+)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx
new file mode 100644
index 00000000000..74edc7d60d3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/_components/send-button.tsx
@@ -0,0 +1,52 @@
+'use client'
+
+import React from 'react'
+import { ArrowUp } from 'lucide-react'
+import { Button } from '@/components/emcn'
+import { cn } from '@/lib/core/utils/cn'
+import {
+ SEND_BUTTON_ACTIVE,
+ SEND_BUTTON_BASE,
+ SEND_BUTTON_DISABLED,
+} from '@/app/workspace/[workspaceId]/home/components/user-input/_components/constants'
+
+interface SendButtonProps {
+ isSending: boolean
+ canSubmit: boolean
+ onSubmit: () => void
+ onStopGeneration: () => void
+}
+
+export const SendButton = React.memo(function SendButton({
+ isSending,
+ canSubmit,
+ onSubmit,
+ onStopGeneration,
+}: SendButtonProps) {
+ if (isSending) {
+ return (
+
+
+
+
+
+ )
+ }
+ return (
+
+
+
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index a69cc08477d..3a549351c53 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -1,69 +1,35 @@
'use client'
-interface SpeechRecognitionEvent extends Event {
- resultIndex: number
- results: SpeechRecognitionResultList
-}
-
-interface SpeechRecognitionErrorEvent extends Event {
- error: string
-}
-
-interface SpeechRecognitionInstance extends EventTarget {
- continuous: boolean
- interimResults: boolean
- lang: string
- start(): void
- stop(): void
- abort(): void
- onstart: ((ev: Event) => void) | null
- onend: ((ev: Event) => void) | null
- onresult: ((ev: SpeechRecognitionEvent) => void) | null
- onerror: ((ev: SpeechRecognitionErrorEvent) => void) | null
-}
-
-interface SpeechRecognitionStatic {
- new (): SpeechRecognitionInstance
-}
-
-type WindowWithSpeech = Window & {
- SpeechRecognition?: SpeechRecognitionStatic
- webkitSpeechRecognition?: SpeechRecognitionStatic
-}
-
+import type React from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import { ArrowUp, Loader2, Mic, Paperclip, X } from 'lucide-react'
import { useParams } from 'next/navigation'
-import {
- Button,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSearchInput,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
- Tooltip,
-} from '@/components/emcn'
-import { Database, Plus, Sim, Table as TableIcon } from '@/components/emcn/icons'
-import {
- AudioIcon,
- CsvIcon,
- DocxIcon,
- getDocumentIcon,
- JsonIcon,
- MarkdownIcon,
- PdfIcon,
- TxtIcon,
- VideoIcon,
- XlsxIcon,
-} from '@/components/icons/document-icons'
+import { Database, Table as TableIcon } from '@/components/emcn/icons'
+import { getDocumentIcon } from '@/components/icons/document-icons'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
-import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
+import type {
+ PlusMenuHandle,
+ SpeechRecognitionErrorEvent,
+ SpeechRecognitionEvent,
+ SpeechRecognitionInstance,
+ WindowWithSpeech,
+} from '@/app/workspace/[workspaceId]/home/components/user-input/_components'
+import {
+ AnimatedPlaceholderEffect,
+ AttachedFilesList,
+ autoResizeTextarea,
+ DropOverlay,
+ MAX_CHAT_TEXTAREA_HEIGHT,
+ MicButton,
+ mapResourceToContext,
+ OVERLAY_CLASSES,
+ PlusMenuDropdown,
+ SendButton,
+ SPEECH_RECOGNITION_LANG,
+ TEXTAREA_BASE_CLASSES,
+} from '@/app/workspace/[workspaceId]/home/components/user-input/_components'
import type {
FileAttachmentForApi,
MothershipResource,
@@ -74,80 +40,13 @@ import {
useMentionMenu,
useMentionTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
+import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import {
computeMentionHighlightRanges,
extractContextTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-import { useAnimatedPlaceholder } from '../../hooks'
-
-const TEXTAREA_BASE_CLASSES = cn(
- 'm-0 box-border h-auto min-h-[24px] w-full resize-none',
- 'overflow-y-auto overflow-x-hidden break-all border-0 bg-transparent',
- 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]',
- 'text-transparent caret-[var(--text-primary)] outline-none',
- 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
- 'focus-visible:ring-0 focus-visible:ring-offset-0',
- '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
-)
-
-const OVERLAY_CLASSES = cn(
- 'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none',
- 'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all border-0 bg-transparent',
- 'px-[4px] py-[4px] font-body text-[15px] leading-[24px] tracking-[-0.015em]',
- 'text-[var(--text-primary)] outline-none',
- '[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
-)
-
-const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors'
-const SEND_BUTTON_ACTIVE =
- 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
-const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
-
-const MAX_CHAT_TEXTAREA_HEIGHT = 200
-const SPEECH_RECOGNITION_LANG = 'en-US'
-
-const DROP_OVERLAY_ICONS = [
- PdfIcon,
- DocxIcon,
- XlsxIcon,
- CsvIcon,
- TxtIcon,
- MarkdownIcon,
- JsonIcon,
- AudioIcon,
- VideoIcon,
-] as const
-
-function autoResizeTextarea(e: React.FormEvent, maxHeight: number) {
- const target = e.target as HTMLTextAreaElement
- target.style.height = 'auto'
- target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
-}
-
-function mapResourceToContext(resource: MothershipResource): ChatContext {
- switch (resource.type) {
- case 'workflow':
- return {
- kind: 'workflow',
- workflowId: resource.id,
- label: resource.title,
- }
- case 'knowledgebase':
- return {
- kind: 'knowledge',
- knowledgeId: resource.id,
- label: resource.title,
- }
- case 'table':
- return { kind: 'table', tableId: resource.id, label: resource.title }
- case 'file':
- return { kind: 'file', fileId: resource.id, label: resource.title }
- default:
- return { kind: 'docs', label: resource.title }
- }
-}
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
@@ -181,10 +80,8 @@ export function UserInput({
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: session } = useSession()
const [value, setValue] = useState(defaultValue)
- const [plusMenuOpen, setPlusMenuOpen] = useState(false)
- const [plusMenuSearch, setPlusMenuSearch] = useState('')
- const [plusMenuActiveIndex, setPlusMenuActiveIndex] = useState(0)
const overlayRef = useRef(null)
+ const plusMenuRef = useRef(null)
const [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue)
if (defaultValue && defaultValue !== prevDefaultValue) {
@@ -206,9 +103,6 @@ export function UserInput({
if (editValue) onEditValueConsumed?.()
}, [editValue, onEditValueConsumed])
- const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
- const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
-
const files = useFileAttachments({
userId: userId || session?.user?.id,
workspaceId,
@@ -219,12 +113,14 @@ export function UserInput({
const contextManagement = useContextManagement({ message: value })
+ const { addContext } = contextManagement
+
const handleContextAdd = useCallback(
(context: ChatContext) => {
- contextManagement.addContext(context)
+ addContext(context)
onContextAdd?.(context)
},
- [contextManagement, onContextAdd]
+ [addContext, onContextAdd]
)
const existingResourceKeys = useMemo(() => {
@@ -240,14 +136,6 @@ export function UserInput({
const availableResources = useAvailableResources(workspaceId, existingResourceKeys)
- const filteredPlusMenuItems = useMemo(() => {
- const q = plusMenuSearch.toLowerCase().trim()
- if (!q) return null
- return availableResources.flatMap(({ type, items }) =>
- items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item }))
- )
- }, [plusMenuSearch, availableResources])
-
const mentionMenu = useMentionMenu({
message: value,
selectedContexts: contextManagement.selectedContexts,
@@ -270,6 +158,11 @@ export function UserInput({
const prefixRef = useRef('')
const valueRef = useRef(value)
+ const filesRef = useRef(files)
+ filesRef.current = files
+ const contextRef = useRef(contextManagement)
+ contextRef.current = contextManagement
+
useEffect(() => {
return () => {
recognitionRef.current?.abort()
@@ -300,13 +193,14 @@ export function UserInput({
(resource: MothershipResource) => {
const textarea = textareaRef.current
if (textarea) {
- const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? value.length
+ const currentValue = valueRef.current
+ const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length
atInsertPosRef.current = null
- const needsSpaceBefore = insertAt > 0 && !/\s/.test(value.charAt(insertAt - 1))
+ const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1))
const insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} `
- const before = value.slice(0, insertAt)
- const after = value.slice(insertAt)
+ const before = currentValue.slice(0, insertAt)
+ const after = currentValue.slice(insertAt)
const newPos = before.length + insertText.length
pendingCursorRef.current = newPos
setValue(`${before}${insertText}${after}`)
@@ -314,47 +208,35 @@ export function UserInput({
const context = mapResourceToContext(resource)
handleContextAdd(context)
- setPlusMenuOpen(false)
},
- [textareaRef, value, handleContextAdd]
+ [textareaRef, handleContextAdd]
)
- const handlePlusMenuSearchKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- const items = filteredPlusMenuItems
- if (!items) return
- if (e.key === 'ArrowDown') {
- e.preventDefault()
- setPlusMenuActiveIndex((prev) => Math.min(prev + 1, items.length - 1))
- } else if (e.key === 'ArrowUp') {
- e.preventDefault()
- setPlusMenuActiveIndex((prev) => Math.max(prev - 1, 0))
- } else if (e.key === 'Enter') {
- e.preventDefault()
- if (items.length > 0 && items[plusMenuActiveIndex]) {
- const { type, item } = items[plusMenuActiveIndex]
- handleResourceSelect({ type, id: item.id, title: item.name })
- setPlusMenuOpen(false)
- setPlusMenuSearch('')
- setPlusMenuActiveIndex(0)
- }
- }
- },
- [filteredPlusMenuItems, plusMenuActiveIndex, handleResourceSelect]
- )
+ const handlePlusMenuClose = useCallback(() => {
+ atInsertPosRef.current = null
+ }, [])
- const handleContainerDragOver = useCallback(
- (e: React.DragEvent) => {
- if (e.dataTransfer.types.includes('application/x-sim-resource')) {
- e.preventDefault()
- e.stopPropagation()
- e.dataTransfer.dropEffect = 'copy'
- return
- }
- files.handleDragOver(e)
- },
- [files]
- )
+ const handleFileSelectStable = useCallback(() => {
+ filesRef.current.handleFileSelect()
+ }, [])
+
+ const handleFileClick = useCallback((file: AttachedFile) => {
+ filesRef.current.handleFileClick(file)
+ }, [])
+
+ const handleRemoveFile = useCallback((id: string) => {
+ filesRef.current.removeFile(id)
+ }, [])
+
+ const handleContainerDragOver = useCallback((e: React.DragEvent) => {
+ if (e.dataTransfer.types.includes('application/x-sim-resource')) {
+ e.preventDefault()
+ e.stopPropagation()
+ e.dataTransfer.dropEffect = 'copy'
+ return
+ }
+ filesRef.current.handleDragOver(e)
+ }, [])
const handleContainerDrop = useCallback(
(e: React.DragEvent) => {
@@ -370,11 +252,23 @@ export function UserInput({
}
return
}
- files.handleDrop(e)
+ filesRef.current.handleDrop(e)
},
- [handleResourceSelect, files]
+ [handleResourceSelect]
)
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ filesRef.current.handleDragEnter(e)
+ }, [])
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ filesRef.current.handleDragLeave(e)
+ }, [])
+
+ const handleFileChange = useCallback((e: React.ChangeEvent) => {
+ filesRef.current.handleFileChange(e)
+ }, [])
+
useEffect(() => {
if (wasSendingRef.current && !isSending) {
textareaRef.current?.focus()
@@ -468,14 +362,18 @@ export function UserInput({
return
}
- prefixRef.current = value
+ prefixRef.current = valueRef.current
if (startRecognition()) {
setIsListening(true)
}
- }, [isListening, value, startRecognition])
+ }, [isListening, startRecognition])
const handleSubmit = useCallback(() => {
- const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles
+ const currentFiles = filesRef.current
+ const currentContext = contextRef.current
+ const currentValue = valueRef.current
+
+ const fileAttachmentsForApi: FileAttachmentForApi[] = currentFiles.attachedFiles
.filter((f) => !f.uploading && f.key)
.map((f) => ({
id: f.id,
@@ -486,19 +384,19 @@ export function UserInput({
}))
onSubmit(
- value,
+ currentValue,
fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined,
- contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined
+ currentContext.selectedContexts.length > 0 ? currentContext.selectedContexts : undefined
)
setValue('')
restartRecognition('')
- files.clearAttachedFiles()
- contextManagement.clearContexts()
+ currentFiles.clearAttachedFiles()
+ currentContext.clearContexts()
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
- }, [onSubmit, files, value, contextManagement, textareaRef, restartRecognition])
+ }, [onSubmit, restartRecognition, textareaRef])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -588,9 +486,7 @@ export function UserInput({
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
- setPlusMenuOpen(true)
- setPlusMenuSearch('')
- setPlusMenuActiveIndex(0)
+ plusMenuRef.current?.open()
restartRecognition(adjusted)
return
}
@@ -619,7 +515,6 @@ export function UserInput({
const maxHeight = isInitialView ? window.innerHeight * 0.3 : MAX_CHAT_TEXTAREA_HEIGHT
autoResizeTextarea(e, maxHeight)
- // Sync overlay scroll
if (overlayRef.current) {
overlayRef.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop
}
@@ -627,7 +522,13 @@ export function UserInput({
[isInitialView]
)
- const renderOverlayContent = useCallback(() => {
+ const handleScroll = useCallback((e: React.UIEvent) => {
+ if (overlayRef.current) {
+ overlayRef.current.scrollTop = e.currentTarget.scrollTop
+ }
+ }, [])
+
+ const overlayContent = useMemo(() => {
const contexts = contextManagement.selectedContexts
if (!value) {
@@ -732,77 +633,26 @@ export function UserInput({
'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
isInitialView && 'shadow-sm'
)}
- onDragEnter={files.handleDragEnter}
- onDragLeave={files.handleDragLeave}
+ onDragEnter={handleDragEnter}
+ onDragLeave={handleDragLeave}
onDragOver={handleContainerDragOver}
onDrop={handleContainerDrop}
>
- {/* Attached files */}
- {files.attachedFiles.length > 0 && (
-
- {files.attachedFiles.map((file) => {
- const isImage = file.type.startsWith('image/')
- return (
-
-
- files.handleFileClick(file)}
- >
- {isImage && file.previewUrl ? (
-
- ) : (
-
- {(() => {
- const Icon = getDocumentIcon(file.type, file.name)
- return
- })()}
-
- {file.name.split('.').pop()}
-
-
- )}
- {file.uploading && (
-
-
-
- )}
- {!file.uploading && (
-
{
- e.stopPropagation()
- files.removeFile(file.id)
- }}
- className='absolute top-[2px] right-[2px] flex h-[16px] w-[16px] items-center justify-center rounded-full bg-black/60 opacity-0 group-hover:opacity-100'
- >
-
-
- )}
-
-
-
- {file.name}
-
-
- )
- })}
-
- )}
+
+
+
- {/* Textarea with overlay for highlighting */}
- {/* Highlight overlay */}
- {renderOverlayContent()}
+ {overlayContent}
)
}
From f94be08950a6dd4679e00793b211f9d16bbb2d6e Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 25 Mar 2026 13:41:27 -0700
Subject: [PATCH 07/20] fix(billing): atomize usage_log and userStats writes
via central recordUsage (#3767)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(billing): atomize usage_log and userStats writes via central recordUsage()
* fix(billing): address PR review — re-throw errors, guard reserved keys, handle zero-cost counters
* chore(lint): fix formatting in hubspot list_lists.ts from staging
* fix(billing): tighten early-return guard to handle empty additionalStats object
* lint
* chore(billing): remove implementation-decision comments
---
apps/sim/app/api/billing/update-cost/route.ts | 54 ++--
apps/sim/app/api/wand/route.ts | 41 ++-
apps/sim/lib/billing/core/usage-log.ts | 273 ++++++------------
apps/sim/lib/logs/execution/logger.test.ts | 2 +-
apps/sim/lib/logs/execution/logger.ts | 101 +++----
apps/sim/providers/utils.test.ts | 1 -
apps/sim/providers/utils.ts | 1 -
apps/sim/tools/hubspot/list_lists.ts | 12 +-
apps/sim/tools/index.test.ts | 48 +--
apps/sim/tools/index.ts | 37 +--
apps/sim/tools/perplexity/chat.ts | 1 -
apps/sim/tools/types.ts | 2 -
12 files changed, 201 insertions(+), 372 deletions(-)
diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts
index 0cbbd73a1d6..65899d55572 100644
--- a/apps/sim/app/api/billing/update-cost/route.ts
+++ b/apps/sim/app/api/billing/update-cost/route.ts
@@ -1,10 +1,8 @@
-import { db } from '@sim/db'
-import { userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { eq, sql } from 'drizzle-orm'
+import { sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
-import { logModelUsage } from '@/lib/billing/core/usage-log'
+import { recordUsage } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { checkInternalApiKey } from '@/lib/copilot/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -87,53 +85,39 @@ export async function POST(req: NextRequest) {
source,
})
- // Check if user stats record exists (same as ExecutionLogger)
- const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
-
- if (userStatsRecords.length === 0) {
- logger.error(
- `[${requestId}] User stats record not found - should be created during onboarding`,
- {
- userId,
- }
- )
- return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
- }
-
const totalTokens = inputTokens + outputTokens
- const updateFields: Record = {
- totalCost: sql`total_cost + ${cost}`,
- currentPeriodCost: sql`current_period_cost + ${cost}`,
+ const additionalStats: Record> = {
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${cost}`,
totalCopilotCalls: sql`total_copilot_calls + 1`,
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
- lastActive: new Date(),
}
if (isMcp) {
- updateFields.totalMcpCopilotCost = sql`total_mcp_copilot_cost + ${cost}`
- updateFields.currentPeriodMcpCopilotCost = sql`current_period_mcp_copilot_cost + ${cost}`
- updateFields.totalMcpCopilotCalls = sql`total_mcp_copilot_calls + 1`
+ additionalStats.totalMcpCopilotCost = sql`total_mcp_copilot_cost + ${cost}`
+ additionalStats.currentPeriodMcpCopilotCost = sql`current_period_mcp_copilot_cost + ${cost}`
+ additionalStats.totalMcpCopilotCalls = sql`total_mcp_copilot_calls + 1`
}
- await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
-
- logger.info(`[${requestId}] Updated user stats record`, {
+ await recordUsage({
userId,
- addedCost: cost,
- source,
+ entries: [
+ {
+ category: 'model',
+ source,
+ description: model,
+ cost,
+ metadata: { inputTokens, outputTokens },
+ },
+ ],
+ additionalStats,
})
- // Log usage for complete audit trail with the original source for visibility
- await logModelUsage({
+ logger.info(`[${requestId}] Recorded usage`, {
userId,
+ addedCost: cost,
source,
- model,
- inputTokens,
- outputTokens,
- cost,
})
// Check if user has hit overage threshold and bill incrementally
diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts
index fffc3b08e70..87e988e04e3 100644
--- a/apps/sim/app/api/wand/route.ts
+++ b/apps/sim/app/api/wand/route.ts
@@ -1,11 +1,11 @@
import { db } from '@sim/db'
-import { userStats, workflow } from '@sim/db/schema'
+import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getBYOKKey } from '@/lib/api-key/byok'
import { getSession } from '@/lib/auth'
-import { logModelUsage } from '@/lib/billing/core/usage-log'
+import { recordUsage } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -134,23 +134,20 @@ async function updateUserStatsForWand(
costToStore = modelCost * costMultiplier
}
- await db
- .update(userStats)
- .set({
- totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
- totalCost: sql`total_cost + ${costToStore}`,
- currentPeriodCost: sql`current_period_cost + ${costToStore}`,
- lastActive: new Date(),
- })
- .where(eq(userStats.userId, userId))
-
- await logModelUsage({
+ await recordUsage({
userId,
- source: 'wand',
- model: modelName,
- inputTokens: promptTokens,
- outputTokens: completionTokens,
- cost: costToStore,
+ entries: [
+ {
+ category: 'model',
+ source: 'wand',
+ description: modelName,
+ cost: costToStore,
+ metadata: { inputTokens: promptTokens, outputTokens: completionTokens },
+ },
+ ],
+ additionalStats: {
+ totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
+ },
})
await checkAndBillOverageThreshold(userId)
@@ -341,7 +338,7 @@ export async function POST(req: NextRequest) {
let finalUsage: any = null
let usageRecorded = false
- const recordUsage = async () => {
+ const flushUsage = async () => {
if (usageRecorded || !finalUsage) {
return
}
@@ -360,7 +357,7 @@ export async function POST(req: NextRequest) {
if (done) {
logger.info(`[${requestId}] Stream completed. Total chunks: ${chunkCount}`)
- await recordUsage()
+ await flushUsage()
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`))
controller.close()
break
@@ -390,7 +387,7 @@ export async function POST(req: NextRequest) {
if (data === '[DONE]') {
logger.info(`[${requestId}] Received [DONE] signal`)
- await recordUsage()
+ await flushUsage()
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)
@@ -468,7 +465,7 @@ export async function POST(req: NextRequest) {
})
try {
- await recordUsage()
+ await flushUsage()
} catch (usageError) {
logger.warn(`[${requestId}] Failed to record usage after stream error`, usageError)
}
diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts
index 936d77f7950..0d50c1b2d00 100644
--- a/apps/sim/lib/billing/core/usage-log.ts
+++ b/apps/sim/lib/billing/core/usage-log.ts
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
-import { usageLog } from '@sim/db/schema'
+import { usageLog, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, desc, eq, gte, lte, sql } from 'drizzle-orm'
+import { and, desc, eq, gte, lte, type SQL, sql } from 'drizzle-orm'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('UsageLog')
@@ -32,224 +32,121 @@ export interface ModelUsageMetadata {
}
/**
- * Metadata for 'fixed' category charges (e.g., tool cost breakdown)
+ * Union type for all usage log metadata types
*/
-export type FixedUsageMetadata = Record
+export type UsageLogMetadata = ModelUsageMetadata | Record | null
/**
- * Union type for all metadata types
+ * A single usage entry to be recorded in the usage_log table.
*/
-export type UsageLogMetadata = ModelUsageMetadata | FixedUsageMetadata | null
-
-/**
- * Parameters for logging model usage (token-based charges)
- */
-export interface LogModelUsageParams {
- userId: string
+export interface UsageEntry {
+ category: UsageLogCategory
source: UsageLogSource
- model: string
- inputTokens: number
- outputTokens: number
+ description: string
cost: number
- toolCost?: number
- workspaceId?: string
- workflowId?: string
- executionId?: string
+ metadata?: UsageLogMetadata
}
/**
- * Parameters for logging fixed charges (flat fees)
+ * Parameters for the central recordUsage function.
+ * This is the single entry point for all billing mutations.
*/
-export interface LogFixedUsageParams {
+export interface RecordUsageParams {
+ /** The user being charged */
userId: string
- source: UsageLogSource
- description: string
- cost: number
+ /** One or more usage_log entries to record. Total cost is derived from these. */
+ entries: UsageEntry[]
+ /** Workspace context */
workspaceId?: string
+ /** Workflow context */
workflowId?: string
+ /** Execution context */
executionId?: string
- /** Optional metadata (e.g., tool cost breakdown from API) */
- metadata?: FixedUsageMetadata
+ /** Source-specific counter increments (e.g. totalCopilotCalls, totalManualExecutions) */
+ additionalStats?: Record
}
/**
- * Log a model usage charge (token-based)
+ * Records usage in a single atomic transaction.
+ *
+ * Inserts all entries into usage_log and updates userStats counters
+ * (totalCost, currentPeriodCost, lastActive) within one Postgres transaction.
+ * The total cost added to userStats is derived from summing entry costs,
+ * ensuring usage_log and currentPeriodCost can never drift apart.
+ *
+ * If billing is disabled, total cost is zero, or no entries have positive cost,
+ * this function returns early without writing anything.
*/
-export async function logModelUsage(params: LogModelUsageParams): Promise {
- if (!isBillingEnabled || params.cost <= 0) {
+export async function recordUsage(params: RecordUsageParams): Promise {
+ if (!isBillingEnabled) {
return
}
- try {
- const metadata: ModelUsageMetadata = {
- inputTokens: params.inputTokens,
- outputTokens: params.outputTokens,
- ...(params.toolCost != null && params.toolCost > 0 && { toolCost: params.toolCost }),
- }
-
- await db.insert(usageLog).values({
- id: crypto.randomUUID(),
- userId: params.userId,
- category: 'model',
- source: params.source,
- description: params.model,
- metadata,
- cost: params.cost.toString(),
- workspaceId: params.workspaceId ?? null,
- workflowId: params.workflowId ?? null,
- executionId: params.executionId ?? null,
- })
+ const { userId, entries, workspaceId, workflowId, executionId, additionalStats } = params
- logger.debug('Logged model usage', {
- userId: params.userId,
- source: params.source,
- model: params.model,
- cost: params.cost,
- })
- } catch (error) {
- logger.error('Failed to log model usage', {
- error: error instanceof Error ? error.message : String(error),
- params,
- })
- // Don't throw - usage logging should not break the main flow
- }
-}
+ const validEntries = entries.filter((e) => e.cost > 0)
+ const totalCost = validEntries.reduce((sum, e) => sum + e.cost, 0)
-/**
- * Log a fixed charge (flat fee like base execution charge or search)
- */
-export async function logFixedUsage(params: LogFixedUsageParams): Promise {
- if (!isBillingEnabled || params.cost <= 0) {
+ if (
+ validEntries.length === 0 &&
+ (!additionalStats || Object.keys(additionalStats).length === 0)
+ ) {
return
}
- try {
- await db.insert(usageLog).values({
- id: crypto.randomUUID(),
- userId: params.userId,
- category: 'fixed',
- source: params.source,
- description: params.description,
- metadata: params.metadata ?? null,
- cost: params.cost.toString(),
- workspaceId: params.workspaceId ?? null,
- workflowId: params.workflowId ?? null,
- executionId: params.executionId ?? null,
- })
+ const RESERVED_KEYS = new Set(['totalCost', 'currentPeriodCost', 'lastActive'])
+ const safeStats = additionalStats
+ ? Object.fromEntries(Object.entries(additionalStats).filter(([k]) => !RESERVED_KEYS.has(k)))
+ : undefined
- logger.debug('Logged fixed usage', {
- userId: params.userId,
- source: params.source,
- description: params.description,
- cost: params.cost,
- })
- } catch (error) {
- logger.error('Failed to log fixed usage', {
- error: error instanceof Error ? error.message : String(error),
- params,
- })
- // Don't throw - usage logging should not break the main flow
- }
-}
-
-/**
- * Parameters for batch logging workflow usage
- */
-export interface LogWorkflowUsageBatchParams {
- userId: string
- workspaceId?: string
- workflowId: string
- executionId?: string
- baseExecutionCharge?: number
- models?: Record<
- string,
- {
- total: number
- tokens: { input: number; output: number }
- toolCost?: number
- }
- >
-}
-
-/**
- * Log all workflow usage entries in a single batch insert (performance optimized)
- */
-export async function logWorkflowUsageBatch(params: LogWorkflowUsageBatchParams): Promise {
- if (!isBillingEnabled) {
- return
- }
-
- const entries: Array<{
- id: string
- userId: string
- category: 'model' | 'fixed'
- source: 'workflow'
- description: string
- metadata: ModelUsageMetadata | null
- cost: string
- workspaceId: string | null
- workflowId: string | null
- executionId: string | null
- }> = []
-
- if (params.baseExecutionCharge && params.baseExecutionCharge > 0) {
- entries.push({
- id: crypto.randomUUID(),
- userId: params.userId,
- category: 'fixed',
- source: 'workflow',
- description: 'execution_fee',
- metadata: null,
- cost: params.baseExecutionCharge.toString(),
- workspaceId: params.workspaceId ?? null,
- workflowId: params.workflowId,
- executionId: params.executionId ?? null,
- })
- }
-
- if (params.models) {
- for (const [modelName, modelData] of Object.entries(params.models)) {
- if (modelData.total > 0) {
- entries.push({
+ await db.transaction(async (tx) => {
+ if (validEntries.length > 0) {
+ await tx.insert(usageLog).values(
+ validEntries.map((entry) => ({
id: crypto.randomUUID(),
- userId: params.userId,
- category: 'model',
- source: 'workflow',
- description: modelName,
- metadata: {
- inputTokens: modelData.tokens.input,
- outputTokens: modelData.tokens.output,
- ...(modelData.toolCost != null &&
- modelData.toolCost > 0 && { toolCost: modelData.toolCost }),
- },
- cost: modelData.total.toString(),
- workspaceId: params.workspaceId ?? null,
- workflowId: params.workflowId,
- executionId: params.executionId ?? null,
- })
- }
+ userId,
+ category: entry.category,
+ source: entry.source,
+ description: entry.description,
+ metadata: entry.metadata ?? null,
+ cost: entry.cost.toString(),
+ workspaceId: workspaceId ?? null,
+ workflowId: workflowId ?? null,
+ executionId: executionId ?? null,
+ }))
+ )
}
- }
- if (entries.length === 0) {
- return
- }
+ const updateFields: Record = {
+ lastActive: new Date(),
+ ...(totalCost > 0 && {
+ totalCost: sql`total_cost + ${totalCost}`,
+ currentPeriodCost: sql`current_period_cost + ${totalCost}`,
+ }),
+ ...safeStats,
+ }
- try {
- await db.insert(usageLog).values(entries)
+ const result = await tx
+ .update(userStats)
+ .set(updateFields)
+ .where(eq(userStats.userId, userId))
+ .returning({ userId: userStats.userId })
- logger.debug('Logged workflow usage batch', {
- userId: params.userId,
- workflowId: params.workflowId,
- entryCount: entries.length,
- })
- } catch (error) {
- logger.error('Failed to log workflow usage batch', {
- error: error instanceof Error ? error.message : String(error),
- params,
- })
- // Don't throw - usage logging should not break the main flow
- }
+ if (result.length === 0) {
+ logger.warn('recordUsage: userStats row not found, transaction will roll back', {
+ userId,
+ totalCost,
+ })
+ throw new Error(`userStats row not found for userId: ${userId}`)
+ }
+ })
+
+ logger.debug('Recorded usage', {
+ userId,
+ totalCost,
+ entryCount: validEntries.length,
+ sources: [...new Set(validEntries.map((e) => e.source))],
+ })
}
/**
diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts
index 7cfdb6ea9e6..1e18987eb6e 100644
--- a/apps/sim/lib/logs/execution/logger.test.ts
+++ b/apps/sim/lib/logs/execution/logger.test.ts
@@ -29,7 +29,7 @@ vi.mock('@/lib/billing/core/usage', () => ({
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
- logWorkflowUsageBatch: vi.fn(() => Promise.resolve()),
+ recordUsage: vi.fn(() => Promise.resolve()),
}))
vi.mock('@/lib/billing/threshold-billing', () => ({
diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts
index a7c8458ac78..b21a8c9eaad 100644
--- a/apps/sim/lib/logs/execution/logger.ts
+++ b/apps/sim/lib/logs/execution/logger.ts
@@ -16,7 +16,7 @@ import {
getOrgUsageLimit,
maybeSendUsageThresholdEmail,
} from '@/lib/billing/core/usage'
-import { logWorkflowUsageBatch } from '@/lib/billing/core/usage-log'
+import { type ModelUsageMetadata, recordUsage } from '@/lib/billing/core/usage-log'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -37,6 +37,17 @@ import type {
} from '@/lib/logs/types'
import type { SerializableExecutionState } from '@/executor/execution/types'
+/** Maps execution trigger types to their corresponding userStats counter columns */
+const TRIGGER_COUNTER_MAP: Record = {
+ manual: { key: 'totalManualExecutions', column: 'total_manual_executions' },
+ api: { key: 'totalApiCalls', column: 'total_api_calls' },
+ webhook: { key: 'totalWebhookTriggers', column: 'total_webhook_triggers' },
+ schedule: { key: 'totalScheduledExecutions', column: 'total_scheduled_executions' },
+ chat: { key: 'totalChatExecutions', column: 'total_chat_executions' },
+ mcp: { key: 'totalMcpExecutions', column: 'total_mcp_executions' },
+ a2a: { key: 'totalA2aExecutions', column: 'total_a2a_executions' },
+} as const
+
export interface ToolCall {
name: string
duration: number // in milliseconds
@@ -634,66 +645,58 @@ export class ExecutionLogger implements IExecutionLoggerService {
return
}
- const costToStore = costSummary.totalCost
-
- const existing = await db.select().from(userStats).where(eq(userStats.userId, userId))
- if (existing.length === 0) {
- logger.error('User stats record not found - should be created during onboarding', {
- userId,
- trigger,
+ const entries: Array<{
+ category: 'model' | 'fixed'
+ source: 'workflow'
+ description: string
+ cost: number
+ metadata?: ModelUsageMetadata | null
+ }> = []
+
+ if (costSummary.baseExecutionCharge > 0) {
+ entries.push({
+ category: 'fixed',
+ source: 'workflow',
+ description: 'execution_fee',
+ cost: costSummary.baseExecutionCharge,
})
- return
}
- // All costs go to currentPeriodCost - credits are applied at end of billing cycle
- const updateFields: any = {
- totalTokensUsed: sql`total_tokens_used + ${costSummary.totalTokens}`,
- totalCost: sql`total_cost + ${costToStore}`,
- currentPeriodCost: sql`current_period_cost + ${costToStore}`,
- lastActive: new Date(),
+ if (costSummary.models) {
+ for (const [modelName, modelData] of Object.entries(costSummary.models)) {
+ if (modelData.total > 0) {
+ entries.push({
+ category: 'model',
+ source: 'workflow',
+ description: modelName,
+ cost: modelData.total,
+ metadata: {
+ inputTokens: modelData.tokens.input,
+ outputTokens: modelData.tokens.output,
+ ...(modelData.toolCost != null &&
+ modelData.toolCost > 0 && { toolCost: modelData.toolCost }),
+ },
+ })
+ }
+ }
}
- switch (trigger) {
- case 'manual':
- updateFields.totalManualExecutions = sql`total_manual_executions + 1`
- break
- case 'api':
- updateFields.totalApiCalls = sql`total_api_calls + 1`
- break
- case 'webhook':
- updateFields.totalWebhookTriggers = sql`total_webhook_triggers + 1`
- break
- case 'schedule':
- updateFields.totalScheduledExecutions = sql`total_scheduled_executions + 1`
- break
- case 'chat':
- updateFields.totalChatExecutions = sql`total_chat_executions + 1`
- break
- case 'mcp':
- updateFields.totalMcpExecutions = sql`total_mcp_executions + 1`
- break
- case 'a2a':
- updateFields.totalA2aExecutions = sql`total_a2a_executions + 1`
- break
+ const additionalStats: Record> = {
+ totalTokensUsed: sql`total_tokens_used + ${costSummary.totalTokens}`,
}
- await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
-
- logger.debug('Updated user stats record with cost data', {
- userId,
- trigger,
- addedCost: costToStore,
- addedTokens: costSummary.totalTokens,
- })
+ const triggerCounter = TRIGGER_COUNTER_MAP[trigger]
+ if (triggerCounter) {
+ additionalStats[triggerCounter.key] = sql`${sql.raw(triggerCounter.column)} + 1`
+ }
- // Log usage entries for auditing (batch insert for performance)
- await logWorkflowUsageBatch({
+ await recordUsage({
userId,
+ entries,
workspaceId: workflowRecord.workspaceId ?? undefined,
workflowId,
executionId,
- baseExecutionCharge: costSummary.baseExecutionCharge,
- models: costSummary.models,
+ additionalStats,
})
// Check if user has hit overage threshold and bill incrementally
diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts
index e8040bb7dc6..1f596e45a73 100644
--- a/apps/sim/providers/utils.test.ts
+++ b/apps/sim/providers/utils.test.ts
@@ -1401,7 +1401,6 @@ describe('prepareToolExecution', () => {
workspaceId: 'ws-456',
chatId: 'chat-789',
userId: 'user-abc',
- skipFixedUsageLog: true,
})
})
diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts
index 64cc6740b09..eeadb8cacc1 100644
--- a/apps/sim/providers/utils.ts
+++ b/apps/sim/providers/utils.ts
@@ -1147,7 +1147,6 @@ export function prepareToolExecution(
? { isDeployedContext: request.isDeployedContext }
: {}),
...(request.callChain ? { callChain: request.callChain } : {}),
- skipFixedUsageLog: true,
},
}
: {}),
diff --git a/apps/sim/tools/hubspot/list_lists.ts b/apps/sim/tools/hubspot/list_lists.ts
index 7fa3fbabb52..eb0c08fa333 100644
--- a/apps/sim/tools/hubspot/list_lists.ts
+++ b/apps/sim/tools/hubspot/list_lists.ts
@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import type { HubSpotListListsParams, HubSpotListListsResponse } from '@/tools/hubspot/types'
-import { LISTS_ARRAY_OUTPUT, METADATA_OUTPUT_PROPERTIES, PAGING_OUTPUT } from '@/tools/hubspot/types'
+import {
+ LISTS_ARRAY_OUTPUT,
+ METADATA_OUTPUT_PROPERTIES,
+ PAGING_OUTPUT,
+} from '@/tools/hubspot/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('HubSpotListLists')
@@ -99,7 +103,11 @@ export const hubspotListListsTool: ToolConfig ({
- mockIsHosted: { value: false },
- mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record,
- mockGetBYOKKey: vi.fn(),
- mockLogFixedUsage: vi.fn(),
- mockRateLimiterFns: {
- acquireKey: vi.fn(),
- preConsumeCapacity: vi.fn(),
- consumeCapacity: vi.fn(),
- },
- })
-)
+const { mockIsHosted, mockEnv, mockGetBYOKKey, mockRateLimiterFns } = vi.hoisted(() => ({
+ mockIsHosted: { value: false },
+ mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record,
+ mockGetBYOKKey: vi.fn(),
+ mockRateLimiterFns: {
+ acquireKey: vi.fn(),
+ preConsumeCapacity: vi.fn(),
+ consumeCapacity: vi.fn(),
+ },
+}))
// Mock feature flags
vi.mock('@/lib/core/config/feature-flags', () => ({
@@ -55,10 +52,7 @@ vi.mock('@/lib/api-key/byok', () => ({
getBYOKKey: (...args: unknown[]) => mockGetBYOKKey(...args),
}))
-// Mock logFixedUsage for billing
-vi.mock('@/lib/billing/core/usage-log', () => ({
- logFixedUsage: (...args: unknown[]) => mockLogFixedUsage(...args),
-}))
+vi.mock('@/lib/billing/core/usage-log', () => ({}))
vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({
getHostedKeyRateLimiter: () => mockRateLimiterFns,
@@ -1364,7 +1358,6 @@ describe('Hosted Key Injection', () => {
cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
vi.clearAllMocks()
mockGetBYOKKey.mockReset()
- mockLogFixedUsage.mockReset()
})
afterEach(() => {
@@ -2022,7 +2015,6 @@ describe('Cost Field Handling', () => {
mockIsHosted.value = true
mockEnv.TEST_HOSTED_KEY = 'test-hosted-api-key'
mockGetBYOKKey.mockResolvedValue(null)
- mockLogFixedUsage.mockResolvedValue(undefined)
// Set up throttler mock defaults
mockRateLimiterFns.acquireKey.mockResolvedValue({
success: true,
@@ -2097,14 +2089,6 @@ describe('Cost Field Handling', () => {
// This test verifies the tool execution flow when hosted key IS available (by checking output structure).
if (result.output.cost) {
expect(result.output.cost.total).toBe(0.005)
- // Should have logged usage
- expect(mockLogFixedUsage).toHaveBeenCalledWith(
- expect.objectContaining({
- userId: 'user-123',
- cost: 0.005,
- description: 'tool:test_cost_per_request',
- })
- )
}
Object.assign(tools, originalTools)
@@ -2169,8 +2153,6 @@ describe('Cost Field Handling', () => {
expect(result.success).toBe(true)
// Should not have cost since user provided their own key
expect(result.output.cost).toBeUndefined()
- // Should not have logged usage
- expect(mockLogFixedUsage).not.toHaveBeenCalled()
Object.assign(tools, originalTools)
})
@@ -2243,14 +2225,6 @@ describe('Cost Field Handling', () => {
// getCost should have been called with params and output
expect(mockGetCost).toHaveBeenCalled()
- // Should have logged usage with metadata
- expect(mockLogFixedUsage).toHaveBeenCalledWith(
- expect.objectContaining({
- cost: 0.015,
- metadata: { mode: 'advanced', results: 10 },
- })
- )
-
Object.assign(tools, originalTools)
})
})
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index 0dbf1753f6b..fcec6fe3710 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import { getBYOKKey } from '@/lib/api-key/byok'
import { generateInternalToken } from '@/lib/auth/internal'
-import { logFixedUsage } from '@/lib/billing/core/usage-log'
import { isHosted } from '@/lib/core/config/feature-flags'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
import { getHostedKeyRateLimiter } from '@/lib/core/rate-limiter'
@@ -285,31 +284,10 @@ async function processHostedKeyCost(
if (!userId) return { cost, metadata }
- const skipLog = !!ctx?.skipFixedUsageLog || !!tool.hosting?.skipFixedUsageLog
- if (!skipLog) {
- try {
- await logFixedUsage({
- userId,
- source: 'workflow',
- description: `tool:${tool.id}`,
- cost,
- workspaceId: wsId,
- workflowId: wfId,
- executionId: executionContext?.executionId,
- metadata,
- })
- logger.debug(
- `[${requestId}] Logged hosted key cost for ${tool.id}: $${cost}`,
- metadata ? { metadata } : {}
- )
- } catch (error) {
- logger.error(`[${requestId}] Failed to log hosted key usage for ${tool.id}:`, error)
- }
- } else {
- logger.debug(
- `[${requestId}] Skipping fixed usage log for ${tool.id} (cost will be tracked via provider tool loop)`
- )
- }
+ logger.debug(
+ `[${requestId}] Hosted key cost for ${tool.id}: $${cost}`,
+ metadata ? { metadata } : {}
+ )
return { cost, metadata }
}
@@ -388,13 +366,6 @@ async function applyHostedKeyCostToResult(
): Promise {
await reportCustomDimensionUsage(tool, params, finalResult.output, executionContext, requestId)
- if (tool.hosting?.skipFixedUsageLog) {
- const ctx = params._context as Record | undefined
- if (ctx) {
- ctx.skipFixedUsageLog = true
- }
- }
-
const { cost: hostedKeyCost, metadata } = await processHostedKeyCost(
tool,
params,
diff --git a/apps/sim/tools/perplexity/chat.ts b/apps/sim/tools/perplexity/chat.ts
index 6455c39d067..74c1d9b5143 100644
--- a/apps/sim/tools/perplexity/chat.ts
+++ b/apps/sim/tools/perplexity/chat.ts
@@ -152,7 +152,6 @@ export const chatTool: ToolConfig
mode: 'per_request',
requestsPerMinute: 20,
},
- skipFixedUsageLog: true,
},
request: {
diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts
index 10382636b48..320caac26c3 100644
--- a/apps/sim/tools/types.ts
+++ b/apps/sim/tools/types.ts
@@ -312,6 +312,4 @@ export interface ToolHostingConfig> {
pricing: ToolHostingPricing
/** Hosted key rate limit configuration (required for hosted key distribution) */
rateLimit: HostedKeyRateLimitConfig
- /** When true, skip the fixed usage log entry (useful for tools that log custom dimensions instead) */
- skipFixedUsageLog?: boolean
}
From 87e8d3caf87fc3cd59c5e78041eee9d1eb8ad3a5 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Wed, 25 Mar 2026 14:27:02 -0700
Subject: [PATCH 08/20] feat(logs) Add messageId and requestId context to all
mothership log messages (#3770)
Co-authored-by: Theodore Li
---
apps/sim/app/api/copilot/chat/route.ts | 240 +++++++++++------
apps/sim/app/api/copilot/chat/stream/route.ts | 32 ++-
apps/sim/app/api/mothership/chat/route.ts | 63 ++++-
apps/sim/app/api/mothership/execute/route.ts | 12 +-
apps/sim/app/api/v1/copilot/chat/route.ts | 18 +-
apps/sim/lib/copilot/chat-payload.ts | 52 ++--
apps/sim/lib/copilot/chat-streaming.ts | 113 +++++---
apps/sim/lib/copilot/logging.ts | 25 ++
apps/sim/lib/copilot/orchestrator/index.ts | 221 ++++++++++++----
.../sse/handlers/handlers.test.ts | 3 +-
.../orchestrator/sse/handlers/handlers.ts | 242 +++++++++++------
.../sse/handlers/tool-execution.ts | 243 ++++++++++++------
.../lib/copilot/orchestrator/stream/core.ts | 18 +-
.../orchestrator/tool-executor/index.ts | 93 +++++--
apps/sim/lib/copilot/orchestrator/types.ts | 1 +
.../sim/lib/copilot/tools/server/base-tool.ts | 1 +
.../files/download-to-workspace-file.ts | 11 +-
.../tools/server/files/workspace-file.ts | 20 +-
.../tools/server/image/generate-image.ts | 18 +-
.../copilot/tools/server/jobs/get-job-logs.ts | 13 +-
.../tools/server/knowledge/knowledge-base.ts | 42 +--
apps/sim/lib/copilot/tools/server/router.ts | 5 +-
.../copilot/tools/server/table/user-table.ts | 18 +-
.../visualization/generate-visualization.ts | 33 ++-
24 files changed, 1108 insertions(+), 429 deletions(-)
create mode 100644 apps/sim/lib/copilot/logging.ts
diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts
index a3349dfe603..2608a58d090 100644
--- a/apps/sim/app/api/copilot/chat/route.ts
+++ b/apps/sim/app/api/copilot/chat/route.ts
@@ -14,6 +14,7 @@ import {
requestChatTitle,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
@@ -182,25 +183,36 @@ export async function POST(req: NextRequest) {
const wf = await getWorkflowById(workflowId)
resolvedWorkspaceId = wf?.workspaceId ?? undefined
} catch {
- logger.warn(`[${tracker.requestId}] Failed to resolve workspaceId from workflow`)
+ logger.warn(
+ appendCopilotLogContext('Failed to resolve workspaceId from workflow', {
+ requestId: tracker.requestId,
+ messageId: userMessageId,
+ })
+ )
}
const userMessageIdToUse = userMessageId || crypto.randomUUID()
try {
- logger.info(`[${tracker.requestId}] Received chat POST`, {
- workflowId,
- hasContexts: Array.isArray(normalizedContexts),
- contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
- contextsPreview: Array.isArray(normalizedContexts)
- ? normalizedContexts.map((c: any) => ({
- kind: c?.kind,
- chatId: c?.chatId,
- workflowId: c?.workflowId,
- executionId: (c as any)?.executionId,
- label: c?.label,
- }))
- : undefined,
- })
+ logger.info(
+ appendCopilotLogContext('Received chat POST', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }),
+ {
+ workflowId,
+ hasContexts: Array.isArray(normalizedContexts),
+ contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
+ contextsPreview: Array.isArray(normalizedContexts)
+ ? normalizedContexts.map((c: any) => ({
+ kind: c?.kind,
+ chatId: c?.chatId,
+ workflowId: c?.workflowId,
+ executionId: (c as any)?.executionId,
+ label: c?.label,
+ }))
+ : undefined,
+ }
+ )
} catch {}
let currentChat: any = null
@@ -238,22 +250,40 @@ export async function POST(req: NextRequest) {
actualChatId
)
agentContexts = processed
- logger.info(`[${tracker.requestId}] Contexts processed for request`, {
- processedCount: agentContexts.length,
- kinds: agentContexts.map((c) => c.type),
- lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
- })
+ logger.info(
+ appendCopilotLogContext('Contexts processed for request', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }),
+ {
+ processedCount: agentContexts.length,
+ kinds: agentContexts.map((c) => c.type),
+ lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
+ }
+ )
if (
Array.isArray(normalizedContexts) &&
normalizedContexts.length > 0 &&
agentContexts.length === 0
) {
logger.warn(
- `[${tracker.requestId}] Contexts provided but none processed. Check executionId for logs contexts.`
+ appendCopilotLogContext(
+ 'Contexts provided but none processed. Check executionId for logs contexts.',
+ {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }
+ )
)
}
} catch (e) {
- logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
+ logger.error(
+ appendCopilotLogContext('Failed to process contexts', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }),
+ e
+ )
}
}
@@ -283,7 +313,10 @@ export async function POST(req: NextRequest) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
logger.error(
- `[${tracker.requestId}] Failed to resolve resource attachment`,
+ appendCopilotLogContext('Failed to resolve resource attachment', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }),
result.reason
)
}
@@ -324,20 +357,26 @@ export async function POST(req: NextRequest) {
)
try {
- logger.info(`[${tracker.requestId}] About to call Sim Agent`, {
- hasContext: agentContexts.length > 0,
- contextCount: agentContexts.length,
- hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
- messageLength: message.length,
- mode: effectiveMode,
- hasTools: Array.isArray(requestPayload.tools),
- toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
- hasBaseTools: Array.isArray(requestPayload.baseTools),
- baseToolCount: Array.isArray(requestPayload.baseTools)
- ? requestPayload.baseTools.length
- : 0,
- hasCredentials: !!requestPayload.credentials,
- })
+ logger.info(
+ appendCopilotLogContext('About to call Sim Agent', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }),
+ {
+ hasContext: agentContexts.length > 0,
+ contextCount: agentContexts.length,
+ hasFileAttachments: Array.isArray(requestPayload.fileAttachments),
+ messageLength: message.length,
+ mode: effectiveMode,
+ hasTools: Array.isArray(requestPayload.tools),
+ toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0,
+ hasBaseTools: Array.isArray(requestPayload.baseTools),
+ baseToolCount: Array.isArray(requestPayload.baseTools)
+ ? requestPayload.baseTools.length
+ : 0,
+ hasCredentials: !!requestPayload.credentials,
+ }
+ )
} catch {}
if (stream && actualChatId) {
@@ -481,10 +520,16 @@ export async function POST(req: NextRequest) {
.where(eq(copilotChats.id, actualChatId))
}
} catch (error) {
- logger.error(`[${tracker.requestId}] Failed to persist chat messages`, {
- chatId: actualChatId,
- error: error instanceof Error ? error.message : 'Unknown error',
- })
+ logger.error(
+ appendCopilotLogContext('Failed to persist chat messages', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }),
+ {
+ chatId: actualChatId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ }
+ )
}
},
},
@@ -510,13 +555,19 @@ export async function POST(req: NextRequest) {
provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined,
}
- logger.info(`[${tracker.requestId}] Non-streaming response from orchestrator:`, {
- hasContent: !!responseData.content,
- contentLength: responseData.content?.length || 0,
- model: responseData.model,
- provider: responseData.provider,
- toolCallsCount: responseData.toolCalls?.length || 0,
- })
+ logger.info(
+ appendCopilotLogContext('Non-streaming response from orchestrator', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }),
+ {
+ hasContent: !!responseData.content,
+ contentLength: responseData.content?.length || 0,
+ model: responseData.model,
+ provider: responseData.provider,
+ toolCallsCount: responseData.toolCalls?.length || 0,
+ }
+ )
// Save messages if we have a chat
if (currentChat && responseData.content) {
@@ -549,8 +600,13 @@ export async function POST(req: NextRequest) {
// Start title generation in parallel if this is first message (non-streaming)
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
- logger.info(`[${tracker.requestId}] Starting title generation for non-streaming response`)
- requestChatTitle({ message, model: selectedModel, provider })
+ logger.info(
+ appendCopilotLogContext('Starting title generation for non-streaming response', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ })
+ )
+ requestChatTitle({ message, model: selectedModel, provider, messageId: userMessageIdToUse })
.then(async (title) => {
if (title) {
await db
@@ -560,11 +616,22 @@ export async function POST(req: NextRequest) {
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
- logger.info(`[${tracker.requestId}] Generated and saved title: ${title}`)
+ logger.info(
+ appendCopilotLogContext(`Generated and saved title: ${title}`, {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ })
+ )
}
})
.catch((error) => {
- logger.error(`[${tracker.requestId}] Title generation failed:`, error)
+ logger.error(
+ appendCopilotLogContext('Title generation failed', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }),
+ error
+ )
})
}
@@ -578,11 +645,17 @@ export async function POST(req: NextRequest) {
.where(eq(copilotChats.id, actualChatId!))
}
- logger.info(`[${tracker.requestId}] Returning non-streaming response`, {
- duration: tracker.getDuration(),
- chatId: actualChatId,
- responseLength: responseData.content?.length || 0,
- })
+ logger.info(
+ appendCopilotLogContext('Returning non-streaming response', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdToUse,
+ }),
+ {
+ duration: tracker.getDuration(),
+ chatId: actualChatId,
+ responseLength: responseData.content?.length || 0,
+ }
+ )
return NextResponse.json({
success: true,
@@ -606,21 +679,33 @@ export async function POST(req: NextRequest) {
const duration = tracker.getDuration()
if (error instanceof z.ZodError) {
- logger.error(`[${tracker.requestId}] Validation error:`, {
- duration,
- errors: error.errors,
- })
+ logger.error(
+ appendCopilotLogContext('Validation error', {
+ requestId: tracker.requestId,
+ messageId: pendingChatStreamID ?? undefined,
+ }),
+ {
+ duration,
+ errors: error.errors,
+ }
+ )
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
- logger.error(`[${tracker.requestId}] Error handling copilot chat:`, {
- duration,
- error: error instanceof Error ? error.message : 'Unknown error',
- stack: error instanceof Error ? error.stack : undefined,
- })
+ logger.error(
+ appendCopilotLogContext('Error handling copilot chat', {
+ requestId: tracker.requestId,
+ messageId: pendingChatStreamID ?? undefined,
+ }),
+ {
+ duration,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ stack: error instanceof Error ? error.stack : undefined,
+ }
+ )
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
@@ -665,11 +750,16 @@ export async function GET(req: NextRequest) {
status: meta?.status || 'unknown',
}
} catch (err) {
- logger.warn('Failed to read stream snapshot for chat', {
- chatId,
- conversationId: chat.conversationId,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to read stream snapshot for chat', {
+ messageId: chat.conversationId || undefined,
+ }),
+ {
+ chatId,
+ conversationId: chat.conversationId,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
}
}
@@ -688,7 +778,11 @@ export async function GET(req: NextRequest) {
...(streamSnapshot ? { streamSnapshot } : {}),
}
- logger.info(`Retrieved chat ${chatId}`)
+ logger.info(
+ appendCopilotLogContext(`Retrieved chat ${chatId}`, {
+ messageId: chat.conversationId || undefined,
+ })
+ )
return NextResponse.json({ success: true, chat: transformedChat })
}
@@ -750,7 +844,7 @@ export async function GET(req: NextRequest) {
chats: transformedChats,
})
} catch (error) {
- logger.error('Error fetching copilot chats:', error)
+ logger.error('Error fetching copilot chats', error)
return createInternalServerErrorResponse('Failed to fetch chats')
}
}
diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts
index 3234b98d085..851fb642034 100644
--- a/apps/sim/app/api/copilot/chat/stream/route.ts
+++ b/apps/sim/app/api/copilot/chat/stream/route.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
getStreamMeta,
readStreamEvents,
@@ -35,12 +36,24 @@ export async function GET(request: NextRequest) {
const toParam = url.searchParams.get('to')
const toEventId = toParam ? Number(toParam) : undefined
+ logger.info(
+ appendCopilotLogContext('[Resume] Received resume request', {
+ messageId: streamId || undefined,
+ }),
+ {
+ streamId: streamId || undefined,
+ fromEventId,
+ toEventId,
+ batchMode,
+ }
+ )
+
if (!streamId) {
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
}
const meta = (await getStreamMeta(streamId)) as StreamMeta | null
- logger.info('[Resume] Stream lookup', {
+ logger.info(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), {
streamId,
fromEventId,
toEventId,
@@ -59,7 +72,7 @@ export async function GET(request: NextRequest) {
if (batchMode) {
const events = await readStreamEvents(streamId, fromEventId)
const filteredEvents = toEventId ? events.filter((e) => e.eventId <= toEventId) : events
- logger.info('[Resume] Batch response', {
+ logger.info(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), {
streamId,
fromEventId,
toEventId,
@@ -111,11 +124,14 @@ export async function GET(request: NextRequest) {
const flushEvents = async () => {
const events = await readStreamEvents(streamId, lastEventId)
if (events.length > 0) {
- logger.info('[Resume] Flushing events', {
- streamId,
- fromEventId: lastEventId,
- eventCount: events.length,
- })
+ logger.info(
+ appendCopilotLogContext('[Resume] Flushing events', { messageId: streamId }),
+ {
+ streamId,
+ fromEventId: lastEventId,
+ eventCount: events.length,
+ }
+ )
}
for (const entry of events) {
lastEventId = entry.eventId
@@ -162,7 +178,7 @@ export async function GET(request: NextRequest) {
}
} catch (error) {
if (!controllerClosed && !request.signal.aborted) {
- logger.warn('Stream replay failed', {
+ logger.warn(appendCopilotLogContext('Stream replay failed', { messageId: streamId }), {
streamId,
error: error instanceof Error ? error.message : String(error),
})
diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts
index ba262842cf1..c5fdddba6fb 100644
--- a/apps/sim/app/api/mothership/chat/route.ts
+++ b/apps/sim/app/api/mothership/chat/route.ts
@@ -12,6 +12,7 @@ import {
createSSEStream,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers'
@@ -87,6 +88,7 @@ const MothershipMessageSchema = z.object({
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
+ let userMessageIdForLogs: string | undefined
try {
const session = await getSession()
@@ -109,6 +111,28 @@ export async function POST(req: NextRequest) {
} = MothershipMessageSchema.parse(body)
const userMessageId = providedMessageId || crypto.randomUUID()
+ userMessageIdForLogs = userMessageId
+
+ logger.info(
+ appendCopilotLogContext('Received mothership chat start request', {
+ requestId: tracker.requestId,
+ messageId: userMessageId,
+ }),
+ {
+ workspaceId,
+ chatId,
+ createNewChat,
+ hasContexts: Array.isArray(contexts) && contexts.length > 0,
+ contextsCount: Array.isArray(contexts) ? contexts.length : 0,
+ hasResourceAttachments:
+ Array.isArray(resourceAttachments) && resourceAttachments.length > 0,
+ resourceAttachmentCount: Array.isArray(resourceAttachments)
+ ? resourceAttachments.length
+ : 0,
+ hasFileAttachments: Array.isArray(fileAttachments) && fileAttachments.length > 0,
+ fileAttachmentCount: Array.isArray(fileAttachments) ? fileAttachments.length : 0,
+ }
+ )
try {
await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId)
@@ -150,7 +174,13 @@ export async function POST(req: NextRequest) {
actualChatId
)
} catch (e) {
- logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
+ logger.error(
+ appendCopilotLogContext('Failed to process contexts', {
+ requestId: tracker.requestId,
+ messageId: userMessageId,
+ }),
+ e
+ )
}
}
@@ -176,7 +206,10 @@ export async function POST(req: NextRequest) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
logger.error(
- `[${tracker.requestId}] Failed to resolve resource attachment`,
+ appendCopilotLogContext('Failed to resolve resource attachment', {
+ requestId: tracker.requestId,
+ messageId: userMessageId,
+ }),
result.reason
)
}
@@ -366,10 +399,16 @@ export async function POST(req: NextRequest) {
})
}
} catch (error) {
- logger.error(`[${tracker.requestId}] Failed to persist chat messages`, {
- chatId: actualChatId,
- error: error instanceof Error ? error.message : 'Unknown error',
- })
+ logger.error(
+ appendCopilotLogContext('Failed to persist chat messages', {
+ requestId: tracker.requestId,
+ messageId: userMessageId,
+ }),
+ {
+ chatId: actualChatId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ }
+ )
}
},
},
@@ -384,9 +423,15 @@ export async function POST(req: NextRequest) {
)
}
- logger.error(`[${tracker.requestId}] Error handling mothership chat:`, {
- error: error instanceof Error ? error.message : 'Unknown error',
- })
+ logger.error(
+ appendCopilotLogContext('Error handling mothership chat', {
+ requestId: tracker.requestId,
+ messageId: userMessageIdForLogs,
+ }),
+ {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ }
+ )
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts
index 6ec8bc33e24..1632b028d12 100644
--- a/apps/sim/app/api/mothership/execute/route.ts
+++ b/apps/sim/app/api/mothership/execute/route.ts
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
import {
@@ -35,6 +36,8 @@ const ExecuteRequestSchema = z.object({
* Consumes the Go SSE stream internally and returns a single JSON response.
*/
export async function POST(req: NextRequest) {
+ let messageId: string | undefined
+
try {
const auth = await checkInternalAuth(req, { requireWorkflowId: false })
if (!auth.success) {
@@ -48,9 +51,10 @@ export async function POST(req: NextRequest) {
await assertActiveWorkspaceAccess(workspaceId, userId)
const effectiveChatId = chatId || crypto.randomUUID()
+ messageId = crypto.randomUUID()
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
generateWorkspaceContext(workspaceId, userId),
- buildIntegrationToolSchemas(userId),
+ buildIntegrationToolSchemas(userId, messageId),
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
])
@@ -60,7 +64,7 @@ export async function POST(req: NextRequest) {
userId,
chatId: effectiveChatId,
mode: 'agent',
- messageId: crypto.randomUUID(),
+ messageId,
isHosted: true,
workspaceContext,
...(integrationTools.length > 0 ? { integrationTools } : {}),
@@ -77,7 +81,7 @@ export async function POST(req: NextRequest) {
})
if (!result.success) {
- logger.error('Mothership execute failed', {
+ logger.error(appendCopilotLogContext('Mothership execute failed', { messageId }), {
error: result.error,
errors: result.errors,
})
@@ -116,7 +120,7 @@ export async function POST(req: NextRequest) {
)
}
- logger.error('Mothership execute error', {
+ logger.error(appendCopilotLogContext('Mothership execute error', { messageId }), {
error: error instanceof Error ? error.message : 'Unknown error',
})
diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts
index 2c60a0a9a22..9a2912633b9 100644
--- a/apps/sim/app/api/v1/copilot/chat/route.ts
+++ b/apps/sim/app/api/v1/copilot/chat/route.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils'
@@ -32,6 +33,7 @@ const RequestSchema = z.object({
* - The copilot can still operate on any workflow using list_user_workflows
*/
export async function POST(req: NextRequest) {
+ let messageId: string | undefined
const auth = await authenticateV1Request(req)
if (!auth.authenticated || !auth.userId) {
return NextResponse.json(
@@ -80,13 +82,25 @@ export async function POST(req: NextRequest) {
// Always generate a chatId - required for artifacts system to work with subagents
const chatId = parsed.chatId || crypto.randomUUID()
+ messageId = crypto.randomUUID()
+ logger.info(
+ appendCopilotLogContext('Received headless copilot chat start request', { messageId }),
+ {
+ workflowId: resolved.workflowId,
+ workflowName: parsed.workflowName,
+ chatId,
+ mode: transportMode,
+ autoExecuteTools: parsed.autoExecuteTools,
+ timeout: parsed.timeout,
+ }
+ )
const requestPayload = {
message: parsed.message,
workflowId: resolved.workflowId,
userId: auth.userId,
model: selectedModel,
mode: transportMode,
- messageId: crypto.randomUUID(),
+ messageId,
chatId,
}
@@ -115,7 +129,7 @@ export async function POST(req: NextRequest) {
)
}
- logger.error('Headless copilot request failed', {
+ logger.error(appendCopilotLogContext('Headless copilot request failed', { messageId }), {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
diff --git a/apps/sim/lib/copilot/chat-payload.ts b/apps/sim/lib/copilot/chat-payload.ts
index 3c0bd22e5f3..783bb1e1eee 100644
--- a/apps/sim/lib/copilot/chat-payload.ts
+++ b/apps/sim/lib/copilot/chat-payload.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getUserSubscriptionState } from '@/lib/billing/core/subscription'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions'
import { isHosted } from '@/lib/core/config/feature-flags'
import { createMcpToolId } from '@/lib/mcp/utils'
@@ -45,7 +46,10 @@ export interface ToolSchema {
* Shared by the interactive chat payload builder and the non-interactive
* block execution route so both paths send the same tool definitions to Go.
*/
-export async function buildIntegrationToolSchemas(userId: string): Promise {
+export async function buildIntegrationToolSchemas(
+ userId: string,
+ messageId?: string
+): Promise {
const integrationTools: ToolSchema[] = []
try {
const { createUserToolSchema } = await import('@/tools/params')
@@ -56,10 +60,15 @@ export async function buildIntegrationToolSchemas(userId: string): Promise 0) {
- logger.info('Added MCP tools to copilot payload', { count: mcpTools.length })
+ logger.info(
+ appendCopilotLogContext('Added MCP tools to copilot payload', {
+ messageId: userMessageId,
+ }),
+ { count: mcpTools.length }
+ )
}
}
} catch (error) {
- logger.warn('Failed to discover MCP tools for copilot', {
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to discover MCP tools for copilot', {
+ messageId: userMessageId,
+ }),
+ {
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
}
}
}
diff --git a/apps/sim/lib/copilot/chat-streaming.ts b/apps/sim/lib/copilot/chat-streaming.ts
index dc0ad0a698c..0d090a892d3 100644
--- a/apps/sim/lib/copilot/chat-streaming.ts
+++ b/apps/sim/lib/copilot/chat-streaming.ts
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import type { OrchestrateStreamOptions } from '@/lib/copilot/orchestrator'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import {
@@ -205,8 +206,9 @@ export async function requestChatTitle(params: {
message: string
model: string
provider?: string
+ messageId?: string
}): Promise {
- const { message, model, provider } = params
+ const { message, model, provider, messageId } = params
if (!message || !model) return null
const headers: Record = { 'Content-Type': 'application/json' }
@@ -223,17 +225,20 @@ export async function requestChatTitle(params: {
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
- logger.warn('Failed to generate chat title via copilot backend', {
- status: response.status,
- error: payload,
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to generate chat title via copilot backend', { messageId }),
+ {
+ status: response.status,
+ error: payload,
+ }
+ )
return null
}
const title = typeof payload?.title === 'string' ? payload.title.trim() : ''
return title || null
} catch (error) {
- logger.error('Error generating chat title:', error)
+ logger.error(appendCopilotLogContext('Error generating chat title', { messageId }), error)
return null
}
}
@@ -274,6 +279,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
orchestrateOptions,
pendingChatStreamAlreadyRegistered = false,
} = params
+ const messageId =
+ typeof requestPayload.messageId === 'string' ? requestPayload.messageId : streamId
let eventWriter: ReturnType | null = null
let clientDisconnected = false
@@ -303,9 +310,15 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
provider: (requestPayload.provider as string | undefined) || null,
requestContext: { requestId },
}).catch((error) => {
- logger.warn(`[${requestId}] Failed to create copilot run segment`, {
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to create copilot run segment', {
+ requestId,
+ messageId,
+ }),
+ {
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
})
}
eventWriter = createStreamEventWriter(streamId)
@@ -324,10 +337,16 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
await redis.del(getStreamAbortKey(streamId))
}
} catch (error) {
- logger.warn(`[${requestId}] Failed to poll distributed stream abort`, {
- streamId,
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to poll distributed stream abort', {
+ requestId,
+ messageId,
+ }),
+ {
+ streamId,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
}
})()
}, STREAM_ABORT_POLL_MS)
@@ -344,11 +363,14 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
await eventWriter.flush()
}
} catch (error) {
- logger.error(`[${requestId}] Failed to persist stream event`, {
- eventType: event.type,
- eventId,
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.error(
+ appendCopilotLogContext('Failed to persist stream event', { requestId, messageId }),
+ {
+ eventType: event.type,
+ eventId,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
// Keep the live SSE stream going even if durable buffering hiccups.
}
@@ -367,7 +389,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
try {
await pushEvent(event)
} catch (error) {
- logger.error(`[${requestId}] Failed to push event`, {
+ logger.error(appendCopilotLogContext('Failed to push event', { requestId, messageId }), {
eventType: event.type,
error: error instanceof Error ? error.message : String(error),
})
@@ -379,7 +401,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
}
if (chatId && !currentChat?.title && isNewChat) {
- requestChatTitle({ message, model: titleModel, provider: titleProvider })
+ requestChatTitle({ message, model: titleModel, provider: titleProvider, messageId })
.then(async (title) => {
if (title) {
await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId!))
@@ -390,7 +412,10 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
}
})
.catch((error) => {
- logger.error(`[${requestId}] Title generation failed:`, error)
+ logger.error(
+ appendCopilotLogContext('Title generation failed', { requestId, messageId }),
+ error
+ )
})
}
@@ -415,7 +440,9 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
})
if (abortController.signal.aborted) {
- logger.info(`[${requestId}] Stream aborted by explicit stop`)
+ logger.info(
+ appendCopilotLogContext('Stream aborted by explicit stop', { requestId, messageId })
+ )
await eventWriter.close().catch(() => {})
await setStreamMeta(streamId, { status: 'cancelled', userId, executionId, runId })
await updateRunStatus(runId, 'cancelled', { completedAt: new Date() }).catch(() => {})
@@ -429,14 +456,23 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
'An unexpected error occurred while processing the response.'
if (clientDisconnected) {
- logger.info(`[${requestId}] Stream failed after client disconnect`, {
- error: errorMessage,
- })
+ logger.info(
+ appendCopilotLogContext('Stream failed after client disconnect', {
+ requestId,
+ messageId,
+ }),
+ {
+ error: errorMessage,
+ }
+ )
}
- logger.error(`[${requestId}] Orchestration returned failure`, {
- error: errorMessage,
- })
+ logger.error(
+ appendCopilotLogContext('Orchestration returned failure', { requestId, messageId }),
+ {
+ error: errorMessage,
+ }
+ )
await pushEventBestEffort({
type: 'error',
error: errorMessage,
@@ -464,18 +500,29 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
await updateRunStatus(runId, 'complete', { completedAt: new Date() }).catch(() => {})
} catch (error) {
if (abortController.signal.aborted) {
- logger.info(`[${requestId}] Stream aborted by explicit stop`)
+ logger.info(
+ appendCopilotLogContext('Stream aborted by explicit stop', { requestId, messageId })
+ )
await eventWriter.close().catch(() => {})
await setStreamMeta(streamId, { status: 'cancelled', userId, executionId, runId })
await updateRunStatus(runId, 'cancelled', { completedAt: new Date() }).catch(() => {})
return
}
if (clientDisconnected) {
- logger.info(`[${requestId}] Stream errored after client disconnect`, {
- error: error instanceof Error ? error.message : 'Stream error',
- })
+ logger.info(
+ appendCopilotLogContext('Stream errored after client disconnect', {
+ requestId,
+ messageId,
+ }),
+ {
+ error: error instanceof Error ? error.message : 'Stream error',
+ }
+ )
}
- logger.error(`[${requestId}] Orchestration error:`, error)
+ logger.error(
+ appendCopilotLogContext('Orchestration error', { requestId, messageId }),
+ error
+ )
const errorMessage = error instanceof Error ? error.message : 'Stream error'
await pushEventBestEffort({
type: 'error',
diff --git a/apps/sim/lib/copilot/logging.ts b/apps/sim/lib/copilot/logging.ts
new file mode 100644
index 00000000000..b1f0aa90435
--- /dev/null
+++ b/apps/sim/lib/copilot/logging.ts
@@ -0,0 +1,25 @@
+export interface CopilotLogContext {
+ requestId?: string
+ messageId?: string
+}
+
+/**
+ * Appends copilot request identifiers to a log message.
+ */
+export function appendCopilotLogContext(message: string, context: CopilotLogContext = {}): string {
+ const suffixParts: string[] = []
+
+ if (context.requestId) {
+ suffixParts.push(`requestId:${context.requestId}`)
+ }
+
+ if (context.messageId) {
+ suffixParts.push(`messageId:${context.messageId}`)
+ }
+
+ if (suffixParts.length === 0) {
+ return message
+ }
+
+ return `${message} [${suffixParts.join(' ')}]`
+}
diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts
index 3320b0df33d..8861b23e47c 100644
--- a/apps/sim/lib/copilot/orchestrator/index.ts
+++ b/apps/sim/lib/copilot/orchestrator/index.ts
@@ -14,6 +14,7 @@ import {
updateRunStatus,
} from '@/lib/copilot/async-runs/repository'
import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
isToolAvailableOnSimSide,
prepareExecutionContext,
@@ -117,15 +118,32 @@ export async function orchestrateCopilotStream(
execContext.abortSignal = options.abortSignal
const payloadMsgId = requestPayload?.messageId
+ const messageId = typeof payloadMsgId === 'string' ? payloadMsgId : crypto.randomUUID()
+ execContext.messageId = messageId
const context = createStreamingContext({
chatId,
executionId,
runId,
- messageId: typeof payloadMsgId === 'string' ? payloadMsgId : crypto.randomUUID(),
+ messageId,
})
+ const withLogContext = (message: string) =>
+ appendCopilotLogContext(message, {
+ requestId: context.requestId,
+ messageId,
+ })
let claimedToolCallIds: string[] = []
let claimedByWorkerId: string | null = null
+ logger.info(withLogContext('Starting copilot orchestration'), {
+ goRoute,
+ workflowId,
+ workspaceId,
+ chatId,
+ executionId,
+ runId,
+ hasUserTimezone: Boolean(userTimezone),
+ })
+
try {
let route = goRoute
let payload = requestPayload
@@ -135,6 +153,12 @@ export async function orchestrateCopilotStream(
for (;;) {
context.streamComplete = false
+ logger.info(withLogContext('Starting orchestration loop iteration'), {
+ route,
+ hasPendingAsyncContinuation: Boolean(context.awaitingAsyncContinuation),
+ claimedToolCallCount: claimedToolCallIds.length,
+ })
+
const loopOptions = {
...options,
onEvent: async (event: SSEEvent) => {
@@ -142,6 +166,14 @@ export async function orchestrateCopilotStream(
const d = (event.data ?? {}) as Record
const response = (d.response ?? {}) as Record
if (response.async_pause) {
+ logger.info(withLogContext('Detected async pause from copilot backend'), {
+ route,
+ checkpointId:
+ typeof (response.async_pause as Record)?.checkpointId ===
+ 'string'
+ ? (response.async_pause as Record).checkpointId
+ : undefined,
+ })
if (runId) {
await updateRunStatus(runId, 'paused_waiting_for_tool').catch(() => {})
}
@@ -167,8 +199,18 @@ export async function orchestrateCopilotStream(
loopOptions
)
+ logger.info(withLogContext('Completed orchestration loop iteration'), {
+ route,
+ streamComplete: context.streamComplete,
+ wasAborted: context.wasAborted,
+ hasAsyncContinuation: Boolean(context.awaitingAsyncContinuation),
+ errorCount: context.errors.length,
+ })
+
if (claimedToolCallIds.length > 0) {
- logger.info('Marking async tool calls as delivered', { toolCallIds: claimedToolCallIds })
+ logger.info(withLogContext('Marking async tool calls as delivered'), {
+ toolCallIds: claimedToolCallIds,
+ })
await Promise.all(
claimedToolCallIds.map((toolCallId) =>
markAsyncToolDelivered(toolCallId).catch(() => null)
@@ -179,6 +221,11 @@ export async function orchestrateCopilotStream(
}
if (options.abortSignal?.aborted || context.wasAborted) {
+ logger.info(withLogContext('Stopping orchestration because request was aborted'), {
+ pendingToolCallCount: Array.from(context.toolCalls.values()).filter(
+ (toolCall) => toolCall.status === 'pending' || toolCall.status === 'executing'
+ ).length,
+ })
for (const [toolCallId, toolCall] of context.toolCalls) {
if (toolCall.status === 'pending' || toolCall.status === 'executing') {
toolCall.status = 'cancelled'
@@ -191,10 +238,18 @@ export async function orchestrateCopilotStream(
}
const continuation = context.awaitingAsyncContinuation
- if (!continuation) break
+ if (!continuation) {
+ logger.info(withLogContext('No async continuation pending; finishing orchestration'))
+ break
+ }
let resumeReady = false
let resumeRetries = 0
+ logger.info(withLogContext('Processing async continuation'), {
+ checkpointId: continuation.checkpointId,
+ runId: continuation.runId,
+ pendingToolCallIds: continuation.pendingToolCallIds,
+ })
for (;;) {
claimedToolCallIds = []
claimedByWorkerId = null
@@ -210,21 +265,31 @@ export async function orchestrateCopilotStream(
if (localPendingPromise) {
localPendingPromises.push(localPendingPromise)
- logger.info('Waiting for local async tool completion before retrying resume claim', {
- toolCallId,
- runId: continuation.runId,
- })
+ logger.info(
+ withLogContext(
+ 'Waiting for local async tool completion before retrying resume claim'
+ ),
+ {
+ toolCallId,
+ runId: continuation.runId,
+ }
+ )
continue
}
if (durableRow && isTerminalAsyncStatus(durableRow.status)) {
if (durableRow.claimedBy && durableRow.claimedBy !== resumeWorkerId) {
missingToolCallIds.push(toolCallId)
- logger.warn('Async tool continuation is waiting on a claim held by another worker', {
- toolCallId,
- runId: continuation.runId,
- claimedBy: durableRow.claimedBy,
- })
+ logger.warn(
+ withLogContext(
+ 'Async tool continuation is waiting on a claim held by another worker'
+ ),
+ {
+ toolCallId,
+ runId: continuation.runId,
+ claimedBy: durableRow.claimedBy,
+ }
+ )
continue
}
readyTools.push({
@@ -243,12 +308,15 @@ export async function orchestrateCopilotStream(
isTerminalToolCallStatus(toolState.status) &&
!isToolAvailableOnSimSide(toolState.name)
) {
- logger.info('Including Go-handled tool in resume payload (no Sim-side row)', {
- toolCallId,
- toolName: toolState.name,
- status: toolState.status,
- runId: continuation.runId,
- })
+ logger.info(
+ withLogContext('Including Go-handled tool in resume payload (no Sim-side row)'),
+ {
+ toolCallId,
+ toolName: toolState.name,
+ status: toolState.status,
+ runId: continuation.runId,
+ }
+ )
readyTools.push({
toolCallId,
toolState,
@@ -258,7 +326,7 @@ export async function orchestrateCopilotStream(
continue
}
- logger.warn('Skipping already-claimed or missing async tool resume', {
+ logger.warn(withLogContext('Skipping already-claimed or missing async tool resume'), {
toolCallId,
runId: continuation.runId,
durableStatus: durableRow?.status,
@@ -268,6 +336,13 @@ export async function orchestrateCopilotStream(
}
if (localPendingPromises.length > 0) {
+ logger.info(
+ withLogContext('Waiting for local pending async tools before resuming continuation'),
+ {
+ checkpointId: continuation.checkpointId,
+ pendingPromiseCount: localPendingPromises.length,
+ }
+ )
await Promise.allSettled(localPendingPromises)
continue
}
@@ -275,15 +350,28 @@ export async function orchestrateCopilotStream(
if (missingToolCallIds.length > 0) {
if (resumeRetries < 3) {
resumeRetries++
- logger.info('Retrying async resume after some tool calls were not yet ready', {
- checkpointId: continuation.checkpointId,
- runId: continuation.runId,
- retry: resumeRetries,
- missingToolCallIds,
- })
+ logger.info(
+ withLogContext('Retrying async resume after some tool calls were not yet ready'),
+ {
+ checkpointId: continuation.checkpointId,
+ runId: continuation.runId,
+ retry: resumeRetries,
+ missingToolCallIds,
+ }
+ )
await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries))
continue
}
+ logger.error(
+ withLogContext(
+ 'Async continuation failed because pending tool calls never became ready'
+ ),
+ {
+ checkpointId: continuation.checkpointId,
+ runId: continuation.runId,
+ missingToolCallIds,
+ }
+ )
throw new Error(
`Failed to resume async tool continuation: pending tool calls were not ready (${missingToolCallIds.join(', ')})`
)
@@ -292,14 +380,25 @@ export async function orchestrateCopilotStream(
if (readyTools.length === 0) {
if (resumeRetries < 3 && continuation.pendingToolCallIds.length > 0) {
resumeRetries++
- logger.info('Retrying async resume because no tool calls were ready yet', {
- checkpointId: continuation.checkpointId,
- runId: continuation.runId,
- retry: resumeRetries,
- })
+ logger.info(
+ withLogContext('Retrying async resume because no tool calls were ready yet'),
+ {
+ checkpointId: continuation.checkpointId,
+ runId: continuation.runId,
+ retry: resumeRetries,
+ }
+ )
await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries))
continue
}
+ logger.error(
+ withLogContext('Async continuation failed because no tool calls were ready'),
+ {
+ checkpointId: continuation.checkpointId,
+ runId: continuation.runId,
+ requestedToolCallIds: continuation.pendingToolCallIds,
+ }
+ )
throw new Error('Failed to resume async tool continuation: no tool calls were ready')
}
@@ -320,12 +419,15 @@ export async function orchestrateCopilotStream(
if (claimFailures.length > 0) {
if (newlyClaimedToolCallIds.length > 0) {
- logger.info('Releasing async tool claims after claim contention during resume', {
- checkpointId: continuation.checkpointId,
- runId: continuation.runId,
- newlyClaimedToolCallIds,
- claimFailures,
- })
+ logger.info(
+ withLogContext('Releasing async tool claims after claim contention during resume'),
+ {
+ checkpointId: continuation.checkpointId,
+ runId: continuation.runId,
+ newlyClaimedToolCallIds,
+ claimFailures,
+ }
+ )
await Promise.all(
newlyClaimedToolCallIds.map((toolCallId) =>
releaseCompletedAsyncToolClaim(toolCallId, resumeWorkerId).catch(() => null)
@@ -334,7 +436,7 @@ export async function orchestrateCopilotStream(
}
if (resumeRetries < 3) {
resumeRetries++
- logger.info('Retrying async resume after claim contention', {
+ logger.info(withLogContext('Retrying async resume after claim contention'), {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
retry: resumeRetries,
@@ -343,6 +445,14 @@ export async function orchestrateCopilotStream(
await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries))
continue
}
+ logger.error(
+ withLogContext('Async continuation failed because tool claims could not be acquired'),
+ {
+ checkpointId: continuation.checkpointId,
+ runId: continuation.runId,
+ claimFailures,
+ }
+ )
throw new Error(
`Failed to resume async tool continuation: unable to claim tool calls (${claimFailures.join(', ')})`
)
@@ -356,7 +466,7 @@ export async function orchestrateCopilotStream(
]
claimedByWorkerId = claimedToolCallIds.length > 0 ? resumeWorkerId : null
- logger.info('Resuming async tool continuation', {
+ logger.info(withLogContext('Resuming async tool continuation'), {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
toolCallIds: readyTools.map((tool) => tool.toolCallId),
@@ -395,10 +505,15 @@ export async function orchestrateCopilotStream(
!isTerminalAsyncStatus(durableStatus) &&
!isDeliveredAsyncStatus(durableStatus)
) {
- logger.warn('Async tool row was claimed for resume without terminal durable state', {
- toolCallId: tool.toolCallId,
- status: durableStatus,
- })
+ logger.warn(
+ withLogContext(
+ 'Async tool row was claimed for resume without terminal durable state'
+ ),
+ {
+ toolCallId: tool.toolCallId,
+ status: durableStatus,
+ }
+ )
}
return {
@@ -416,11 +531,20 @@ export async function orchestrateCopilotStream(
checkpointId: continuation.checkpointId,
results,
}
+ logger.info(withLogContext('Prepared async continuation payload for resume endpoint'), {
+ route,
+ checkpointId: continuation.checkpointId,
+ resultCount: results.length,
+ })
resumeReady = true
break
}
if (!resumeReady) {
+ logger.warn(withLogContext('Async continuation loop exited without resume payload'), {
+ checkpointId: continuation.checkpointId,
+ runId: continuation.runId,
+ })
break
}
}
@@ -436,12 +560,19 @@ export async function orchestrateCopilotStream(
usage: context.usage,
cost: context.cost,
}
+ logger.info(withLogContext('Completing copilot orchestration'), {
+ success: result.success,
+ chatId: result.chatId,
+ hasRequestId: Boolean(result.requestId),
+ errorCount: result.errors?.length || 0,
+ toolCallCount: result.toolCalls.length,
+ })
await options.onComplete?.(result)
return result
} catch (error) {
const err = error instanceof Error ? error : new Error('Copilot orchestration failed')
if (claimedToolCallIds.length > 0 && claimedByWorkerId) {
- logger.warn('Releasing async tool claims after delivery failure', {
+ logger.warn(withLogContext('Releasing async tool claims after delivery failure'), {
toolCallIds: claimedToolCallIds,
workerId: claimedByWorkerId,
})
@@ -451,7 +582,9 @@ export async function orchestrateCopilotStream(
)
)
}
- logger.error('Copilot orchestration failed', { error: err.message })
+ logger.error(withLogContext('Copilot orchestration failed'), {
+ error: err.message,
+ })
await options.onError?.(err)
return {
success: false,
diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts
index 0b4001244d6..98c4952ba33 100644
--- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts
+++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts
@@ -148,7 +148,8 @@ describe('sse-handlers tool lifecycle', () => {
'read',
499,
'Request aborted during tool execution',
- { cancelled: true }
+ { cancelled: true },
+ 'msg-1'
)
const updated = context.toolCalls.get('tool-cancel')
diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts
index 17b820555c2..a0385e5a42b 100644
--- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts
+++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository'
import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
asRecord,
getEventData,
@@ -53,13 +54,25 @@ function abortPendingToolIfStreamDead(
toolCall.status = 'cancelled'
toolCall.endTime = Date.now()
markToolResultSeen(toolCallId)
- markToolComplete(toolCall.id, toolCall.name, 499, 'Request aborted before tool execution', {
- cancelled: true,
- }).catch((err) => {
- logger.error('markToolComplete fire-and-forget failed (stream aborted)', {
- toolCallId: toolCall.id,
- error: err instanceof Error ? err.message : String(err),
- })
+ markToolComplete(
+ toolCall.id,
+ toolCall.name,
+ 499,
+ 'Request aborted before tool execution',
+ {
+ cancelled: true,
+ },
+ context.messageId
+ ).catch((err) => {
+ logger.error(
+ appendCopilotLogContext('markToolComplete fire-and-forget failed (stream aborted)', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId: toolCall.id,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
return true
}
@@ -90,7 +103,8 @@ function getEventUI(event: SSEEvent): {
function handleClientCompletion(
toolCall: ToolCallState,
toolCallId: string,
- completion: { status: string; message?: string; data?: Record } | null
+ completion: { status: string; message?: string; data?: Record } | null,
+ context: StreamingContext
): void {
if (completion?.status === 'background') {
toolCall.status = 'skipped'
@@ -100,12 +114,18 @@ function handleClientCompletion(
toolCall.name,
202,
completion.message || 'Tool execution moved to background',
- { background: true }
+ { background: true },
+ context.messageId
).catch((err) => {
- logger.error('markToolComplete fire-and-forget failed (client background)', {
- toolCallId: toolCall.id,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.error(
+ appendCopilotLogContext('markToolComplete fire-and-forget failed (client background)', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId: toolCall.id,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
markToolResultSeen(toolCallId)
return
@@ -117,12 +137,19 @@ function handleClientCompletion(
toolCall.id,
toolCall.name,
400,
- completion.message || 'Tool execution rejected'
+ completion.message || 'Tool execution rejected',
+ undefined,
+ context.messageId
).catch((err) => {
- logger.error('markToolComplete fire-and-forget failed (client rejected)', {
- toolCallId: toolCall.id,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.error(
+ appendCopilotLogContext('markToolComplete fire-and-forget failed (client rejected)', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId: toolCall.id,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
markToolResultSeen(toolCallId)
return
@@ -135,12 +162,18 @@ function handleClientCompletion(
toolCall.name,
499,
completion.message || 'Workflow execution was stopped manually by the user.',
- completion.data
+ completion.data,
+ context.messageId
).catch((err) => {
- logger.error('markToolComplete fire-and-forget failed (client cancelled)', {
- toolCallId: toolCall.id,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.error(
+ appendCopilotLogContext('markToolComplete fire-and-forget failed (client cancelled)', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId: toolCall.id,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
markToolResultSeen(toolCallId)
return
@@ -149,15 +182,25 @@ function handleClientCompletion(
toolCall.status = success ? 'success' : 'error'
toolCall.endTime = Date.now()
const msg = completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out')
- markToolComplete(toolCall.id, toolCall.name, success ? 200 : 500, msg, completion?.data).catch(
- (err) => {
- logger.error('markToolComplete fire-and-forget failed (client completion)', {
+ markToolComplete(
+ toolCall.id,
+ toolCall.name,
+ success ? 200 : 500,
+ msg,
+ completion?.data,
+ context.messageId
+ ).catch((err) => {
+ logger.error(
+ appendCopilotLogContext('markToolComplete fire-and-forget failed (client completion)', {
+ messageId: context.messageId,
+ }),
+ {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: err instanceof Error ? err.message : String(err),
- })
- }
- )
+ }
+ )
+ })
markToolResultSeen(toolCallId)
}
@@ -170,7 +213,8 @@ async function emitSyntheticToolResult(
toolCallId: string,
toolName: string,
completion: { status: string; message?: string; data?: Record } | null,
- options: OrchestratorOptions
+ options: OrchestratorOptions,
+ context: StreamingContext
): Promise {
const success = completion?.status === 'success'
const isCancelled = completion?.status === 'cancelled'
@@ -189,11 +233,16 @@ async function emitSyntheticToolResult(
error: !success ? completion?.message : undefined,
} as SSEEvent)
} catch (error) {
- logger.warn('Failed to emit synthetic tool_result', {
- toolCallId,
- toolName,
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to emit synthetic tool_result', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId,
+ toolName,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
}
}
@@ -260,6 +309,17 @@ export const sseHandlers: Record = {
const rid = typeof event.data === 'string' ? event.data : undefined
if (rid) {
context.requestId = rid
+ logger.info(
+ appendCopilotLogContext('Mapped copilot message to Go trace ID', {
+ messageId: context.messageId,
+ }),
+ {
+ goTraceId: rid,
+ chatId: context.chatId,
+ executionId: context.executionId,
+ runId: context.runId,
+ }
+ )
}
},
title_updated: () => {},
@@ -406,19 +466,29 @@ export const sseHandlers: Record = {
args,
})
} catch (err) {
- logger.warn('Failed to persist async tool row before execution', {
- toolCallId,
- toolName,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to persist async tool row before execution', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId,
+ toolName,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
}
return executeToolAndReport(toolCallId, context, execContext, options)
})().catch((err) => {
- logger.error('Parallel tool execution failed', {
- toolCallId,
- toolName,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.error(
+ appendCopilotLogContext('Parallel tool execution failed', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId,
+ toolName,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
return {
status: 'error',
message: err instanceof Error ? err.message : String(err),
@@ -457,19 +527,24 @@ export const sseHandlers: Record = {
args,
status: 'running',
}).catch((err) => {
- logger.warn('Failed to persist async tool row for client-executable tool', {
- toolCallId,
- toolName,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to persist async tool row for client-executable tool', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId,
+ toolName,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
- handleClientCompletion(toolCall, toolCallId, completion)
- await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options)
+ handleClientCompletion(toolCall, toolCallId, completion, context)
+ await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options, context)
}
return
}
@@ -651,19 +726,29 @@ export const subAgentHandlers: Record = {
args,
})
} catch (err) {
- logger.warn('Failed to persist async subagent tool row before execution', {
- toolCallId,
- toolName,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to persist async subagent tool row before execution', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId,
+ toolName,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
}
return executeToolAndReport(toolCallId, context, execContext, options)
})().catch((err) => {
- logger.error('Parallel subagent tool execution failed', {
- toolCallId,
- toolName,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.error(
+ appendCopilotLogContext('Parallel subagent tool execution failed', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId,
+ toolName,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
return {
status: 'error',
message: err instanceof Error ? err.message : String(err),
@@ -697,19 +782,25 @@ export const subAgentHandlers: Record = {
args,
status: 'running',
}).catch((err) => {
- logger.warn('Failed to persist async tool row for client-executable subagent tool', {
- toolCallId,
- toolName,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext(
+ 'Failed to persist async tool row for client-executable subagent tool',
+ { messageId: context.messageId }
+ ),
+ {
+ toolCallId,
+ toolName,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
const completion = await waitForToolCompletion(
toolCallId,
options.timeout || STREAM_TIMEOUT_MS,
options.abortSignal
)
- handleClientCompletion(toolCall, toolCallId, completion)
- await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options)
+ handleClientCompletion(toolCall, toolCallId, completion, context)
+ await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options, context)
}
return
}
@@ -769,10 +860,15 @@ export const subAgentHandlers: Record = {
export function handleSubagentRouting(event: SSEEvent, context: StreamingContext): boolean {
if (!event.subagent) return false
if (!context.subAgentParentToolCallId) {
- logger.warn('Subagent event missing parent tool call', {
- type: event.type,
- subagent: event.subagent,
- })
+ logger.warn(
+ appendCopilotLogContext('Subagent event missing parent tool call', {
+ messageId: context.messageId,
+ }),
+ {
+ type: event.type,
+ subagent: event.subagent,
+ }
+ )
return false
}
return true
diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts
index 2b48b5a91c9..e8442e1c76c 100644
--- a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts
+++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts
@@ -3,6 +3,7 @@ import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { completeAsyncToolCall, markAsyncToolRunning } from '@/lib/copilot/async-runs/repository'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { waitForToolConfirmation } from '@/lib/copilot/orchestrator/persistence'
import { asRecord, markToolResultSeen } from '@/lib/copilot/orchestrator/sse/utils'
import { executeToolServerSide, markToolComplete } from '@/lib/copilot/orchestrator/tool-executor'
@@ -186,12 +187,15 @@ async function maybeWriteOutputToFile(
contentType
)
- logger.info('Tool output written to file', {
- toolName,
- fileName,
- size: buffer.length,
- fileId: uploaded.id,
- })
+ logger.info(
+ appendCopilotLogContext('Tool output written to file', { messageId: context.messageId }),
+ {
+ toolName,
+ fileName,
+ size: buffer.length,
+ fileId: uploaded.id,
+ }
+ )
return {
success: true,
@@ -205,11 +209,16 @@ async function maybeWriteOutputToFile(
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
- logger.warn('Failed to write tool output to file', {
- toolName,
- outputPath,
- error: message,
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to write tool output to file', {
+ messageId: context.messageId,
+ }),
+ {
+ toolName,
+ outputPath,
+ error: message,
+ }
+ )
return {
success: false,
error: `Failed to write output file: ${message}`,
@@ -289,10 +298,11 @@ function terminalCompletionFromToolCall(toolCall: {
function reportCancelledTool(
toolCall: { id: string; name: string },
message: string,
+ messageId?: string,
data: Record = { cancelled: true }
): void {
- markToolComplete(toolCall.id, toolCall.name, 499, message, data).catch((err) => {
- logger.error('markToolComplete failed (cancelled)', {
+ markToolComplete(toolCall.id, toolCall.name, 499, message, data, messageId).catch((err) => {
+ logger.error(appendCopilotLogContext('markToolComplete failed (cancelled)', { messageId }), {
toolCallId: toolCall.id,
toolName: toolCall.name,
error: err instanceof Error ? err.message : String(err),
@@ -387,11 +397,14 @@ async function maybeWriteOutputToTable(
}
})
- logger.info('Tool output written to table', {
- toolName,
- tableId: outputTable,
- rowCount: rows.length,
- })
+ logger.info(
+ appendCopilotLogContext('Tool output written to table', { messageId: context.messageId }),
+ {
+ toolName,
+ tableId: outputTable,
+ rowCount: rows.length,
+ }
+ )
return {
success: true,
@@ -402,11 +415,16 @@ async function maybeWriteOutputToTable(
},
}
} catch (err) {
- logger.warn('Failed to write tool output to table', {
- toolName,
- outputTable,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to write tool output to table', {
+ messageId: context.messageId,
+ }),
+ {
+ toolName,
+ outputTable,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
return {
success: false,
error: `Failed to write to table: ${err instanceof Error ? err.message : String(err)}`,
@@ -506,13 +524,16 @@ async function maybeWriteReadCsvToTable(
}
})
- logger.info('Read output written to table', {
- toolName,
- tableId: outputTable,
- tableName: table.name,
- rowCount: rows.length,
- filePath,
- })
+ logger.info(
+ appendCopilotLogContext('Read output written to table', { messageId: context.messageId }),
+ {
+ toolName,
+ tableId: outputTable,
+ tableName: table.name,
+ rowCount: rows.length,
+ filePath,
+ }
+ )
return {
success: true,
@@ -524,11 +545,16 @@ async function maybeWriteReadCsvToTable(
},
}
} catch (err) {
- logger.warn('Failed to write read output to table', {
- toolName,
- outputTable,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to write read output to table', {
+ messageId: context.messageId,
+ }),
+ {
+ toolName,
+ outputTable,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
return {
success: false,
error: `Failed to import into table: ${err instanceof Error ? err.message : String(err)}`,
@@ -562,14 +588,14 @@ export async function executeToolAndReport(
result: { cancelled: true },
error: 'Request aborted before tool execution',
}).catch(() => {})
- reportCancelledTool(toolCall, 'Request aborted before tool execution')
+ reportCancelledTool(toolCall, 'Request aborted before tool execution', context.messageId)
return cancelledCompletion('Request aborted before tool execution')
}
toolCall.status = 'executing'
await markAsyncToolRunning(toolCall.id, 'sim-stream').catch(() => {})
- logger.info('Tool execution started', {
+ logger.info(appendCopilotLogContext('Tool execution started', { messageId: context.messageId }), {
toolCallId: toolCall.id,
toolName: toolCall.name,
params: toolCall.params,
@@ -590,7 +616,7 @@ export async function executeToolAndReport(
result: { cancelled: true },
error: 'Request aborted during tool execution',
}).catch(() => {})
- reportCancelledTool(toolCall, 'Request aborted during tool execution')
+ reportCancelledTool(toolCall, 'Request aborted during tool execution', context.messageId)
return cancelledCompletion('Request aborted during tool execution')
}
result = await maybeWriteOutputToFile(toolCall.name, toolCall.params, result, execContext)
@@ -604,7 +630,11 @@ export async function executeToolAndReport(
result: { cancelled: true },
error: 'Request aborted during tool post-processing',
}).catch(() => {})
- reportCancelledTool(toolCall, 'Request aborted during tool post-processing')
+ reportCancelledTool(
+ toolCall,
+ 'Request aborted during tool post-processing',
+ context.messageId
+ )
return cancelledCompletion('Request aborted during tool post-processing')
}
result = await maybeWriteOutputToTable(toolCall.name, toolCall.params, result, execContext)
@@ -618,7 +648,11 @@ export async function executeToolAndReport(
result: { cancelled: true },
error: 'Request aborted during tool post-processing',
}).catch(() => {})
- reportCancelledTool(toolCall, 'Request aborted during tool post-processing')
+ reportCancelledTool(
+ toolCall,
+ 'Request aborted during tool post-processing',
+ context.messageId
+ )
return cancelledCompletion('Request aborted during tool post-processing')
}
result = await maybeWriteReadCsvToTable(toolCall.name, toolCall.params, result, execContext)
@@ -632,7 +666,11 @@ export async function executeToolAndReport(
result: { cancelled: true },
error: 'Request aborted during tool post-processing',
}).catch(() => {})
- reportCancelledTool(toolCall, 'Request aborted during tool post-processing')
+ reportCancelledTool(
+ toolCall,
+ 'Request aborted during tool post-processing',
+ context.messageId
+ )
return cancelledCompletion('Request aborted during tool post-processing')
}
toolCall.status = result.success ? 'success' : 'error'
@@ -648,18 +686,24 @@ export async function executeToolAndReport(
: raw && typeof raw === 'object'
? JSON.stringify(raw).slice(0, 200)
: undefined
- logger.info('Tool execution succeeded', {
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- outputPreview: preview,
- })
+ logger.info(
+ appendCopilotLogContext('Tool execution succeeded', { messageId: context.messageId }),
+ {
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ outputPreview: preview,
+ }
+ )
} else {
- logger.warn('Tool execution failed', {
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- error: result.error,
- params: toolCall.params,
- })
+ logger.warn(
+ appendCopilotLogContext('Tool execution failed', { messageId: context.messageId }),
+ {
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ error: result.error,
+ params: toolCall.params,
+ }
+ )
}
// If create_workflow was successful, update the execution context with the new workflowId.
@@ -687,7 +731,11 @@ export async function executeToolAndReport(
if (abortRequested(context, execContext, options)) {
toolCall.status = 'cancelled'
- reportCancelledTool(toolCall, 'Request aborted before tool result delivery')
+ reportCancelledTool(
+ toolCall,
+ 'Request aborted before tool result delivery',
+ context.messageId
+ )
return cancelledCompletion('Request aborted before tool result delivery')
}
@@ -702,13 +750,19 @@ export async function executeToolAndReport(
toolCall.name,
result.success ? 200 : 500,
result.error || (result.success ? 'Tool completed' : 'Tool failed'),
- result.output
+ result.output,
+ context.messageId
).catch((err) => {
- logger.error('markToolComplete fire-and-forget failed', {
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.error(
+ appendCopilotLogContext('markToolComplete fire-and-forget failed', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
const resultEvent: SSEEvent = {
@@ -743,10 +797,15 @@ export async function executeToolAndReport(
if (deleted.length > 0) {
isDeleteOp = true
removeChatResources(execContext.chatId, deleted).catch((err) => {
- logger.warn('Failed to remove chat resources after deletion', {
- chatId: execContext.chatId,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to remove chat resources after deletion', {
+ messageId: context.messageId,
+ }),
+ {
+ chatId: execContext.chatId,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
for (const resource of deleted) {
@@ -769,10 +828,15 @@ export async function executeToolAndReport(
if (resources.length > 0) {
persistChatResources(execContext.chatId, resources).catch((err) => {
- logger.warn('Failed to persist chat resources', {
- chatId: execContext.chatId,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to persist chat resources', {
+ messageId: context.messageId,
+ }),
+ {
+ chatId: execContext.chatId,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
for (const resource of resources) {
@@ -801,19 +865,22 @@ export async function executeToolAndReport(
result: { cancelled: true },
error: 'Request aborted during tool execution',
}).catch(() => {})
- reportCancelledTool(toolCall, 'Request aborted during tool execution')
+ reportCancelledTool(toolCall, 'Request aborted during tool execution', context.messageId)
return cancelledCompletion('Request aborted during tool execution')
}
toolCall.status = 'error'
toolCall.error = error instanceof Error ? error.message : String(error)
toolCall.endTime = Date.now()
- logger.error('Tool execution threw', {
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- error: toolCall.error,
- params: toolCall.params,
- })
+ logger.error(
+ appendCopilotLogContext('Tool execution threw', { messageId: context.messageId }),
+ {
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ error: toolCall.error,
+ params: toolCall.params,
+ }
+ )
markToolResultSeen(toolCall.id)
await completeAsyncToolCall({
@@ -825,14 +892,26 @@ export async function executeToolAndReport(
// Fire-and-forget (same reasoning as above).
// Pass error as structured data so the Go side can surface it to the LLM.
- markToolComplete(toolCall.id, toolCall.name, 500, toolCall.error, {
- error: toolCall.error,
- }).catch((err) => {
- logger.error('markToolComplete fire-and-forget failed', {
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- error: err instanceof Error ? err.message : String(err),
- })
+ markToolComplete(
+ toolCall.id,
+ toolCall.name,
+ 500,
+ toolCall.error,
+ {
+ error: toolCall.error,
+ },
+ context.messageId
+ ).catch((err) => {
+ logger.error(
+ appendCopilotLogContext('markToolComplete fire-and-forget failed', {
+ messageId: context.messageId,
+ }),
+ {
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
})
const errorEvent: SSEEvent = {
diff --git a/apps/sim/lib/copilot/orchestrator/stream/core.ts b/apps/sim/lib/copilot/orchestrator/stream/core.ts
index 9367a3b181a..79f5facb47d 100644
--- a/apps/sim/lib/copilot/orchestrator/stream/core.ts
+++ b/apps/sim/lib/copilot/orchestrator/stream/core.ts
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isPaid } from '@/lib/billing/plan-helpers'
import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
handleSubagentRouting,
sseHandlers,
@@ -164,10 +165,13 @@ export async function runStreamLoop(
try {
await options.onEvent?.(normalizedEvent)
} catch (error) {
- logger.warn('Failed to forward SSE event', {
- type: normalizedEvent.type,
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to forward SSE event', { messageId: context.messageId }),
+ {
+ type: normalizedEvent.type,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
}
// Let the caller intercept before standard dispatch.
@@ -201,7 +205,11 @@ export async function runStreamLoop(
if (context.subAgentParentStack.length > 0) {
context.subAgentParentStack.pop()
} else {
- logger.warn('subagent_end without matching subagent_start')
+ logger.warn(
+ appendCopilotLogContext('subagent_end without matching subagent_start', {
+ messageId: context.messageId,
+ })
+ )
}
context.subAgentParentToolCallId =
context.subAgentParentStack.length > 0
diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts
index 68880b77e00..85778ed36cf 100644
--- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts
+++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts
@@ -3,6 +3,7 @@ import { credential, mcpServers, pendingCredentialDraft, user } from '@sim/db/sc
import { createLogger } from '@sim/logger'
import { and, eq, isNull, lt } from 'drizzle-orm'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import type {
ExecutionContext,
ToolCallResult,
@@ -321,12 +322,17 @@ async function executeManageCustomTool(
error: `Unsupported operation for manage_custom_tool: ${operation}`,
}
} catch (error) {
- logger.error('manage_custom_tool execution failed', {
- operation,
- workspaceId,
- userId: context.userId,
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.error(
+ appendCopilotLogContext('manage_custom_tool execution failed', {
+ messageId: context.messageId,
+ }),
+ {
+ operation,
+ workspaceId,
+ userId: context.userId,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to manage custom tool',
@@ -553,11 +559,16 @@ async function executeManageMcpTool(
return { success: false, error: `Unsupported operation for manage_mcp_tool: ${operation}` }
} catch (error) {
- logger.error('manage_mcp_tool execution failed', {
- operation,
- workspaceId,
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.error(
+ appendCopilotLogContext('manage_mcp_tool execution failed', {
+ messageId: context.messageId,
+ }),
+ {
+ operation,
+ workspaceId,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to manage MCP server',
@@ -716,11 +727,16 @@ async function executeManageSkill(
return { success: false, error: `Unsupported operation for manage_skill: ${operation}` }
} catch (error) {
- logger.error('manage_skill execution failed', {
- operation,
- workspaceId,
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.error(
+ appendCopilotLogContext('manage_skill execution failed', {
+ messageId: context.messageId,
+ }),
+ {
+ operation,
+ workspaceId,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to manage skill',
@@ -992,10 +1008,15 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
},
}
} catch (err) {
- logger.warn('Failed to generate OAuth link, falling back to generic URL', {
- providerName,
- error: err instanceof Error ? err.message : String(err),
- })
+ logger.warn(
+ appendCopilotLogContext('Failed to generate OAuth link, falling back to generic URL', {
+ messageId: c.messageId,
+ }),
+ {
+ providerName,
+ error: err instanceof Error ? err.message : String(err),
+ }
+ )
const workspaceUrl = c.workspaceId
? `${baseUrl}/workspace/${c.workspaceId}`
: `${baseUrl}/workspace`
@@ -1179,7 +1200,12 @@ export async function executeToolServerSide(
const toolConfig = getTool(resolvedToolName)
if (!toolConfig) {
- logger.warn('Tool not found in registry', { toolName, resolvedToolName })
+ logger.warn(
+ appendCopilotLogContext('Tool not found in registry', {
+ messageId: context.messageId,
+ }),
+ { toolName, resolvedToolName }
+ )
return {
success: false,
error: `Tool not found: ${toolName}`,
@@ -1241,6 +1267,7 @@ async function executeServerToolDirect(
workspaceId: context.workspaceId,
userPermission: context.userPermission,
chatId: context.chatId,
+ messageId: context.messageId,
abortSignal: context.abortSignal,
})
@@ -1266,10 +1293,15 @@ async function executeServerToolDirect(
return { success: true, output: result }
} catch (error) {
- logger.error('Server tool execution failed', {
- toolName,
- error: error instanceof Error ? error.message : String(error),
- })
+ logger.error(
+ appendCopilotLogContext('Server tool execution failed', {
+ messageId: context.messageId,
+ }),
+ {
+ toolName,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
return {
success: false,
error: error instanceof Error ? error.message : 'Server tool execution failed',
@@ -1320,7 +1352,8 @@ export async function markToolComplete(
toolName: string,
status: number,
message?: unknown,
- data?: unknown
+ data?: unknown,
+ messageId?: string
): Promise {
try {
const controller = new AbortController()
@@ -1344,7 +1377,11 @@ export async function markToolComplete(
})
if (!response.ok) {
- logger.warn('Mark-complete call failed', { toolCallId, toolName, status: response.status })
+ logger.warn(appendCopilotLogContext('Mark-complete call failed', { messageId }), {
+ toolCallId,
+ toolName,
+ status: response.status,
+ })
return false
}
@@ -1354,7 +1391,7 @@ export async function markToolComplete(
}
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === 'AbortError'
- logger.error('Mark-complete call failed', {
+ logger.error(appendCopilotLogContext('Mark-complete call failed', { messageId }), {
toolCallId,
toolName,
timedOut: isTimeout,
diff --git a/apps/sim/lib/copilot/orchestrator/types.ts b/apps/sim/lib/copilot/orchestrator/types.ts
index 97c3bd61367..791f6632373 100644
--- a/apps/sim/lib/copilot/orchestrator/types.ts
+++ b/apps/sim/lib/copilot/orchestrator/types.ts
@@ -195,6 +195,7 @@ export interface ExecutionContext {
workflowId: string
workspaceId?: string
chatId?: string
+ messageId?: string
executionId?: string
runId?: string
abortSignal?: AbortSignal
diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts
index db7a2d6cc25..1eca5a063e6 100644
--- a/apps/sim/lib/copilot/tools/server/base-tool.ts
+++ b/apps/sim/lib/copilot/tools/server/base-tool.ts
@@ -5,6 +5,7 @@ export interface ServerToolContext {
workspaceId?: string
userPermission?: string
chatId?: string
+ messageId?: string
abortSignal?: AbortSignal
}
diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts
index 8aaf0edbc9a..fbae8b4dcc0 100644
--- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts
+++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { z } from 'zod'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
assertServerToolNotAborted,
type BaseServerTool,
@@ -124,6 +125,9 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool<
params: DownloadToWorkspaceFileArgs,
context?: ServerToolContext
): Promise {
+ const withMessageId = (message: string) =>
+ appendCopilotLogContext(message, { messageId: context?.messageId })
+
if (!context?.userId) {
throw new Error('Authentication required')
}
@@ -174,7 +178,7 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool<
mimeType
)
- logger.info('Downloaded remote file to workspace', {
+ logger.info(withMessageId('Downloaded remote file to workspace'), {
sourceUrl: params.url,
fileId: uploaded.id,
fileName: uploaded.name,
@@ -191,7 +195,10 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool<
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error'
- logger.error('Failed to download file to workspace', { url: params.url, error: msg })
+ logger.error(withMessageId('Failed to download file to workspace'), {
+ url: params.url,
+ error: msg,
+ })
return { success: false, message: `Failed to download file: ${msg}` }
}
},
diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts
index e85fec40758..538ea4f52fa 100644
--- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts
+++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
assertServerToolNotAborted,
type BaseServerTool,
@@ -50,8 +51,11 @@ export const workspaceFileServerTool: BaseServerTool {
+ const withMessageId = (message: string) =>
+ appendCopilotLogContext(message, { messageId: context?.messageId })
+
if (!context?.userId) {
- logger.error('Unauthorized attempt to access workspace files')
+ logger.error(withMessageId('Unauthorized attempt to access workspace files'))
throw new Error('Authentication required')
}
@@ -90,7 +94,7 @@ export const workspaceFileServerTool: BaseServerTool {
+ const withMessageId = (message: string) =>
+ appendCopilotLogContext(message, { messageId: context?.messageId })
+
if (!context?.userId) {
throw new Error('Authentication required')
}
@@ -93,17 +97,17 @@ export const generateImageServerTool: BaseServerTool = {
name: 'get_job_logs',
async execute(rawArgs: GetJobLogsArgs, context?: ServerToolContext): Promise {
+ const withMessageId = (message: string) =>
+ appendCopilotLogContext(message, { messageId: context?.messageId })
+
const {
jobId,
executionId,
@@ -110,7 +114,12 @@ export const getJobLogsServerTool: BaseServerTool
const clampedLimit = Math.min(Math.max(1, limit), 5)
- logger.info('Fetching job logs', { jobId, executionId, limit: clampedLimit, includeDetails })
+ logger.info(withMessageId('Fetching job logs'), {
+ jobId,
+ executionId,
+ limit: clampedLimit,
+ includeDetails,
+ })
const conditions = [eq(jobExecutionLogs.scheduleId, jobId)]
if (executionId) {
@@ -164,7 +173,7 @@ export const getJobLogsServerTool: BaseServerTool
return entry
})
- logger.info('Job logs prepared', {
+ logger.info(withMessageId('Job logs prepared'), {
jobId,
count: entries.length,
resultSizeKB: Math.round(JSON.stringify(entries).length / 1024),
diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts
index a459a271957..2eae12f37ef 100644
--- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts
+++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts
@@ -3,6 +3,7 @@ import { knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { generateInternalToken } from '@/lib/auth/internal'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
assertServerToolNotAborted,
type BaseServerTool,
@@ -47,8 +48,15 @@ export const knowledgeBaseServerTool: BaseServerTool {
+ const withMessageId = (message: string) =>
+ appendCopilotLogContext(message, { messageId: context?.messageId })
+
if (!context?.userId) {
- logger.error('Unauthorized attempt to access knowledge base - no authenticated user context')
+ logger.error(
+ withMessageId(
+ 'Unauthorized attempt to access knowledge base - no authenticated user context'
+ )
+ )
throw new Error('Authentication required')
}
@@ -97,7 +105,7 @@ export const knowledgeBaseServerTool: BaseServerTool {
- logger.error('Background document processing failed', {
+ logger.error(withMessageId('Background document processing failed'), {
documentId: doc.id,
error: err instanceof Error ? err.message : String(err),
})
})
- logger.info('Workspace file added to knowledge base via copilot', {
+ logger.info(withMessageId('Workspace file added to knowledge base via copilot'), {
knowledgeBaseId: args.knowledgeBaseId,
documentId: doc.id,
fileName: fileRecord.name,
@@ -344,7 +352,7 @@ export const knowledgeBaseServerTool: BaseServerTool = {
name: 'user_table',
async execute(params: UserTableArgs, context?: ServerToolContext): Promise {
+ const withMessageId = (message: string) =>
+ appendCopilotLogContext(message, { messageId: context?.messageId })
+
if (!context?.userId) {
- logger.error('Unauthorized attempt to access user table - no authenticated user context')
+ logger.error(
+ withMessageId('Unauthorized attempt to access user table - no authenticated user context')
+ )
throw new Error('Authentication required')
}
@@ -723,7 +729,7 @@ export const userTableServerTool: BaseServerTool
const coerced = coerceRows(rows, columns, columnMap)
const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context)
- logger.info('Table created from file', {
+ logger.info(withMessageId('Table created from file'), {
tableId: table.id,
fileName: file.name,
columns: columns.length,
@@ -799,7 +805,7 @@ export const userTableServerTool: BaseServerTool
const coerced = coerceRows(rows, matchedColumns, columnMap)
const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context)
- logger.info('Rows imported from file', {
+ logger.info(withMessageId('Rows imported from file'), {
tableId: table.id,
fileName: file.name,
matchedColumns: mappedHeaders.length,
@@ -997,7 +1003,11 @@ export const userTableServerTool: BaseServerTool
? error.cause.message
: String(error.cause)
: undefined
- logger.error('Table operation failed', { operation, error: errorMessage, cause })
+ logger.error(withMessageId('Table operation failed'), {
+ operation,
+ error: errorMessage,
+ cause,
+ })
const displayMessage = cause ? `${errorMessage} (${cause})` : errorMessage
return { success: false, message: `Operation failed: ${displayMessage}` }
}
diff --git a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts
index b1eaf61a16d..abceb9d9d50 100644
--- a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts
+++ b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
import {
assertServerToolNotAborted,
type BaseServerTool,
@@ -62,8 +63,10 @@ function validateGeneratedWorkspaceFileName(fileName: string): string | null {
async function collectSandboxFiles(
workspaceId: string,
inputFiles?: string[],
- inputTables?: string[]
+ inputTables?: string[],
+ messageId?: string
): Promise {
+ const withMessageId = (message: string) => appendCopilotLogContext(message, { messageId })
const sandboxFiles: SandboxFile[] = []
let totalSize = 0
@@ -72,12 +75,12 @@ async function collectSandboxFiles(
for (const fileRef of inputFiles) {
const record = findWorkspaceFileRecord(allFiles, fileRef)
if (!record) {
- logger.warn('Sandbox input file not found', { fileRef })
+ logger.warn(withMessageId('Sandbox input file not found'), { fileRef })
continue
}
const ext = record.name.split('.').pop()?.toLowerCase() ?? ''
if (!TEXT_EXTENSIONS.has(ext)) {
- logger.warn('Skipping non-text sandbox input file', {
+ logger.warn(withMessageId('Skipping non-text sandbox input file'), {
fileId: record.id,
fileName: record.name,
ext,
@@ -85,7 +88,7 @@ async function collectSandboxFiles(
continue
}
if (record.size > MAX_FILE_SIZE) {
- logger.warn('Sandbox input file exceeds size limit', {
+ logger.warn(withMessageId('Sandbox input file exceeds size limit'), {
fileId: record.id,
fileName: record.name,
size: record.size,
@@ -93,7 +96,9 @@ async function collectSandboxFiles(
continue
}
if (totalSize + record.size > MAX_TOTAL_SIZE) {
- logger.warn('Sandbox input total size limit reached, skipping remaining files')
+ logger.warn(
+ withMessageId('Sandbox input total size limit reached, skipping remaining files')
+ )
break
}
const buffer = await downloadWorkspaceFile(record)
@@ -114,7 +119,7 @@ async function collectSandboxFiles(
for (const tableId of inputTables) {
const table = await getTableById(tableId)
if (!table) {
- logger.warn('Sandbox input table not found', { tableId })
+ logger.warn(withMessageId('Sandbox input table not found'), { tableId })
continue
}
const { rows } = await queryRows(tableId, workspaceId, { limit: 10000 }, 'sandbox-input')
@@ -129,7 +134,9 @@ async function collectSandboxFiles(
}
const csvContent = csvLines.join('\n')
if (totalSize + csvContent.length > MAX_TOTAL_SIZE) {
- logger.warn('Sandbox input total size limit reached, skipping remaining tables')
+ logger.warn(
+ withMessageId('Sandbox input total size limit reached, skipping remaining tables')
+ )
break
}
totalSize += csvContent.length
@@ -150,6 +157,9 @@ export const generateVisualizationServerTool: BaseServerTool<
params: VisualizationArgs,
context?: ServerToolContext
): Promise {
+ const withMessageId = (message: string) =>
+ appendCopilotLogContext(message, { messageId: context?.messageId })
+
if (!context?.userId) {
throw new Error('Authentication required')
}
@@ -167,7 +177,8 @@ export const generateVisualizationServerTool: BaseServerTool<
const sandboxFiles = await collectSandboxFiles(
workspaceId,
params.inputFiles,
- params.inputTables
+ params.inputTables,
+ context.messageId
)
const wrappedCode = [
@@ -232,7 +243,7 @@ export const generateVisualizationServerTool: BaseServerTool<
imageBuffer,
'image/png'
)
- logger.info('Chart image overwritten', {
+ logger.info(withMessageId('Chart image overwritten'), {
fileId: updated.id,
fileName: updated.name,
size: imageBuffer.length,
@@ -256,7 +267,7 @@ export const generateVisualizationServerTool: BaseServerTool<
'image/png'
)
- logger.info('Chart image saved', {
+ logger.info(withMessageId('Chart image saved'), {
fileId: uploaded.id,
fileName: uploaded.name,
size: imageBuffer.length,
@@ -271,7 +282,7 @@ export const generateVisualizationServerTool: BaseServerTool<
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error'
- logger.error('Visualization generation failed', { error: msg })
+ logger.error(withMessageId('Visualization generation failed'), { error: msg })
return { success: false, message: `Failed to generate visualization: ${msg}` }
}
},
From 438defceb0b5a4a7cf612e3d5f4cd5110ed0260a Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 25 Mar 2026 14:48:28 -0700
Subject: [PATCH 09/20] fix(mothership): key resumes by orchestration id
(#3771)
---
apps/sim/lib/copilot/orchestrator/index.ts | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts
index 8861b23e47c..39d4a112037 100644
--- a/apps/sim/lib/copilot/orchestrator/index.ts
+++ b/apps/sim/lib/copilot/orchestrator/index.ts
@@ -126,6 +126,7 @@ export async function orchestrateCopilotStream(
runId,
messageId,
})
+ const continuationWorkerId = `sim-resume:${crypto.randomUUID()}`
const withLogContext = (message: string) =>
appendCopilotLogContext(message, {
requestId: context.requestId,
@@ -253,7 +254,7 @@ export async function orchestrateCopilotStream(
for (;;) {
claimedToolCallIds = []
claimedByWorkerId = null
- const resumeWorkerId = continuation.runId || context.runId || context.messageId
+ const resumeWorkerId = continuationWorkerId
const readyTools: ReadyContinuationTool[] = []
const localPendingPromises: Promise[] = []
const missingToolCallIds: string[] = []
@@ -272,6 +273,7 @@ export async function orchestrateCopilotStream(
{
toolCallId,
runId: continuation.runId,
+ workerId: resumeWorkerId,
}
)
continue
@@ -287,6 +289,7 @@ export async function orchestrateCopilotStream(
{
toolCallId,
runId: continuation.runId,
+ workerId: resumeWorkerId,
claimedBy: durableRow.claimedBy,
}
)
@@ -355,6 +358,7 @@ export async function orchestrateCopilotStream(
{
checkpointId: continuation.checkpointId,
runId: continuation.runId,
+ workerId: resumeWorkerId,
retry: resumeRetries,
missingToolCallIds,
}
@@ -385,6 +389,7 @@ export async function orchestrateCopilotStream(
{
checkpointId: continuation.checkpointId,
runId: continuation.runId,
+ workerId: resumeWorkerId,
retry: resumeRetries,
}
)
@@ -424,6 +429,7 @@ export async function orchestrateCopilotStream(
{
checkpointId: continuation.checkpointId,
runId: continuation.runId,
+ workerId: resumeWorkerId,
newlyClaimedToolCallIds,
claimFailures,
}
@@ -439,6 +445,7 @@ export async function orchestrateCopilotStream(
logger.info(withLogContext('Retrying async resume after claim contention'), {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
+ workerId: resumeWorkerId,
retry: resumeRetries,
claimFailures,
})
@@ -469,6 +476,7 @@ export async function orchestrateCopilotStream(
logger.info(withLogContext('Resuming async tool continuation'), {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
+ workerId: resumeWorkerId,
toolCallIds: readyTools.map((tool) => tool.toolCallId),
})
From be6b00d95f91fd64052890c2bc102fb025f433b9 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Wed, 25 Mar 2026 15:30:36 -0700
Subject: [PATCH 10/20] feat(ui): add request a demo modal (#3766)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(ui): add request a demo modal
* Remove dead code
* Remove footer modal
* Address greptile comments
* Sanatize CRLF characters from emails
* extract shared email header safety regex
Co-authored-by: Theodore Li
* Use pricing CTA action for demo modal
Co-authored-by: Theodore Li
* fix demo request import ordering
Co-authored-by: Theodore Li
* merge staging and fix hubspot list formatting
Co-authored-by: Theodore Li
* fix(generate-docs): fix tool description extraction and simplify script
- Fix endsWith over-matching: basename === 'index.ts'/'types.ts' instead
of endsWith(), which was silently skipping valid tool files like
list_leave_types.ts, delete_index.ts, etc.
- Add extractSwitchCaseToolMapping() to resolve op ID → tool ID mismatches
where block switch statements map differently (e.g. HubSpot get_carts →
hubspot_list_carts)
- Fix double fs.readFileSync in writeIntegrationsJson — reuse existing
fileContent variable instead of re-reading the file
- Remove 5 dead functions superseded by *FromContent variants
- Simplify extractToolsAccessFromContent to use matchAll
- fix(upstash): replace template literal tool ID with explicit switch cases
* fix(generate-docs): restore extractIconName by aliasing to extractIconNameFromContent
* restore
* fix(demo-modal): reset form on open to prevent stale success state on reopen
* undo hardcoded ff
* fix(upstash): throw on unknown operation instead of silently falling back to get
---------
Co-authored-by: Theodore Li
Co-authored-by: Cursor Agent
Co-authored-by: Theodore Li
Co-authored-by: waleed
---
apps/docs/content/docs/en/tools/hubspot.mdx | 891 +++++++++++++++++-
apps/docs/content/docs/en/tools/rippling.mdx | 18 +-
.../(home)/components/demo-request/consts.ts | 84 ++
.../demo-request/demo-request-modal.tsx | 298 ++++++
.../components/enterprise/enterprise.tsx | 53 +-
.../features/components/features-preview.tsx | 7 +-
apps/sim/app/(home)/components/hero/hero.tsx | 19 +-
.../landing-preview-home.tsx | 5 +-
.../navbar/components/blog-dropdown.tsx | 14 +-
.../navbar/components/docs-dropdown.tsx | 10 +-
.../app/(home)/components/pricing/pricing.tsx | 29 +-
apps/sim/app/(home)/landing.tsx | 8 +-
.../(landing)/integrations/[slug]/page.tsx | 101 +-
.../integrations/data/integrations.json | 360 ++++---
.../sim/app/(landing)/integrations/layout.tsx | 10 +-
apps/sim/app/(landing)/integrations/page.tsx | 27 +-
apps/sim/app/api/demo-requests/route.ts | 106 +++
.../app/api/help/integration-request/route.ts | 12 +-
apps/sim/app/api/help/route.ts | 11 +-
apps/sim/app/layout.tsx | 17 +-
apps/sim/app/sitemap.ts | 12 +-
apps/sim/blocks/blocks/upstash.ts | 37 +-
.../emcn/components/form-field/form-field.tsx | 28 +
apps/sim/components/emcn/components/index.ts | 1 +
apps/sim/lib/messaging/email/mailer.test.ts | 80 +-
apps/sim/lib/messaging/email/mailer.ts | 63 +-
apps/sim/lib/messaging/email/utils.test.ts | 27 +-
apps/sim/lib/messaging/email/utils.ts | 8 +
scripts/generate-docs.ts | 291 ++----
29 files changed, 2073 insertions(+), 554 deletions(-)
create mode 100644 apps/sim/app/(home)/components/demo-request/consts.ts
create mode 100644 apps/sim/app/(home)/components/demo-request/demo-request-modal.tsx
create mode 100644 apps/sim/app/api/demo-requests/route.ts
create mode 100644 apps/sim/components/emcn/components/form-field/form-field.tsx
diff --git a/apps/docs/content/docs/en/tools/hubspot.mdx b/apps/docs/content/docs/en/tools/hubspot.mdx
index 99cba9a5343..882f2766f10 100644
--- a/apps/docs/content/docs/en/tools/hubspot.mdx
+++ b/apps/docs/content/docs/en/tools/hubspot.mdx
@@ -41,6 +41,7 @@ Retrieve all users from HubSpot account
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `limit` | string | No | Number of results to return \(default: 100, max: 100\) |
+| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
#### Output
@@ -53,6 +54,9 @@ Retrieve all users from HubSpot account
| ↳ `primaryTeamId` | string | Primary team ID |
| ↳ `secondaryTeamIds` | array | Secondary team IDs |
| ↳ `superAdmin` | boolean | Whether user is a super admin |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
| `totalItems` | number | Total number of users returned |
| `success` | boolean | Operation success status |
@@ -230,7 +234,7 @@ Search for contacts in HubSpot using filters, sorting, and queries
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "CONTAINS"\), and "value" |
+| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "CONTAINS_TOKEN", "GT"\), and "value" |
| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) |
| `query` | string | No | Search query string to match against contact name, email, and other text fields |
| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["email", "firstname", "lastname", "phone"\]\) |
@@ -449,7 +453,7 @@ Search for companies in HubSpot using filters, sorting, and queries
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "CONTAINS"\), and "value" |
+| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "CONTAINS_TOKEN", "GT"\), and "value" |
| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) |
| `query` | string | No | Search query string to match against company name, domain, and other text fields |
| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["name", "domain", "industry"\]\) |
@@ -529,4 +533,887 @@ Retrieve all deals from HubSpot account with pagination support
| ↳ `hasMore` | boolean | Whether more records are available |
| `success` | boolean | Operation success status |
+### `hubspot_get_deal`
+
+Retrieve a single deal by ID from HubSpot
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `dealId` | string | Yes | The HubSpot deal ID to retrieve |
+| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "dealname,amount,dealstage"\) |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deal` | object | HubSpot deal record |
+| ↳ `dealname` | string | Deal name |
+| ↳ `amount` | string | Deal amount |
+| ↳ `dealstage` | string | Current deal stage |
+| ↳ `pipeline` | string | Pipeline the deal is in |
+| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
+| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
+| ↳ `description` | string | Deal description |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| ↳ `num_associated_contacts` | string | Number of associated contacts |
+| `dealId` | string | The retrieved deal ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_create_deal`
+
+Create a new deal in HubSpot. Requires at least a dealname property
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `properties` | object | Yes | Deal properties as JSON object. Must include dealname \(e.g., \{"dealname": "New Deal", "amount": "5000", "dealstage": "appointmentscheduled"\}\) |
+| `associations` | array | No | Array of associations to create with the deal as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deal` | object | HubSpot deal record |
+| ↳ `dealname` | string | Deal name |
+| ↳ `amount` | string | Deal amount |
+| ↳ `dealstage` | string | Current deal stage |
+| ↳ `pipeline` | string | Pipeline the deal is in |
+| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
+| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
+| ↳ `description` | string | Deal description |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| ↳ `num_associated_contacts` | string | Number of associated contacts |
+| `dealId` | string | The created deal ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_update_deal`
+
+Update an existing deal in HubSpot by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `dealId` | string | Yes | The HubSpot deal ID to update |
+| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
+| `properties` | object | Yes | Deal properties to update as JSON object \(e.g., \{"amount": "10000", "dealstage": "closedwon"\}\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deal` | object | HubSpot deal record |
+| ↳ `dealname` | string | Deal name |
+| ↳ `amount` | string | Deal amount |
+| ↳ `dealstage` | string | Current deal stage |
+| ↳ `pipeline` | string | Pipeline the deal is in |
+| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
+| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
+| ↳ `description` | string | Deal description |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| ↳ `num_associated_contacts` | string | Number of associated contacts |
+| `dealId` | string | The updated deal ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_search_deals`
+
+Search for deals in HubSpot using filters, sorting, and queries
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "NEQ", "CONTAINS_TOKEN", "NOT_CONTAINS_TOKEN"\), and "value" |
+| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) |
+| `query` | string | No | Search query string to match against deal name and other text fields |
+| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["dealname", "amount", "dealstage"\]\) |
+| `limit` | number | No | Maximum number of results to return \(max 200\) |
+| `after` | string | No | Pagination cursor for next page \(from previous response\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deals` | array | Array of HubSpot deal records |
+| ↳ `dealname` | string | Deal name |
+| ↳ `amount` | string | Deal amount |
+| ↳ `dealstage` | string | Current deal stage |
+| ↳ `pipeline` | string | Pipeline the deal is in |
+| ↳ `closedate` | string | Expected close date \(ISO 8601\) |
+| ↳ `dealtype` | string | Deal type \(New Business, Existing Business, etc.\) |
+| ↳ `description` | string | Deal description |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Deal creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| ↳ `num_associated_contacts` | string | Number of associated contacts |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| `total` | number | Total number of matching deals |
+| `success` | boolean | Operation success status |
+
+### `hubspot_list_tickets`
+
+Retrieve all tickets from HubSpot account with pagination support
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
+| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "subject,content,hs_ticket_priority"\) |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `tickets` | array | Array of HubSpot ticket records |
+| ↳ `subject` | string | Ticket subject/name |
+| ↳ `content` | string | Ticket content/description |
+| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
+| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
+| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
+| ↳ `hs_ticket_category` | string | Ticket category |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| `success` | boolean | Operation success status |
+
+### `hubspot_get_ticket`
+
+Retrieve a single ticket by ID from HubSpot
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `ticketId` | string | Yes | The HubSpot ticket ID to retrieve |
+| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "subject,content,hs_ticket_priority"\) |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `ticket` | object | HubSpot ticket record |
+| ↳ `subject` | string | Ticket subject/name |
+| ↳ `content` | string | Ticket content/description |
+| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
+| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
+| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
+| ↳ `hs_ticket_category` | string | Ticket category |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `ticketId` | string | The retrieved ticket ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_create_ticket`
+
+Create a new ticket in HubSpot. Requires subject and hs_pipeline_stage properties
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `properties` | object | Yes | Ticket properties as JSON object. Must include subject and hs_pipeline_stage \(e.g., \{"subject": "Support request", "hs_pipeline_stage": "1", "hs_ticket_priority": "HIGH"\}\) |
+| `associations` | array | No | Array of associations to create with the ticket as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `ticket` | object | HubSpot ticket record |
+| ↳ `subject` | string | Ticket subject/name |
+| ↳ `content` | string | Ticket content/description |
+| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
+| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
+| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
+| ↳ `hs_ticket_category` | string | Ticket category |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `ticketId` | string | The created ticket ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_update_ticket`
+
+Update an existing ticket in HubSpot by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `ticketId` | string | Yes | The HubSpot ticket ID to update |
+| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
+| `properties` | object | Yes | Ticket properties to update as JSON object \(e.g., \{"subject": "Updated subject", "hs_ticket_priority": "HIGH"\}\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `ticket` | object | HubSpot ticket record |
+| ↳ `subject` | string | Ticket subject/name |
+| ↳ `content` | string | Ticket content/description |
+| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
+| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
+| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
+| ↳ `hs_ticket_category` | string | Ticket category |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `ticketId` | string | The updated ticket ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_search_tickets`
+
+Search for tickets in HubSpot using filters, sorting, and queries
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `filterGroups` | array | No | Array of filter groups as JSON. Each group contains "filters" array with objects having "propertyName", "operator" \(e.g., "EQ", "NEQ", "CONTAINS_TOKEN", "NOT_CONTAINS_TOKEN"\), and "value" |
+| `sorts` | array | No | Array of sort objects as JSON with "propertyName" and "direction" \("ASCENDING" or "DESCENDING"\) |
+| `query` | string | No | Search query string to match against ticket subject and other text fields |
+| `properties` | array | No | Array of HubSpot property names to return \(e.g., \["subject", "content", "hs_ticket_priority"\]\) |
+| `limit` | number | No | Maximum number of results to return \(max 200\) |
+| `after` | string | No | Pagination cursor for next page \(from previous response\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `tickets` | array | Array of HubSpot ticket records |
+| ↳ `subject` | string | Ticket subject/name |
+| ↳ `content` | string | Ticket content/description |
+| ↳ `hs_pipeline` | string | Pipeline the ticket is in |
+| ↳ `hs_pipeline_stage` | string | Current pipeline stage |
+| ↳ `hs_ticket_priority` | string | Ticket priority \(LOW, MEDIUM, HIGH\) |
+| ↳ `hs_ticket_category` | string | Ticket category |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Ticket creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| `total` | number | Total number of matching tickets |
+| `success` | boolean | Operation success status |
+
+### `hubspot_list_line_items`
+
+Retrieve all line items from HubSpot account with pagination support
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
+| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "name,quantity,price,amount"\) |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "deals,quotes"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `lineItems` | array | Array of HubSpot line item records |
+| ↳ `name` | string | Line item name |
+| ↳ `description` | string | Full description of the product |
+| ↳ `hs_sku` | string | Unique product identifier \(SKU\) |
+| ↳ `quantity` | string | Number of units included |
+| ↳ `price` | string | Unit price |
+| ↳ `amount` | string | Total cost \(quantity * unit price\) |
+| ↳ `hs_line_item_currency_code` | string | Currency code |
+| ↳ `recurringbillingfrequency` | string | Recurring billing frequency |
+| ↳ `hs_recurring_billing_start_date` | string | Recurring billing start date |
+| ↳ `hs_recurring_billing_end_date` | string | Recurring billing end date |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| `success` | boolean | Operation success status |
+
+### `hubspot_get_line_item`
+
+Retrieve a single line item by ID from HubSpot
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `lineItemId` | string | Yes | The HubSpot line item ID to retrieve |
+| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "name,quantity,price,amount"\) |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "deals,quotes"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `lineItem` | object | HubSpot line item record |
+| ↳ `name` | string | Line item name |
+| ↳ `description` | string | Full description of the product |
+| ↳ `hs_sku` | string | Unique product identifier \(SKU\) |
+| ↳ `quantity` | string | Number of units included |
+| ↳ `price` | string | Unit price |
+| ↳ `amount` | string | Total cost \(quantity * unit price\) |
+| ↳ `hs_line_item_currency_code` | string | Currency code |
+| ↳ `recurringbillingfrequency` | string | Recurring billing frequency |
+| ↳ `hs_recurring_billing_start_date` | string | Recurring billing start date |
+| ↳ `hs_recurring_billing_end_date` | string | Recurring billing end date |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `lineItemId` | string | The retrieved line item ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_create_line_item`
+
+Create a new line item in HubSpot. Requires at least a name property
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `properties` | object | Yes | Line item properties as JSON object \(e.g., \{"name": "Product A", "quantity": "2", "price": "50.00", "hs_sku": "SKU-001"\}\) |
+| `associations` | array | No | Array of associations to create with the line item as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `lineItem` | object | HubSpot line item record |
+| ↳ `name` | string | Line item name |
+| ↳ `description` | string | Full description of the product |
+| ↳ `hs_sku` | string | Unique product identifier \(SKU\) |
+| ↳ `quantity` | string | Number of units included |
+| ↳ `price` | string | Unit price |
+| ↳ `amount` | string | Total cost \(quantity * unit price\) |
+| ↳ `hs_line_item_currency_code` | string | Currency code |
+| ↳ `recurringbillingfrequency` | string | Recurring billing frequency |
+| ↳ `hs_recurring_billing_start_date` | string | Recurring billing start date |
+| ↳ `hs_recurring_billing_end_date` | string | Recurring billing end date |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `lineItemId` | string | The created line item ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_update_line_item`
+
+Update an existing line item in HubSpot by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `lineItemId` | string | Yes | The HubSpot line item ID to update |
+| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
+| `properties` | object | Yes | Line item properties to update as JSON object \(e.g., \{"quantity": "5", "price": "25.00"\}\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `lineItem` | object | HubSpot line item record |
+| ↳ `name` | string | Line item name |
+| ↳ `description` | string | Full description of the product |
+| ↳ `hs_sku` | string | Unique product identifier \(SKU\) |
+| ↳ `quantity` | string | Number of units included |
+| ↳ `price` | string | Unit price |
+| ↳ `amount` | string | Total cost \(quantity * unit price\) |
+| ↳ `hs_line_item_currency_code` | string | Currency code |
+| ↳ `recurringbillingfrequency` | string | Recurring billing frequency |
+| ↳ `hs_recurring_billing_start_date` | string | Recurring billing start date |
+| ↳ `hs_recurring_billing_end_date` | string | Recurring billing end date |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `lineItemId` | string | The updated line item ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_list_quotes`
+
+Retrieve all quotes from HubSpot account with pagination support
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
+| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "hs_title,hs_expiration_date,hs_status"\) |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "deals,line_items"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `quotes` | array | Array of HubSpot quote records |
+| ↳ `hs_title` | string | Quote name/title |
+| ↳ `hs_expiration_date` | string | Expiration date |
+| ↳ `hs_status` | string | Quote status |
+| ↳ `hs_esign_enabled` | string | Whether e-signatures are enabled |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| `success` | boolean | Operation success status |
+
+### `hubspot_get_quote`
+
+Retrieve a single quote by ID from HubSpot
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `quoteId` | string | Yes | The HubSpot quote ID to retrieve |
+| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "hs_title,hs_expiration_date,hs_status"\) |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "deals,line_items"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `quote` | object | HubSpot quote record |
+| ↳ `hs_title` | string | Quote name/title |
+| ↳ `hs_expiration_date` | string | Expiration date |
+| ↳ `hs_status` | string | Quote status |
+| ↳ `hs_esign_enabled` | string | Whether e-signatures are enabled |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `quoteId` | string | The retrieved quote ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_list_appointments`
+
+Retrieve all appointments from HubSpot account with pagination support
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
+| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "hs_meeting_title,hs_meeting_start_time"\) |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `appointments` | array | Array of HubSpot appointment records |
+| ↳ `hs_appointment_type` | string | Appointment type |
+| ↳ `hs_meeting_title` | string | Meeting title |
+| ↳ `hs_meeting_start_time` | string | Start time \(ISO 8601\) |
+| ↳ `hs_meeting_end_time` | string | End time \(ISO 8601\) |
+| ↳ `hs_meeting_location` | string | Meeting location |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `hs_createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| `success` | boolean | Operation success status |
+
+### `hubspot_get_appointment`
+
+Retrieve a single appointment by ID from HubSpot
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `appointmentId` | string | Yes | The HubSpot appointment ID to retrieve |
+| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return \(e.g., "hs_meeting_title,hs_meeting_start_time"\) |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for \(e.g., "contacts,companies"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `appointment` | object | HubSpot appointment record |
+| ↳ `hs_appointment_type` | string | Appointment type |
+| ↳ `hs_meeting_title` | string | Meeting title |
+| ↳ `hs_meeting_start_time` | string | Start time \(ISO 8601\) |
+| ↳ `hs_meeting_end_time` | string | End time \(ISO 8601\) |
+| ↳ `hs_meeting_location` | string | Meeting location |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `hs_createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `appointmentId` | string | The retrieved appointment ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_create_appointment`
+
+Create a new appointment in HubSpot
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `properties` | object | Yes | Appointment properties as JSON object \(e.g., \{"hs_meeting_title": "Discovery Call", "hs_meeting_start_time": "2024-01-15T10:00:00Z", "hs_meeting_end_time": "2024-01-15T11:00:00Z"\}\) |
+| `associations` | array | No | Array of associations to create with the appointment as JSON. Each object should have "to.id" and "types" array with "associationCategory" and "associationTypeId" |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `appointment` | object | HubSpot appointment record |
+| ↳ `hs_appointment_type` | string | Appointment type |
+| ↳ `hs_meeting_title` | string | Meeting title |
+| ↳ `hs_meeting_start_time` | string | Start time \(ISO 8601\) |
+| ↳ `hs_meeting_end_time` | string | End time \(ISO 8601\) |
+| ↳ `hs_meeting_location` | string | Meeting location |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `hs_createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `appointmentId` | string | The created appointment ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_update_appointment`
+
+Update an existing appointment in HubSpot by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `appointmentId` | string | Yes | The HubSpot appointment ID to update |
+| `idProperty` | string | No | Property to use as unique identifier. If not specified, uses record ID |
+| `properties` | object | Yes | Appointment properties to update as JSON object \(e.g., \{"hs_meeting_title": "Updated Call", "hs_meeting_location": "Zoom"\}\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `appointment` | object | HubSpot appointment record |
+| ↳ `hs_appointment_type` | string | Appointment type |
+| ↳ `hs_meeting_title` | string | Meeting title |
+| ↳ `hs_meeting_start_time` | string | Start time \(ISO 8601\) |
+| ↳ `hs_meeting_end_time` | string | End time \(ISO 8601\) |
+| ↳ `hs_meeting_location` | string | Meeting location |
+| ↳ `hubspot_owner_id` | string | HubSpot owner ID |
+| ↳ `hs_object_id` | string | HubSpot object ID \(same as record ID\) |
+| ↳ `hs_createdate` | string | Creation date \(ISO 8601\) |
+| ↳ `hs_lastmodifieddate` | string | Last modified date \(ISO 8601\) |
+| `appointmentId` | string | The updated appointment ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_list_carts`
+
+Retrieve all carts from HubSpot account with pagination support
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
+| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `carts` | array | Array of HubSpot CRM records |
+| ↳ `id` | string | Unique record ID \(hs_object_id\) |
+| ↳ `createdAt` | string | Record creation timestamp \(ISO 8601\) |
+| ↳ `updatedAt` | string | Record last updated timestamp \(ISO 8601\) |
+| ↳ `archived` | boolean | Whether the record is archived |
+| ↳ `properties` | object | Record properties |
+| ↳ `associations` | object | Associated records |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| `success` | boolean | Operation success status |
+
+### `hubspot_get_cart`
+
+Retrieve a single cart by ID from HubSpot
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `cartId` | string | Yes | The HubSpot cart ID to retrieve |
+| `properties` | string | No | Comma-separated list of HubSpot property names to return |
+| `associations` | string | No | Comma-separated list of object types to retrieve associated IDs for |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `cart` | object | HubSpot CRM record |
+| ↳ `id` | string | Unique record ID \(hs_object_id\) |
+| ↳ `createdAt` | string | Record creation timestamp \(ISO 8601\) |
+| ↳ `updatedAt` | string | Record last updated timestamp \(ISO 8601\) |
+| ↳ `archived` | boolean | Whether the record is archived |
+| ↳ `properties` | object | Record properties |
+| ↳ `associations` | object | Associated records |
+| `cartId` | string | The retrieved cart ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_list_owners`
+
+Retrieve all owners from HubSpot account with pagination support
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `limit` | string | No | Maximum number of results per page \(max 100, default 100\) |
+| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
+| `email` | string | No | Filter owners by email address |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `owners` | array | Array of HubSpot owner objects |
+| ↳ `id` | string | Owner ID |
+| ↳ `email` | string | Owner email address |
+| ↳ `firstName` | string | Owner first name |
+| ↳ `lastName` | string | Owner last name |
+| ↳ `userId` | number | Associated user ID |
+| ↳ `teams` | array | Teams the owner belongs to |
+| ↳ `id` | string | Team ID |
+| ↳ `name` | string | Team name |
+| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
+| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
+| ↳ `archived` | boolean | Whether the owner is archived |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| `success` | boolean | Operation success status |
+
+### `hubspot_list_marketing_events`
+
+Retrieve all marketing events from HubSpot account with pagination support
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `limit` | string | No | Maximum number of results per page \(max 100, default 10\) |
+| `after` | string | No | Pagination cursor for next page of results \(from previous response\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `events` | array | Array of HubSpot marketing event objects |
+| ↳ `objectId` | string | Unique event ID \(HubSpot internal\) |
+| ↳ `eventName` | string | Event name |
+| ↳ `eventType` | string | Event type |
+| ↳ `eventStatus` | string | Event status |
+| ↳ `eventDescription` | string | Event description |
+| ↳ `eventUrl` | string | Event URL |
+| ↳ `eventOrganizer` | string | Event organizer |
+| ↳ `startDateTime` | string | Start date/time \(ISO 8601\) |
+| ↳ `endDateTime` | string | End date/time \(ISO 8601\) |
+| ↳ `eventCancelled` | boolean | Whether event is cancelled |
+| ↳ `eventCompleted` | boolean | Whether event is completed |
+| ↳ `registrants` | number | Number of registrants |
+| ↳ `attendees` | number | Number of attendees |
+| ↳ `cancellations` | number | Number of cancellations |
+| ↳ `noShows` | number | Number of no-shows |
+| ↳ `externalEventId` | string | External event ID |
+| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
+| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| `success` | boolean | Operation success status |
+
+### `hubspot_get_marketing_event`
+
+Retrieve a single marketing event by ID from HubSpot
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `eventId` | string | Yes | The HubSpot marketing event objectId to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `event` | object | HubSpot marketing event |
+| ↳ `objectId` | string | Unique event ID \(HubSpot internal\) |
+| ↳ `eventName` | string | Event name |
+| ↳ `eventType` | string | Event type |
+| ↳ `eventStatus` | string | Event status |
+| ↳ `eventDescription` | string | Event description |
+| ↳ `eventUrl` | string | Event URL |
+| ↳ `eventOrganizer` | string | Event organizer |
+| ↳ `startDateTime` | string | Start date/time \(ISO 8601\) |
+| ↳ `endDateTime` | string | End date/time \(ISO 8601\) |
+| ↳ `eventCancelled` | boolean | Whether event is cancelled |
+| ↳ `eventCompleted` | boolean | Whether event is completed |
+| ↳ `registrants` | number | Number of registrants |
+| ↳ `attendees` | number | Number of attendees |
+| ↳ `cancellations` | number | Number of cancellations |
+| ↳ `noShows` | number | Number of no-shows |
+| ↳ `externalEventId` | string | External event ID |
+| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
+| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
+| `eventId` | string | The retrieved marketing event ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_list_lists`
+
+Search and retrieve lists from HubSpot account
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `query` | string | No | Search query to filter lists by name. Leave empty to return all lists. |
+| `count` | string | No | Maximum number of results to return \(default 20, max 500\) |
+| `offset` | string | No | Pagination offset for next page of results \(use the offset value from previous response\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `lists` | array | Array of HubSpot list objects |
+| ↳ `listId` | string | List ID |
+| ↳ `name` | string | List name |
+| ↳ `objectTypeId` | string | Object type ID \(e.g., 0-1 for contacts\) |
+| ↳ `processingType` | string | Processing type \(MANUAL, DYNAMIC, SNAPSHOT\) |
+| ↳ `processingStatus` | string | Processing status \(COMPLETE, PROCESSING\) |
+| ↳ `listVersion` | number | List version number |
+| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
+| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
+| `paging` | object | Pagination information for fetching more results |
+| ↳ `after` | string | Cursor for next page of results |
+| ↳ `link` | string | Link to next page |
+| `metadata` | object | Response metadata |
+| ↳ `totalReturned` | number | Number of records returned in this response |
+| ↳ `hasMore` | boolean | Whether more records are available |
+| ↳ `total` | number | Total number of lists matching the query |
+| `success` | boolean | Operation success status |
+
+### `hubspot_get_list`
+
+Retrieve a single list by ID from HubSpot
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `listId` | string | Yes | The HubSpot list ID to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `list` | object | HubSpot list |
+| ↳ `listId` | string | List ID |
+| ↳ `name` | string | List name |
+| ↳ `objectTypeId` | string | Object type ID \(e.g., 0-1 for contacts\) |
+| ↳ `processingType` | string | Processing type \(MANUAL, DYNAMIC, SNAPSHOT\) |
+| ↳ `processingStatus` | string | Processing status \(COMPLETE, PROCESSING\) |
+| ↳ `listVersion` | number | List version number |
+| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
+| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
+| `listId` | string | The retrieved list ID |
+| `success` | boolean | Operation success status |
+
+### `hubspot_create_list`
+
+Create a new list in HubSpot. Specify the object type and processing type (MANUAL or DYNAMIC)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `name` | string | Yes | Name of the list |
+| `objectTypeId` | string | Yes | Object type ID \(e.g., "0-1" for contacts, "0-2" for companies\) |
+| `processingType` | string | Yes | Processing type: "MANUAL" for static lists or "DYNAMIC" for active lists |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `list` | object | HubSpot list |
+| ↳ `listId` | string | List ID |
+| ↳ `name` | string | List name |
+| ↳ `objectTypeId` | string | Object type ID \(e.g., 0-1 for contacts\) |
+| ↳ `processingType` | string | Processing type \(MANUAL, DYNAMIC, SNAPSHOT\) |
+| ↳ `processingStatus` | string | Processing status \(COMPLETE, PROCESSING\) |
+| ↳ `listVersion` | number | List version number |
+| ↳ `createdAt` | string | Creation date \(ISO 8601\) |
+| ↳ `updatedAt` | string | Last updated date \(ISO 8601\) |
+| `listId` | string | The created list ID |
+| `success` | boolean | Operation success status |
+
diff --git a/apps/docs/content/docs/en/tools/rippling.mdx b/apps/docs/content/docs/en/tools/rippling.mdx
index 6eecade9a09..b029318cf27 100644
--- a/apps/docs/content/docs/en/tools/rippling.mdx
+++ b/apps/docs/content/docs/en/tools/rippling.mdx
@@ -72,7 +72,7 @@ List all employees in Rippling with optional pagination
| ↳ `endDate` | string | Employment end date |
| ↳ `manager` | string | Manager ID or name |
| ↳ `phone` | string | Phone number |
-| `totalCount` | number | Total number of employees returned |
+| `totalCount` | number | Number of employees returned on this page |
### `rippling_get_employee`
@@ -131,7 +131,7 @@ List all employees in Rippling including terminated employees with optional pagi
| ↳ `endDate` | string | Employment end date |
| ↳ `manager` | string | Manager ID or name |
| ↳ `phone` | string | Phone number |
-| `totalCount` | number | Total number of employees returned |
+| `totalCount` | number | Number of employees returned on this page |
### `rippling_list_departments`
@@ -153,7 +153,7 @@ List all departments in the Rippling organization
| ↳ `id` | string | Department ID |
| ↳ `name` | string | Department name |
| ↳ `parent` | string | Parent department ID |
-| `totalCount` | number | Total number of departments returned |
+| `totalCount` | number | Number of departments returned on this page |
### `rippling_list_teams`
@@ -175,7 +175,7 @@ List all teams in Rippling
| ↳ `id` | string | Team ID |
| ↳ `name` | string | Team name |
| ↳ `parent` | string | Parent team ID |
-| `totalCount` | number | Total number of teams returned |
+| `totalCount` | number | Number of teams returned on this page |
### `rippling_list_levels`
@@ -197,7 +197,7 @@ List all position levels in Rippling
| ↳ `id` | string | Level ID |
| ↳ `name` | string | Level name |
| ↳ `parent` | string | Parent level ID |
-| `totalCount` | number | Total number of levels returned |
+| `totalCount` | number | Number of levels returned on this page |
### `rippling_list_work_locations`
@@ -223,7 +223,7 @@ List all work locations in Rippling
| ↳ `state` | string | State or province |
| ↳ `zip` | string | ZIP or postal code |
| ↳ `country` | string | Country |
-| `totalCount` | number | Total number of work locations returned |
+| `totalCount` | number | Number of work locations returned on this page |
### `rippling_get_company`
@@ -270,7 +270,7 @@ Get activity events for the current company in Rippling
| ↳ `description` | string | Event description |
| ↳ `createdAt` | string | Event creation timestamp |
| ↳ `actor` | json | Actor who triggered the event \(id, name\) |
-| `totalCount` | number | Total number of activity events returned |
+| `totalCount` | number | Number of activity events returned on this page |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `rippling_list_custom_fields`
@@ -294,7 +294,7 @@ List all custom fields defined in Rippling
| ↳ `type` | string | Field type |
| ↳ `title` | string | Field title |
| ↳ `mandatory` | boolean | Whether the field is mandatory |
-| `totalCount` | number | Total number of custom fields returned |
+| `totalCount` | number | Number of custom fields returned on this page |
### `rippling_get_current_user`
@@ -385,7 +385,7 @@ List leave balances for all employees in Rippling
| ↳ `balances` | array | Leave balance entries |
| ↳ `leaveType` | string | Type of leave |
| ↳ `minutesRemaining` | number | Minutes of leave remaining |
-| `totalCount` | number | Total number of leave balances returned |
+| `totalCount` | number | Number of leave balances returned on this page |
### `rippling_get_leave_balance`
diff --git a/apps/sim/app/(home)/components/demo-request/consts.ts b/apps/sim/app/(home)/components/demo-request/consts.ts
new file mode 100644
index 00000000000..fadfdd389a9
--- /dev/null
+++ b/apps/sim/app/(home)/components/demo-request/consts.ts
@@ -0,0 +1,84 @@
+import { z } from 'zod'
+import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
+import { quickValidateEmail } from '@/lib/messaging/email/validation'
+
+export const DEMO_REQUEST_REGION_VALUES = [
+ 'north_america',
+ 'europe',
+ 'asia_pacific',
+ 'latin_america',
+ 'middle_east_africa',
+ 'other',
+] as const
+
+export const DEMO_REQUEST_USER_COUNT_VALUES = [
+ '1_10',
+ '11_50',
+ '51_200',
+ '201_500',
+ '501_1000',
+ '1000_plus',
+] as const
+
+export const DEMO_REQUEST_REGION_OPTIONS = [
+ { value: 'north_america', label: 'North America' },
+ { value: 'europe', label: 'Europe' },
+ { value: 'asia_pacific', label: 'Asia Pacific' },
+ { value: 'latin_america', label: 'Latin America' },
+ { value: 'middle_east_africa', label: 'Middle East & Africa' },
+ { value: 'other', label: 'Other' },
+] as const
+
+export const DEMO_REQUEST_USER_COUNT_OPTIONS = [
+ { value: '1_10', label: '1-10' },
+ { value: '11_50', label: '11-50' },
+ { value: '51_200', label: '51-200' },
+ { value: '201_500', label: '201-500' },
+ { value: '501_1000', label: '501-1,000' },
+ { value: '1000_plus', label: '1,000+' },
+] as const
+
+export const demoRequestSchema = z.object({
+ firstName: z
+ .string()
+ .trim()
+ .min(1, 'First name is required')
+ .max(100)
+ .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
+ lastName: z
+ .string()
+ .trim()
+ .min(1, 'Last name is required')
+ .max(100)
+ .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
+ companyEmail: z
+ .string()
+ .trim()
+ .min(1, 'Company email is required')
+ .max(320)
+ .transform((value) => value.toLowerCase())
+ .refine((value) => quickValidateEmail(value).isValid, 'Enter a valid work email'),
+ phoneNumber: z
+ .string()
+ .trim()
+ .max(50, 'Phone number must be 50 characters or less')
+ .optional()
+ .transform((value) => (value && value.length > 0 ? value : undefined)),
+ region: z.enum(DEMO_REQUEST_REGION_VALUES, {
+ errorMap: () => ({ message: 'Please select a region' }),
+ }),
+ userCount: z.enum(DEMO_REQUEST_USER_COUNT_VALUES, {
+ errorMap: () => ({ message: 'Please select the number of users' }),
+ }),
+ details: z.string().trim().min(1, 'Details are required').max(2000),
+})
+
+export type DemoRequestPayload = z.infer
+
+export function getDemoRequestRegionLabel(value: DemoRequestPayload['region']): string {
+ return DEMO_REQUEST_REGION_OPTIONS.find((option) => option.value === value)?.label ?? value
+}
+
+export function getDemoRequestUserCountLabel(value: DemoRequestPayload['userCount']): string {
+ return DEMO_REQUEST_USER_COUNT_OPTIONS.find((option) => option.value === value)?.label ?? value
+}
diff --git a/apps/sim/app/(home)/components/demo-request/demo-request-modal.tsx b/apps/sim/app/(home)/components/demo-request/demo-request-modal.tsx
new file mode 100644
index 00000000000..0d5e7ff9ed8
--- /dev/null
+++ b/apps/sim/app/(home)/components/demo-request/demo-request-modal.tsx
@@ -0,0 +1,298 @@
+'use client'
+
+import { useCallback, useState } from 'react'
+import {
+ Button,
+ Combobox,
+ FormField,
+ Input,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalTrigger,
+ Textarea,
+} from '@/components/emcn'
+import { Check } from '@/components/emcn/icons'
+import {
+ DEMO_REQUEST_REGION_OPTIONS,
+ DEMO_REQUEST_USER_COUNT_OPTIONS,
+ type DemoRequestPayload,
+ demoRequestSchema,
+} from '@/app/(home)/components/demo-request/consts'
+
+interface DemoRequestModalProps {
+ children: React.ReactNode
+ theme?: 'dark' | 'light'
+}
+
+type DemoRequestField = keyof DemoRequestPayload
+type DemoRequestErrors = Partial>
+
+interface DemoRequestFormState {
+ firstName: string
+ lastName: string
+ companyEmail: string
+ phoneNumber: string
+ region: DemoRequestPayload['region'] | ''
+ userCount: DemoRequestPayload['userCount'] | ''
+ details: string
+}
+
+const SUBMIT_SUCCESS_MESSAGE = "We'll be in touch soon!"
+const COMBOBOX_REGIONS = [...DEMO_REQUEST_REGION_OPTIONS]
+const COMBOBOX_USER_COUNTS = [...DEMO_REQUEST_USER_COUNT_OPTIONS]
+
+const INITIAL_FORM_STATE: DemoRequestFormState = {
+ firstName: '',
+ lastName: '',
+ companyEmail: '',
+ phoneNumber: '',
+ region: '',
+ userCount: '',
+ details: '',
+}
+
+export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalProps) {
+ const [open, setOpen] = useState(false)
+ const [form, setForm] = useState(INITIAL_FORM_STATE)
+ const [errors, setErrors] = useState({})
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [submitError, setSubmitError] = useState(null)
+ const [submitSuccess, setSubmitSuccess] = useState(false)
+
+ const resetForm = useCallback(() => {
+ setForm(INITIAL_FORM_STATE)
+ setErrors({})
+ setIsSubmitting(false)
+ setSubmitError(null)
+ setSubmitSuccess(false)
+ }, [])
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ setOpen(nextOpen)
+ resetForm()
+ },
+ [resetForm]
+ )
+
+ const updateField = useCallback(
+ (
+ field: TField,
+ value: DemoRequestFormState[TField]
+ ) => {
+ setForm((prev) => ({ ...prev, [field]: value }))
+ setErrors((prev) => {
+ if (!prev[field]) {
+ return prev
+ }
+
+ const nextErrors = { ...prev }
+ delete nextErrors[field]
+ return nextErrors
+ })
+ setSubmitError(null)
+ setSubmitSuccess(false)
+ },
+ []
+ )
+
+ const handleSubmit = useCallback(
+ async (event: React.FormEvent) => {
+ event.preventDefault()
+ setSubmitError(null)
+ setSubmitSuccess(false)
+
+ const parsed = demoRequestSchema.safeParse({
+ ...form,
+ phoneNumber: form.phoneNumber || undefined,
+ })
+
+ if (!parsed.success) {
+ const fieldErrors = parsed.error.flatten().fieldErrors
+ setErrors({
+ firstName: fieldErrors.firstName?.[0],
+ lastName: fieldErrors.lastName?.[0],
+ companyEmail: fieldErrors.companyEmail?.[0],
+ phoneNumber: fieldErrors.phoneNumber?.[0],
+ region: fieldErrors.region?.[0],
+ userCount: fieldErrors.userCount?.[0],
+ details: fieldErrors.details?.[0],
+ })
+ return
+ }
+
+ setIsSubmitting(true)
+
+ try {
+ const response = await fetch('/api/demo-requests', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(parsed.data),
+ })
+
+ const result = (await response.json().catch(() => null)) as {
+ error?: string
+ message?: string
+ } | null
+
+ if (!response.ok) {
+ throw new Error(result?.error || 'Failed to submit demo request')
+ }
+
+ setSubmitSuccess(true)
+ } catch (error) {
+ setSubmitError(
+ error instanceof Error
+ ? error.message
+ : 'Failed to submit demo request. Please try again.'
+ )
+ } finally {
+ setIsSubmitting(false)
+ }
+ },
+ [form, resetForm]
+ )
+
+ return (
+
+ {children}
+
+
+
+ {submitSuccess ? 'Demo request submitted' : 'Nearly there!'}
+
+
+
+
+
+ {submitSuccess ? (
+
+
+
+
+
+
+ {SUBMIT_SUCCESS_MESSAGE}
+
+
+ Our team will be in touch soon. If you have any questions, please email us at{' '}
+
+ enterprise@sim.ai
+
+ .
+
+
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/apps/sim/app/(home)/components/enterprise/enterprise.tsx b/apps/sim/app/(home)/components/enterprise/enterprise.tsx
index 08aab8bbf5b..1d547140108 100644
--- a/apps/sim/app/(home)/components/enterprise/enterprise.tsx
+++ b/apps/sim/app/(home)/components/enterprise/enterprise.tsx
@@ -18,6 +18,7 @@ import Link from 'next/link'
import { Badge, ChevronDown } from '@/components/emcn'
import { Lock } from '@/components/emcn/icons'
import { GithubIcon } from '@/components/icons'
+import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
import { AccessControlPanel } from '@/app/(home)/components/enterprise/components/access-control-panel'
import { AuditLogPreview } from '@/app/(home)/components/enterprise/components/audit-log-preview'
@@ -177,12 +178,12 @@ export default function Enterprise() {
{/* Fade edges */}
{/* Duplicate tags for seamless loop */}
@@ -204,31 +205,31 @@ export default function Enterprise() {
Ready for growth?
-
- Book a demo
-
-
-
-
+
+ Book a demo
+
+
+
-
-
-
+ >
+
+
+
+
+
diff --git a/apps/sim/app/(home)/components/features/components/features-preview.tsx b/apps/sim/app/(home)/components/features/components/features-preview.tsx
index 255e9fd4dc6..fd05ed0857e 100644
--- a/apps/sim/app/(home)/components/features/components/features-preview.tsx
+++ b/apps/sim/app/(home)/components/features/components/features-preview.tsx
@@ -608,9 +608,12 @@ const MOCK_KB_DATA = [
const MD_COMPONENTS: Components = {
h1: ({ children }) => (
-
+
{children}
-
+
),
h2: ({ children }) => (
diff --git a/apps/sim/app/(home)/components/hero/hero.tsx b/apps/sim/app/(home)/components/hero/hero.tsx
index d6c75765ca1..15ee28c832c 100644
--- a/apps/sim/app/(home)/components/hero/hero.tsx
+++ b/apps/sim/app/(home)/components/hero/hero.tsx
@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic'
import Image from 'next/image'
import Link from 'next/link'
+import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
import {
BlocksLeftAnimated,
BlocksRightAnimated,
@@ -70,15 +71,15 @@ export default function Hero() {
-
- Get a demo
-
+
+
+ Get a demo
+
+
-
What should we get done?
-
+
-
-
+
@@ -68,6 +72,7 @@ export function BlogDropdown({ posts }: BlogDropdownProps) {
image={featured.ogImage}
title={featured.title}
imageHeight='190px'
+ sizes='340px'
titleSize='13px'
className='col-span-2 row-span-2'
/>
@@ -79,6 +84,7 @@ export function BlogDropdown({ posts }: BlogDropdownProps) {
image={post.ogImage}
title={post.title}
imageHeight='72px'
+ sizes='170px'
/>
))}
diff --git a/apps/sim/app/(home)/components/navbar/components/docs-dropdown.tsx b/apps/sim/app/(home)/components/navbar/components/docs-dropdown.tsx
index 9d5daa4fa2f..df218c1c7ac 100644
--- a/apps/sim/app/(home)/components/navbar/components/docs-dropdown.tsx
+++ b/apps/sim/app/(home)/components/navbar/components/docs-dropdown.tsx
@@ -1,3 +1,4 @@
+import Image from 'next/image'
import { AgentIcon, GithubOutlineIcon, McpIcon } from '@/components/icons'
const PREVIEW_CARDS = [
@@ -46,12 +47,13 @@ export function DocsDropdown() {
rel='noopener noreferrer'
className='group/card overflow-hidden rounded-[5px] border border-[#2A2A2A] bg-[#1C1C1C] transition-colors hover:border-[#3D3D3D] hover:bg-[#2A2A2A]'
>
-
-
+
diff --git a/apps/sim/app/(home)/components/pricing/pricing.tsx b/apps/sim/app/(home)/components/pricing/pricing.tsx
index 77c4b9b46f0..de1a719d36c 100644
--- a/apps/sim/app/(home)/components/pricing/pricing.tsx
+++ b/apps/sim/app/(home)/components/pricing/pricing.tsx
@@ -1,5 +1,6 @@
import Link from 'next/link'
import { Badge } from '@/components/emcn'
+import { DemoRequestModal } from '@/app/(home)/components/demo-request/demo-request-modal'
interface PricingTier {
id: string
@@ -9,7 +10,7 @@ interface PricingTier {
billingPeriod?: string
color: string
features: string[]
- cta: { label: string; href: string }
+ cta: { label: string; href?: string; action?: 'demo-request' }
}
const PRICING_TIERS: PricingTier[] = [
@@ -78,7 +79,7 @@ const PRICING_TIERS: PricingTier[] = [
'SSO & SCIM · SOC2 & HIPAA',
'Self hosting · Dedicated support',
],
- cta: { label: 'Book a demo', href: 'https://form.typeform.com/to/jqCO12pF' },
+ cta: { label: 'Book a demo', action: 'demo-request' },
},
]
@@ -101,7 +102,7 @@ interface PricingCardProps {
}
function PricingCard({ tier }: PricingCardProps) {
- const isEnterprise = tier.id === 'enterprise'
+ const isDemoRequest = tier.cta.action === 'demo-request'
const isPro = tier.id === 'pro'
return (
@@ -124,25 +125,25 @@ function PricingCard({ tier }: PricingCardProps) {
)}
- {isEnterprise ? (
-
- {tier.cta.label}
-
+ {isDemoRequest ? (
+
+
+ {tier.cta.label}
+
+
) : isPro ? (
{tier.cta.label}
) : (
{tier.cta.label}
diff --git a/apps/sim/app/(home)/landing.tsx b/apps/sim/app/(home)/landing.tsx
index 79689dd57a7..9463afa4be6 100644
--- a/apps/sim/app/(home)/landing.tsx
+++ b/apps/sim/app/(home)/landing.tsx
@@ -37,11 +37,17 @@ export default async function Landing() {
return (
+
+ Skip to main content
+
-
+
diff --git a/apps/sim/app/(landing)/integrations/[slug]/page.tsx b/apps/sim/app/(landing)/integrations/[slug]/page.tsx
index 4229fc2ec5d..a486f105f7f 100644
--- a/apps/sim/app/(landing)/integrations/[slug]/page.tsx
+++ b/apps/sim/app/(landing)/integrations/[slug]/page.tsx
@@ -1,6 +1,8 @@
import type { Metadata } from 'next'
+import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
+import { getBaseUrl } from '@/lib/core/utils/urls'
import { TEMPLATES } from '@/app/workspace/[workspaceId]/home/components/template-prompts/consts'
import { IntegrationIcon } from '../components/integration-icon'
import { blockTypeToIconMap } from '../data/icon-mapping'
@@ -11,6 +13,7 @@ import { TemplateCardButton } from './components/template-card-button'
const allIntegrations = integrations as Integration[]
const INTEGRATION_COUNT = allIntegrations.length
+const baseUrl = getBaseUrl()
/** Fast O(1) lookups — avoids repeated linear scans inside render loops. */
const bySlug = new Map(allIntegrations.map((i) => [i.slug, i]))
@@ -173,16 +176,24 @@ export async function generateMetadata({
openGraph: {
title: `${name} Integration — AI Workflow Automation | Sim`,
description: `Connect ${name} to ${INTEGRATION_COUNT - 1}+ tools using AI agents. ${description.slice(0, 100).trimEnd()}.`,
- url: `https://sim.ai/integrations/${slug}`,
+ url: `${baseUrl}/integrations/${slug}`,
type: 'website',
- images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }],
+ images: [
+ {
+ url: `${baseUrl}/opengraph-image.png`,
+ width: 1200,
+ height: 630,
+ alt: `${name} Integration — Sim`,
+ },
+ ],
},
twitter: {
card: 'summary_large_image',
title: `${name} Integration | Sim`,
description: `Automate ${name} with AI-powered workflows. Connect to ${INTEGRATION_COUNT - 1}+ tools. Free to start.`,
+ images: [{ url: `${baseUrl}/opengraph-image.png`, alt: `${name} Integration — Sim` }],
},
- alternates: { canonical: `https://sim.ai/integrations/${slug}` },
+ alternates: { canonical: `${baseUrl}/integrations/${slug}` },
}
}
@@ -211,14 +222,14 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
- { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
+ { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{
'@type': 'ListItem',
position: 2,
name: 'Integrations',
- item: 'https://sim.ai/integrations',
+ item: `${baseUrl}/integrations`,
},
- { '@type': 'ListItem', position: 3, name, item: `https://sim.ai/integrations/${slug}` },
+ { '@type': 'ListItem', position: 3, name, item: `${baseUrl}/integrations/${slug}` },
],
}
@@ -227,7 +238,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
'@type': 'SoftwareApplication',
name: `${name} Integration`,
description,
- url: `https://sim.ai/integrations/${slug}`,
+ url: `${baseUrl}/integrations/${slug}`,
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
featureList: operations.map((o) => o.name),
@@ -647,39 +658,43 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
{/* Related integrations — internal linking for SEO */}
- {relatedIntegrations.length > 0 && (
-
-
Related integrations
-
- {relatedIntegrations.map((rel) => (
-
-
-
- {rel.name}
-
-
- ))}
-
-
- All integrations →
-
-
- )}
+
+ {relatedIntegrations.length > 0 && (
+ <>
+
+ Related integrations
+
+
+ {relatedIntegrations.map((rel) => (
+
+
+
+ {rel.name}
+
+
+ ))}
+
+ >
+ )}
+
0 ? ' mt-4' : ''}`}
+ >
+ All integrations →
+
+
@@ -690,10 +705,12 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
>
{/* Logo pair: Sim × Integration */}
-
diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json
index 2b3f9e19a1c..7d8cf706be5 100644
--- a/apps/sim/app/(landing)/integrations/data/integrations.json
+++ b/apps/sim/app/(landing)/integrations/data/integrations.json
@@ -168,35 +168,35 @@
"operations": [
{
"name": "List Bases",
- "description": ""
+ "description": "List all bases the authenticated user has access to"
},
{
"name": "List Tables",
- "description": ""
+ "description": "List all tables and their schema in an Airtable base"
},
{
"name": "Get Base Schema",
- "description": ""
+ "description": "Get the schema of all tables, fields, and views in an Airtable base"
},
{
"name": "List Records",
- "description": ""
+ "description": "Read records from an Airtable table"
},
{
"name": "Get Record",
- "description": ""
+ "description": "Retrieve a single record from an Airtable table by its ID"
},
{
"name": "Create Records",
- "description": ""
+ "description": "Write new records to an Airtable table"
},
{
"name": "Update Record",
- "description": ""
+ "description": "Update an existing record in an Airtable table by ID"
},
{
"name": "Update Multiple Records",
- "description": ""
+ "description": "Update multiple existing records in an Airtable table"
}
],
"operationCount": 8,
@@ -287,11 +287,11 @@
},
{
"name": "Delete Index",
- "description": ""
+ "description": "Delete an entire Algolia index and all its records"
},
{
"name": "Copy/Move Index",
- "description": ""
+ "description": "Copy or move an Algolia index to a new destination"
},
{
"name": "Clear Records",
@@ -609,7 +609,7 @@
},
{
"name": "Add to Sequence",
- "description": ""
+ "description": "Add contacts to an Apollo sequence"
},
{
"name": "Create Task",
@@ -904,7 +904,7 @@
},
{
"name": "Assert Record (Upsert)",
- "description": "Upsert a record in Attio \u2014 creates it if no match is found, updates it if a match exists"
+ "description": "Upsert a record in Attio — creates it if no match is found, updates it if a match exists"
},
{
"name": "List Notes",
@@ -1399,7 +1399,7 @@
},
{
"name": "List Event Types",
- "description": ""
+ "description": "Retrieve a list of all event types"
},
{
"name": "Update Event Type",
@@ -1508,7 +1508,7 @@
},
{
"name": "List Event Types",
- "description": ""
+ "description": "Retrieve a list of all event types for a user or organization"
},
{
"name": "Get Event Type",
@@ -1758,11 +1758,11 @@
"operations": [
{
"name": "Read Page",
- "description": ""
+ "description": "Retrieve content from Confluence pages using the Confluence API."
},
{
"name": "Create Page",
- "description": ""
+ "description": "Create a new page in a Confluence space."
},
{
"name": "Update Page",
@@ -1770,7 +1770,7 @@
},
{
"name": "Delete Page",
- "description": ""
+ "description": "Delete a Confluence page. By default moves to trash; use purge=true to permanently delete."
},
{
"name": "List Pages in Space",
@@ -2504,7 +2504,7 @@
"slug": "dspy",
"name": "DSPy",
"description": "Run predictions using self-hosted DSPy programs",
- "longDescription": "Integrate with your self-hosted DSPy programs for LLM-powered predictions. Supports Predict, Chain of Thought, and ReAct agents. DSPy is the framework for programming\u2014not prompting\u2014language models.",
+ "longDescription": "Integrate with your self-hosted DSPy programs for LLM-powered predictions. Supports Predict, Chain of Thought, and ReAct agents. DSPy is the framework for programming—not prompting—language models.",
"bgColor": "#E0E0E0",
"iconName": "DsPyIcon",
"docsUrl": "https://docs.sim.ai/tools/dspy",
@@ -2635,15 +2635,15 @@
},
{
"name": "Create Index",
- "description": ""
+ "description": "Create a new index with optional settings and mappings."
},
{
"name": "Delete Index",
- "description": ""
+ "description": "Delete an index and all its documents. This operation is irreversible."
},
{
"name": "Get Index Info",
- "description": ""
+ "description": "Retrieve index information including settings, mappings, and aliases."
},
{
"name": "List Indices",
@@ -3668,51 +3668,51 @@
"operations": [
{
"name": "Send Email",
- "description": ""
+ "description": "Send Gmail messages"
},
{
"name": "Read Email",
- "description": ""
+ "description": "Read Gmail messages"
},
{
"name": "Draft Email",
- "description": ""
+ "description": "Draft emails using Gmail"
},
{
"name": "Search Email",
- "description": ""
+ "description": "Search emails in Gmail"
},
{
"name": "Move Email",
- "description": ""
+ "description": "Move emails between Gmail labels/folders"
},
{
"name": "Mark as Read",
- "description": ""
+ "description": "Mark a Gmail message as read"
},
{
"name": "Mark as Unread",
- "description": ""
+ "description": "Mark a Gmail message as unread"
},
{
"name": "Archive Email",
- "description": ""
+ "description": "Archive a Gmail message (remove from inbox)"
},
{
"name": "Unarchive Email",
- "description": ""
+ "description": "Unarchive a Gmail message (move back to inbox)"
},
{
"name": "Delete Email",
- "description": ""
+ "description": "Delete a Gmail message (move to trash)"
},
{
"name": "Add Label",
- "description": ""
+ "description": "Add label(s) to a Gmail message"
},
{
"name": "Remove Label",
- "description": ""
+ "description": "Remove label(s) from a Gmail message"
}
],
"operationCount": 12,
@@ -4080,7 +4080,7 @@
},
{
"name": "Create File",
- "description": ""
+ "description": "Upload a file to Google Drive with complete metadata returned"
},
{
"name": "Upload File",
@@ -4453,31 +4453,31 @@
},
{
"name": "Clear Data",
- "description": ""
+ "description": "Clear values from a specific range in a Google Sheets spreadsheet"
},
{
"name": "Get Spreadsheet Info",
- "description": ""
+ "description": "Get metadata about a Google Sheets spreadsheet including title and sheet list"
},
{
"name": "Create Spreadsheet",
- "description": ""
+ "description": "Create a new Google Sheets spreadsheet"
},
{
"name": "Batch Read",
- "description": ""
+ "description": "Read multiple ranges from a Google Sheets spreadsheet in a single request"
},
{
"name": "Batch Update",
- "description": ""
+ "description": "Update multiple ranges in a Google Sheets spreadsheet in a single request"
},
{
"name": "Batch Clear",
- "description": ""
+ "description": "Clear multiple ranges in a Google Sheets spreadsheet in a single request"
},
{
"name": "Copy Sheet",
- "description": ""
+ "description": "Copy a sheet from one spreadsheet to another"
}
],
"operationCount": 11,
@@ -4540,7 +4540,7 @@
},
{
"name": "Reorder Slides",
- "description": ""
+ "description": "Move one or more slides to a new position in a Google Slides presentation"
},
{
"name": "Create Table",
@@ -4807,7 +4807,7 @@
},
{
"name": "List Meeting Types",
- "description": ""
+ "description": "List all meeting types in the workspace"
},
{
"name": "Create Webhook",
@@ -5062,13 +5062,9 @@
"iconName": "HubspotIcon",
"docsUrl": "https://docs.sim.ai/tools/hubspot",
"operations": [
- {
- "name": "Get Users",
- "description": "Retrieve all users from HubSpot account"
- },
{
"name": "Get Contacts",
- "description": ""
+ "description": "Retrieve all contacts from HubSpot account with pagination support"
},
{
"name": "Create Contact",
@@ -5084,7 +5080,7 @@
},
{
"name": "Get Companies",
- "description": ""
+ "description": "Retrieve all companies from HubSpot account with pagination support"
},
{
"name": "Create Company",
@@ -5100,10 +5096,90 @@
},
{
"name": "Get Deals",
- "description": ""
+ "description": "Retrieve all deals from HubSpot account with pagination support"
+ },
+ {
+ "name": "Create Deal",
+ "description": "Create a new deal in HubSpot. Requires at least a dealname property"
+ },
+ {
+ "name": "Update Deal",
+ "description": "Update an existing deal in HubSpot by ID"
+ },
+ {
+ "name": "Search Deals",
+ "description": "Search for deals in HubSpot using filters, sorting, and queries"
+ },
+ {
+ "name": "Get Tickets",
+ "description": "Retrieve all tickets from HubSpot account with pagination support"
+ },
+ {
+ "name": "Create Ticket",
+ "description": "Create a new ticket in HubSpot. Requires subject and hs_pipeline_stage properties"
+ },
+ {
+ "name": "Update Ticket",
+ "description": "Update an existing ticket in HubSpot by ID"
+ },
+ {
+ "name": "Search Tickets",
+ "description": "Search for tickets in HubSpot using filters, sorting, and queries"
+ },
+ {
+ "name": "Get Line Items",
+ "description": "Retrieve all line items from HubSpot account with pagination support"
+ },
+ {
+ "name": "Create Line Item",
+ "description": "Create a new line item in HubSpot. Requires at least a name property"
+ },
+ {
+ "name": "Update Line Item",
+ "description": "Update an existing line item in HubSpot by ID"
+ },
+ {
+ "name": "Get Quotes",
+ "description": "Retrieve all quotes from HubSpot account with pagination support"
+ },
+ {
+ "name": "Get Appointments",
+ "description": "Retrieve all appointments from HubSpot account with pagination support"
+ },
+ {
+ "name": "Create Appointment",
+ "description": "Create a new appointment in HubSpot"
+ },
+ {
+ "name": "Update Appointment",
+ "description": "Update an existing appointment in HubSpot by ID"
+ },
+ {
+ "name": "Get Carts",
+ "description": "Retrieve all carts from HubSpot account with pagination support"
+ },
+ {
+ "name": "List Owners",
+ "description": "Retrieve all owners from HubSpot account with pagination support"
+ },
+ {
+ "name": "Get Marketing Events",
+ "description": "Retrieve all marketing events from HubSpot account with pagination support"
+ },
+ {
+ "name": "Get Lists",
+ "description": "Search and retrieve lists from HubSpot account"
+ },
+ {
+ "name": "Create List",
+ "description": "Create a new list in HubSpot. Specify the object type and processing type (MANUAL or DYNAMIC)"
+ },
+ {
+ "name": "Get Users",
+ "description": "Retrieve all users from HubSpot account"
}
],
- "operationCount": 10,
+ "operationCount": 29,
"triggers": [
{
"id": "hubspot_contact_created",
@@ -5611,7 +5687,7 @@
},
{
"name": "Update Ticket",
- "description": ""
+ "description": "Update a ticket in Intercom (change state, assignment, attributes)"
},
{
"name": "Create Message",
@@ -5619,59 +5695,59 @@
},
{
"name": "List Admins",
- "description": ""
+ "description": "Fetch a list of all admins for the workspace"
},
{
"name": "Close Conversation",
- "description": ""
+ "description": "Close a conversation in Intercom"
},
{
"name": "Open Conversation",
- "description": ""
+ "description": "Open a closed or snoozed conversation in Intercom"
},
{
"name": "Snooze Conversation",
- "description": ""
+ "description": "Snooze a conversation to reopen at a future time"
},
{
"name": "Assign Conversation",
- "description": ""
+ "description": "Assign a conversation to an admin or team in Intercom"
},
{
"name": "List Tags",
- "description": ""
+ "description": "Fetch a list of all tags in the workspace"
},
{
"name": "Create Tag",
- "description": ""
+ "description": "Create a new tag or update an existing tag name"
},
{
"name": "Tag Contact",
- "description": ""
+ "description": "Add a tag to a specific contact"
},
{
"name": "Untag Contact",
- "description": ""
+ "description": "Remove a tag from a specific contact"
},
{
"name": "Tag Conversation",
- "description": ""
+ "description": "Add a tag to a specific conversation"
},
{
"name": "Create Note",
- "description": ""
+ "description": "Add a note to a specific contact"
},
{
"name": "Create Event",
- "description": ""
+ "description": "Track a custom event for a contact in Intercom"
},
{
"name": "Attach Contact to Company",
- "description": ""
+ "description": "Attach a contact to a company in Intercom"
},
{
"name": "Detach Contact from Company",
- "description": ""
+ "description": "Remove a contact from a company in Intercom"
}
],
"operationCount": 31,
@@ -5721,7 +5797,7 @@
"operations": [
{
"name": "Read Issue",
- "description": ""
+ "description": "Retrieve detailed information about a specific Jira issue"
},
{
"name": "Update Issue",
@@ -5733,19 +5809,19 @@
},
{
"name": "Delete Issue",
- "description": ""
+ "description": "Delete a Jira issue"
},
{
"name": "Assign Issue",
- "description": ""
+ "description": "Assign a Jira issue to a user"
},
{
"name": "Transition Issue",
- "description": ""
+ "description": "Move a Jira issue between workflow statuses (e.g., To Do -> In Progress)"
},
{
"name": "Search Issues",
- "description": ""
+ "description": "Search for Jira issues using JQL (Jira Query Language)"
},
{
"name": "Add Comment",
@@ -5793,11 +5869,11 @@
},
{
"name": "Create Issue Link",
- "description": ""
+ "description": "Create a link relationship between two Jira issues"
},
{
"name": "Delete Issue Link",
- "description": ""
+ "description": "Delete a link between two Jira issues"
},
{
"name": "Add Watcher",
@@ -5867,87 +5943,87 @@
"operations": [
{
"name": "Get Service Desks",
- "description": ""
+ "description": "Get all service desks from Jira Service Management"
},
{
"name": "Get Request Types",
- "description": ""
+ "description": "Get request types for a service desk in Jira Service Management"
},
{
"name": "Create Request",
- "description": ""
+ "description": "Create a new service request in Jira Service Management"
},
{
"name": "Get Request",
- "description": ""
+ "description": "Get a single service request from Jira Service Management"
},
{
"name": "Get Requests",
- "description": ""
+ "description": "Get multiple service requests from Jira Service Management"
},
{
"name": "Add Comment",
- "description": ""
+ "description": "Add a comment (public or internal) to a service request in Jira Service Management"
},
{
"name": "Get Comments",
- "description": ""
+ "description": "Get comments for a service request in Jira Service Management"
},
{
"name": "Get Customers",
- "description": ""
+ "description": "Get customers for a service desk in Jira Service Management"
},
{
"name": "Add Customer",
- "description": ""
+ "description": "Add customers to a service desk in Jira Service Management"
},
{
"name": "Get Organizations",
- "description": ""
+ "description": "Get organizations for a service desk in Jira Service Management"
},
{
"name": "Create Organization",
- "description": ""
+ "description": "Create a new organization in Jira Service Management"
},
{
"name": "Add Organization",
- "description": ""
+ "description": "Add an organization to a service desk in Jira Service Management"
},
{
"name": "Get Queues",
- "description": ""
+ "description": "Get queues for a service desk in Jira Service Management"
},
{
"name": "Get SLA",
- "description": ""
+ "description": "Get SLA information for a service request in Jira Service Management"
},
{
"name": "Get Transitions",
- "description": ""
+ "description": "Get available transitions for a service request in Jira Service Management"
},
{
"name": "Transition Request",
- "description": ""
+ "description": "Transition a service request to a new status in Jira Service Management"
},
{
"name": "Get Participants",
- "description": ""
+ "description": "Get participants for a request in Jira Service Management"
},
{
"name": "Add Participants",
- "description": ""
+ "description": "Add participants to a request in Jira Service Management"
},
{
"name": "Get Approvals",
- "description": ""
+ "description": "Get approvals for a request in Jira Service Management"
},
{
"name": "Answer Approval",
- "description": ""
+ "description": "Approve or decline an approval request in Jira Service Management"
},
{
"name": "Get Request Type Fields",
- "description": ""
+ "description": "Get the fields required to create a request of a specific type in Jira Service Management"
}
],
"operationCount": 21,
@@ -7823,7 +7899,7 @@
},
{
"name": "Create File",
- "description": ""
+ "description": "Upload a file to OneDrive"
},
{
"name": "Upload File",
@@ -7862,39 +7938,39 @@
"operations": [
{
"name": "Send Email",
- "description": ""
+ "description": "Send emails using Outlook"
},
{
"name": "Draft Email",
- "description": ""
+ "description": "Draft emails using Outlook"
},
{
"name": "Read Email",
- "description": ""
+ "description": "Read emails from Outlook"
},
{
"name": "Forward Email",
- "description": ""
+ "description": "Forward an existing Outlook message to specified recipients"
},
{
"name": "Move Email",
- "description": ""
+ "description": "Move emails between Outlook folders"
},
{
"name": "Mark as Read",
- "description": ""
+ "description": "Mark an Outlook message as read"
},
{
"name": "Mark as Unread",
- "description": ""
+ "description": "Mark an Outlook message as unread"
},
{
"name": "Delete Email",
- "description": ""
+ "description": "Delete an Outlook message (move to Deleted Items)"
},
{
"name": "Copy Email",
- "description": ""
+ "description": "Copy an Outlook message to another folder"
}
],
"operationCount": 9,
@@ -7960,15 +8036,15 @@
"operations": [
{
"name": "Search",
- "description": ""
+ "description": "Search the web using Parallel AI. Provides comprehensive search results with intelligent processing and content extraction."
},
{
"name": "Extract from URLs",
- "description": ""
+ "description": "Extract targeted information from specific URLs using Parallel AI. Processes provided URLs to pull relevant content based on your objective."
},
{
"name": "Deep Research",
- "description": ""
+ "description": "Conduct comprehensive deep research across the web using Parallel AI. Synthesizes information from multiple sources with citations. Can take up to 45 minutes to complete."
}
],
"operationCount": 3,
@@ -8018,7 +8094,7 @@
"operations": [
{
"name": "Generate Embeddings",
- "description": ""
+ "description": "Generate embeddings from text using Pinecone"
},
{
"name": "Upsert Text",
@@ -8499,15 +8575,15 @@
"operations": [
{
"name": "Upsert",
- "description": ""
+ "description": "Insert or update points in a Qdrant collection"
},
{
"name": "Search",
- "description": ""
+ "description": "Search for similar vectors in a Qdrant collection"
},
{
"name": "Fetch",
- "description": ""
+ "description": "Fetch points by ID from a Qdrant collection"
}
],
"operationCount": 3,
@@ -8943,7 +9019,7 @@
},
{
"name": "List Leave Types",
- "description": ""
+ "description": "List company leave types configured in Rippling"
},
{
"name": "Create Group",
@@ -9125,7 +9201,7 @@
},
{
"name": "List Report Types",
- "description": ""
+ "description": "Get a list of available report types"
},
{
"name": "List Dashboards",
@@ -9456,7 +9532,7 @@
},
{
"name": "Read List",
- "description": ""
+ "description": "Get metadata (and optionally columns/items) for a SharePoint list"
},
{
"name": "Update List",
@@ -9633,11 +9709,11 @@
"operations": [
{
"name": "Send Message",
- "description": ""
+ "description": "Send messages to Slack channels or direct messages. Supports Slack mrkdwn formatting."
},
{
"name": "Send Ephemeral Message",
- "description": ""
+ "description": "Send an ephemeral message visible only to a specific user in a channel. Optionally reply in a thread. The message does not persist across sessions."
},
{
"name": "Create Canvas",
@@ -9645,7 +9721,7 @@
},
{
"name": "Read Messages",
- "description": ""
+ "description": "Read the latest messages from Slack channels. Retrieve conversation history with filtering options."
},
{
"name": "Get Message",
@@ -9677,19 +9753,19 @@
},
{
"name": "Update Message",
- "description": ""
+ "description": "Update a message previously sent by the bot in Slack"
},
{
"name": "Delete Message",
- "description": ""
+ "description": "Delete a message previously sent by the bot in Slack"
},
{
"name": "Add Reaction",
- "description": ""
+ "description": "Add an emoji reaction to a Slack message"
},
{
"name": "Remove Reaction",
- "description": ""
+ "description": "Remove an emoji reaction from a Slack message"
},
{
"name": "Get Channel Info",
@@ -10530,67 +10606,67 @@
"operations": [
{
"name": "Get",
- "description": ""
+ "description": "Get the value of a key from Upstash Redis."
},
{
"name": "Set",
- "description": ""
+ "description": "Set the value of a key in Upstash Redis with an optional expiration time in seconds."
},
{
"name": "Delete",
- "description": ""
+ "description": "Delete a key from Upstash Redis."
},
{
"name": "List Keys",
- "description": ""
+ "description": "List keys matching a pattern in Upstash Redis. Defaults to listing all keys (*)."
},
{
"name": "HSET",
- "description": ""
+ "description": "Set a field in a hash stored at a key in Upstash Redis."
},
{
"name": "HGET",
- "description": ""
+ "description": "Get the value of a field in a hash stored at a key in Upstash Redis."
},
{
"name": "HGETALL",
- "description": ""
+ "description": "Get all fields and values of a hash stored at a key in Upstash Redis."
},
{
"name": "INCR",
- "description": ""
+ "description": "Atomically increment the integer value of a key by one in Upstash Redis. If the key does not exist, it is set to 0 before incrementing."
},
{
"name": "INCRBY",
- "description": ""
+ "description": "Increment the integer value of a key by a given amount. Use a negative value to decrement. If the key does not exist, it is set to 0 before the operation."
},
{
"name": "EXISTS",
- "description": ""
+ "description": "Check if a key exists in Upstash Redis. Returns true if the key exists, false otherwise."
},
{
"name": "SETNX",
- "description": ""
+ "description": "Set the value of a key only if it does not already exist. Returns true if the key was set, false if it already existed."
},
{
"name": "LPUSH",
- "description": ""
+ "description": "Prepend a value to the beginning of a list in Upstash Redis. Creates the list if it does not exist."
},
{
"name": "LRANGE",
- "description": ""
+ "description": "Get a range of elements from a list in Upstash Redis. Use 0 and -1 for start and stop to get all elements."
},
{
"name": "EXPIRE",
- "description": ""
+ "description": "Set a timeout on a key in Upstash Redis. After the timeout, the key is deleted."
},
{
"name": "TTL",
- "description": ""
+ "description": "Get the remaining time to live of a key in Upstash Redis. Returns -1 if the key has no expiration, -2 if the key does not exist."
},
{
"name": "Command",
- "description": ""
+ "description": "Execute an arbitrary Redis command against Upstash Redis. Pass the full command as a JSON array (e.g., ["
}
],
"operationCount": 16,
@@ -10911,23 +10987,23 @@
"operations": [
{
"name": "List Items",
- "description": ""
+ "description": "List all items from a Webflow CMS collection"
},
{
"name": "Get Item",
- "description": ""
+ "description": "Get a single item from a Webflow CMS collection"
},
{
"name": "Create Item",
- "description": ""
+ "description": "Create a new item in a Webflow CMS collection"
},
{
"name": "Update Item",
- "description": ""
+ "description": "Update an existing item in a Webflow CMS collection"
},
{
"name": "Delete Item",
- "description": ""
+ "description": "Delete an item from a Webflow CMS collection"
}
],
"operationCount": 5,
diff --git a/apps/sim/app/(landing)/integrations/layout.tsx b/apps/sim/app/(landing)/integrations/layout.tsx
index 9691c27ebef..8b523aa9ff1 100644
--- a/apps/sim/app/(landing)/integrations/layout.tsx
+++ b/apps/sim/app/(landing)/integrations/layout.tsx
@@ -1,15 +1,17 @@
import { getNavBlogPosts } from '@/lib/blog/registry'
+import { getBaseUrl } from '@/lib/core/utils/urls'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default async function IntegrationsLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
+ const url = getBaseUrl()
const orgJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Sim',
- url: 'https://sim.ai',
- logo: 'https://sim.ai/logo/primary/small.png',
+ url,
+ logo: `${url}/logo/primary/small.png`,
sameAs: ['https://x.com/simdotai'],
}
@@ -17,10 +19,10 @@ export default async function IntegrationsLayout({ children }: { children: React
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Sim',
- url: 'https://sim.ai',
+ url,
potentialAction: {
'@type': 'SearchAction',
- target: 'https://sim.ai/search?q={search_term_string}',
+ target: `${url}/search?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
}
diff --git a/apps/sim/app/(landing)/integrations/page.tsx b/apps/sim/app/(landing)/integrations/page.tsx
index b4d8243c3db..65a65262366 100644
--- a/apps/sim/app/(landing)/integrations/page.tsx
+++ b/apps/sim/app/(landing)/integrations/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
+import { getBaseUrl } from '@/lib/core/utils/urls'
import { IntegrationGrid } from './components/integration-grid'
import { RequestIntegrationModal } from './components/request-integration-modal'
import { blockTypeToIconMap } from './data/icon-mapping'
@@ -15,6 +16,8 @@ const INTEGRATION_COUNT = allIntegrations.length
*/
const TOP_NAMES = [...new Set(POPULAR_WORKFLOWS.flatMap((p) => [p.from, p.to]))].slice(0, 6)
+const baseUrl = getBaseUrl()
+
export const metadata: Metadata = {
title: 'Integrations',
description: `Connect ${INTEGRATION_COUNT}+ apps and services with Sim's AI workflow automation. Build intelligent pipelines with ${TOP_NAMES.join(', ')}, and more.`,
@@ -28,16 +31,26 @@ export const metadata: Metadata = {
openGraph: {
title: 'Integrations for AI Workflow Automation | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim. Build AI-powered pipelines that link ${TOP_NAMES.join(', ')}, and every tool your team uses.`,
- url: 'https://sim.ai/integrations',
+ url: `${baseUrl}/integrations`,
type: 'website',
- images: [{ url: 'https://sim.ai/opengraph-image.png', width: 1200, height: 630 }],
+ images: [
+ {
+ url: `${baseUrl}/opengraph-image.png`,
+ width: 1200,
+ height: 630,
+ alt: 'Sim Integrations for AI Workflow Automation',
+ },
+ ],
},
twitter: {
card: 'summary_large_image',
title: 'Integrations | Sim',
description: `Connect ${INTEGRATION_COUNT}+ apps with Sim's AI workflow automation.`,
+ images: [
+ { url: `${baseUrl}/opengraph-image.png`, alt: 'Sim Integrations for AI Workflow Automation' },
+ ],
},
- alternates: { canonical: 'https://sim.ai/integrations' },
+ alternates: { canonical: `${baseUrl}/integrations` },
}
export default function IntegrationsPage() {
@@ -45,12 +58,12 @@ export default function IntegrationsPage() {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
- { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
+ { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{
'@type': 'ListItem',
position: 2,
name: 'Integrations',
- item: 'https://sim.ai/integrations',
+ item: `${baseUrl}/integrations`,
},
],
}
@@ -60,7 +73,7 @@ export default function IntegrationsPage() {
'@type': 'ItemList',
name: 'Sim AI Workflow Integrations',
description: `Complete list of ${INTEGRATION_COUNT}+ integrations available in Sim for building AI-powered workflow automation.`,
- url: 'https://sim.ai/integrations',
+ url: `${baseUrl}/integrations`,
numberOfItems: INTEGRATION_COUNT,
itemListElement: allIntegrations.map((integration, index) => ({
'@type': 'ListItem',
@@ -69,7 +82,7 @@ export default function IntegrationsPage() {
'@type': 'SoftwareApplication',
name: integration.name,
description: integration.description,
- url: `https://sim.ai/integrations/${integration.slug}`,
+ url: `${baseUrl}/integrations/${integration.slug}`,
applicationCategory: 'BusinessApplication',
featureList: integration.operations.map((o) => o.name),
},
diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts
new file mode 100644
index 00000000000..8ca458f6cbc
--- /dev/null
+++ b/apps/sim/app/api/demo-requests/route.ts
@@ -0,0 +1,106 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { env } from '@/lib/core/config/env'
+import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
+import { RateLimiter } from '@/lib/core/rate-limiter'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { getEmailDomain } from '@/lib/core/utils/urls'
+import { sendEmail } from '@/lib/messaging/email/mailer'
+import { getFromEmailAddress } from '@/lib/messaging/email/utils'
+import {
+ demoRequestSchema,
+ getDemoRequestRegionLabel,
+ getDemoRequestUserCountLabel,
+} from '@/app/(home)/components/demo-request/consts'
+
+const logger = createLogger('DemoRequestAPI')
+const rateLimiter = new RateLimiter()
+
+const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
+ maxTokens: 10,
+ refillRate: 5,
+ refillIntervalMs: 60_000,
+}
+
+export async function POST(req: NextRequest) {
+ const requestId = generateRequestId()
+
+ try {
+ const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
+ const storageKey = `public:demo-request:${ip}`
+
+ const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
+ storageKey,
+ PUBLIC_ENDPOINT_RATE_LIMIT
+ )
+
+ if (!allowed) {
+ logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
+ return NextResponse.json(
+ { error: 'Too many requests. Please try again later.' },
+ {
+ status: 429,
+ headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
+ }
+ )
+ }
+
+ const body = await req.json()
+ const validationResult = demoRequestSchema.safeParse(body)
+
+ if (!validationResult.success) {
+ logger.warn(`[${requestId}] Invalid demo request data`, {
+ errors: validationResult.error.format(),
+ })
+ return NextResponse.json(
+ { error: 'Invalid request data', details: validationResult.error.format() },
+ { status: 400 }
+ )
+ }
+
+ const { firstName, lastName, companyEmail, phoneNumber, region, userCount, details } =
+ validationResult.data
+
+ logger.info(`[${requestId}] Processing demo request`, {
+ email: `${companyEmail.substring(0, 3)}***`,
+ region,
+ userCount,
+ })
+
+ const emailText = `Demo request submitted
+Submitted: ${new Date().toISOString()}
+Name: ${firstName} ${lastName}
+Email: ${companyEmail}
+Phone: ${phoneNumber ?? 'Not provided'}
+Region: ${getDemoRequestRegionLabel(region)}
+Users: ${getDemoRequestUserCountLabel(userCount)}
+
+Details:
+${details}
+`
+
+ const emailResult = await sendEmail({
+ to: [`enterprise@${env.EMAIL_DOMAIN || getEmailDomain()}`],
+ subject: `[DEMO REQUEST] ${firstName} ${lastName}`,
+ text: emailText,
+ from: getFromEmailAddress(),
+ replyTo: companyEmail,
+ emailType: 'transactional',
+ })
+
+ if (!emailResult.success) {
+ logger.error(`[${requestId}] Error sending demo request email`, emailResult.message)
+ return NextResponse.json({ error: 'Failed to submit request' }, { status: 500 })
+ }
+
+ logger.info(`[${requestId}] Demo request email sent successfully`)
+
+ return NextResponse.json(
+ { success: true, message: 'Thanks! Our team will reach out shortly.' },
+ { status: 201 }
+ )
+ } catch (error) {
+ logger.error(`[${requestId}] Error processing demo request`, error)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/api/help/integration-request/route.ts b/apps/sim/app/api/help/integration-request/route.ts
index c6773d2f686..929ae36730a 100644
--- a/apps/sim/app/api/help/integration-request/route.ts
+++ b/apps/sim/app/api/help/integration-request/route.ts
@@ -7,7 +7,10 @@ import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
-import { getFromEmailAddress } from '@/lib/messaging/email/utils'
+import {
+ getFromEmailAddress,
+ NO_EMAIL_HEADER_CONTROL_CHARS_REGEX,
+} from '@/lib/messaging/email/utils'
const logger = createLogger('IntegrationRequestAPI')
@@ -20,7 +23,12 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
}
const integrationRequestSchema = z.object({
- integrationName: z.string().min(1, 'Integration name is required').max(200),
+ integrationName: z
+ .string()
+ .trim()
+ .min(1, 'Integration name is required')
+ .max(200)
+ .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
email: z.string().email('A valid email is required'),
useCase: z.string().max(2000).optional(),
})
diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts
index 2c2e24ddb7d..a028bd4005f 100644
--- a/apps/sim/app/api/help/route.ts
+++ b/apps/sim/app/api/help/route.ts
@@ -7,12 +7,19 @@ import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
-import { getFromEmailAddress } from '@/lib/messaging/email/utils'
+import {
+ getFromEmailAddress,
+ NO_EMAIL_HEADER_CONTROL_CHARS_REGEX,
+} from '@/lib/messaging/email/utils'
const logger = createLogger('HelpAPI')
const helpFormSchema = z.object({
- subject: z.string().min(1, 'Subject is required'),
+ subject: z
+ .string()
+ .trim()
+ .min(1, 'Subject is required')
+ .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
})
diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx
index c4d5db0301e..a58f0c25cbf 100644
--- a/apps/sim/app/layout.tsx
+++ b/apps/sim/app/layout.tsx
@@ -3,11 +3,7 @@ import Script from 'next/script'
import { PublicEnvScript } from 'next-runtime-env'
import { BrandedLayout } from '@/components/branded-layout'
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
-import {
- generateBrandedMetadata,
- generateStructuredData,
- generateThemeCSS,
-} from '@/ee/whitelabeling'
+import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling'
import '@/app/_styles/globals.css'
import { OneDollarStats } from '@/components/analytics/onedollarstats'
import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags'
@@ -21,8 +17,6 @@ import { season } from '@/app/_styles/fonts/season/season'
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
- maximumScale: 1,
- userScalable: false,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
@@ -32,7 +26,6 @@ export const viewport: Viewport = {
export const metadata: Metadata = generateBrandedMetadata()
export default function RootLayout({ children }: { children: React.ReactNode }) {
- const structuredData = generateStructuredData()
const themeCSS = generateThemeCSS()
return (
@@ -76,14 +69,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
strategy='lazyOnload'
/>
)}
- {/* Structured Data for SEO */}
-
-
{/*
Workspace layout dimensions: set CSS vars before hydration to avoid layout jump.
diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts
index a3553f13d74..b1a56d264fa 100644
--- a/apps/sim/app/sitemap.ts
+++ b/apps/sim/app/sitemap.ts
@@ -1,6 +1,7 @@
import type { MetadataRoute } from 'next'
import { getAllPostMeta } from '@/lib/blog/registry'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import integrations from '@/app/(landing)/integrations/data/integrations.json'
export default async function sitemap(): Promise
{
const baseUrl = getBaseUrl()
@@ -24,6 +25,10 @@ export default async function sitemap(): Promise {
// url: `${baseUrl}/templates`,
// lastModified: now,
// },
+ {
+ url: `${baseUrl}/integrations`,
+ lastModified: now,
+ },
{
url: `${baseUrl}/changelog`,
lastModified: now,
@@ -44,5 +49,10 @@ export default async function sitemap(): Promise {
lastModified: new Date(p.updated ?? p.date),
}))
- return [...staticPages, ...blogPages]
+ const integrationPages: MetadataRoute.Sitemap = integrations.map((i) => ({
+ url: `${baseUrl}/integrations/${i.slug}`,
+ lastModified: now,
+ }))
+
+ return [...staticPages, ...blogPages, ...integrationPages]
}
diff --git a/apps/sim/blocks/blocks/upstash.ts b/apps/sim/blocks/blocks/upstash.ts
index 040377d9625..854c99fccba 100644
--- a/apps/sim/blocks/blocks/upstash.ts
+++ b/apps/sim/blocks/blocks/upstash.ts
@@ -256,7 +256,42 @@ export const UpstashBlock: BlockConfig = {
if (params.increment !== undefined) {
params.increment = Number(params.increment)
}
- return `upstash_redis_${params.operation}`
+ switch (params.operation) {
+ case 'get':
+ return 'upstash_redis_get'
+ case 'set':
+ return 'upstash_redis_set'
+ case 'delete':
+ return 'upstash_redis_delete'
+ case 'keys':
+ return 'upstash_redis_keys'
+ case 'command':
+ return 'upstash_redis_command'
+ case 'hset':
+ return 'upstash_redis_hset'
+ case 'hget':
+ return 'upstash_redis_hget'
+ case 'hgetall':
+ return 'upstash_redis_hgetall'
+ case 'incr':
+ return 'upstash_redis_incr'
+ case 'incrby':
+ return 'upstash_redis_incrby'
+ case 'exists':
+ return 'upstash_redis_exists'
+ case 'setnx':
+ return 'upstash_redis_setnx'
+ case 'lpush':
+ return 'upstash_redis_lpush'
+ case 'lrange':
+ return 'upstash_redis_lrange'
+ case 'expire':
+ return 'upstash_redis_expire'
+ case 'ttl':
+ return 'upstash_redis_ttl'
+ default:
+ throw new Error(`Unknown operation: ${params.operation}`)
+ }
},
},
},
diff --git a/apps/sim/components/emcn/components/form-field/form-field.tsx b/apps/sim/components/emcn/components/form-field/form-field.tsx
new file mode 100644
index 00000000000..69b9c5c5290
--- /dev/null
+++ b/apps/sim/components/emcn/components/form-field/form-field.tsx
@@ -0,0 +1,28 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { Label } from '@/components/emcn'
+
+export interface FormFieldProps {
+ label: ReactNode
+ children: ReactNode
+ htmlFor?: string
+ optional?: boolean
+ error?: ReactNode
+}
+
+/**
+ * Standard labeled field wrapper for forms and modals.
+ */
+export function FormField({ label, children, htmlFor, optional = false, error }: FormFieldProps) {
+ return (
+
+
+ {label}
+ {optional ? (optional) : null}
+
+ {children}
+ {error ?
{error}
: null}
+
+ )
+}
diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts
index 269510bda0f..1d8afcef3f1 100644
--- a/apps/sim/components/emcn/components/index.ts
+++ b/apps/sim/components/emcn/components/index.ts
@@ -56,6 +56,7 @@ export {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from './dropdown-menu/dropdown-menu'
+export { FormField, type FormFieldProps } from './form-field/form-field'
export { Input, type InputProps, inputVariants } from './input/input'
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp/input-otp'
export { Label } from './label/label'
diff --git a/apps/sim/lib/messaging/email/mailer.test.ts b/apps/sim/lib/messaging/email/mailer.test.ts
index 327c8f49657..18e720b6268 100644
--- a/apps/sim/lib/messaging/email/mailer.test.ts
+++ b/apps/sim/lib/messaging/email/mailer.test.ts
@@ -1,21 +1,11 @@
import { createEnvMock, loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
-/**
- * Tests for the mailer module.
- *
- * Note: Due to bun test runner's module loading behavior, the Resend and Azure
- * clients are initialized at module load time. These tests mock the actual
- * Resend and EmailClient classes to return mock implementations that our
- * mock functions can intercept.
- */
-
const mockSend = vi.fn()
const mockBatchSend = vi.fn()
const mockAzureBeginSend = vi.fn()
const mockAzurePollUntilDone = vi.fn()
-// Mock the Resend module - returns an object with emails.send
vi.mock('resend', () => {
return {
Resend: vi.fn().mockImplementation(() => ({
@@ -29,7 +19,6 @@ vi.mock('resend', () => {
}
})
-// Mock Azure Communication Email - returns an object with beginSend
vi.mock('@azure/communication-email', () => {
return {
EmailClient: vi.fn().mockImplementation(() => ({
@@ -38,13 +27,11 @@ vi.mock('@azure/communication-email', () => {
}
})
-// Mock unsubscribe module
vi.mock('@/lib/messaging/email/unsubscribe', () => ({
isUnsubscribed: vi.fn(),
generateUnsubscribeToken: vi.fn(),
}))
-// Mock env with valid API keys so the clients get initialized
vi.mock('@/lib/core/config/env', () =>
createEnvMock({
RESEND_API_KEY: 'test-api-key',
@@ -55,28 +42,23 @@ vi.mock('@/lib/core/config/env', () =>
})
)
-// Mock URL utilities
vi.mock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
getBaseDomain: vi.fn().mockReturnValue('test.sim.ai'),
}))
-// Mock the utils module (getFromEmailAddress)
vi.mock('@/lib/messaging/email/utils', () => ({
getFromEmailAddress: vi.fn().mockReturnValue('Sim '),
+ hasEmailHeaderControlChars: vi.fn().mockImplementation((value: string) => /[\r\n]/.test(value)),
+ EMAIL_HEADER_CONTROL_CHARS_REGEX: /[\r\n]/,
+ NO_EMAIL_HEADER_CONTROL_CHARS_REGEX: /^[^\r\n]*$/,
}))
vi.mock('@sim/logger', () => loggerMock)
-// Import after mocks are set up
-import {
- type EmailType,
- hasEmailService,
- sendBatchEmails,
- sendEmail,
-} from '@/lib/messaging/email/mailer'
-import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'
+import { type EmailType, hasEmailService, sendBatchEmails, sendEmail } from './mailer'
+import { generateUnsubscribeToken, isUnsubscribed } from './unsubscribe'
describe('mailer', () => {
const testEmailOptions = {
@@ -90,7 +72,6 @@ describe('mailer', () => {
;(isUnsubscribed as Mock).mockResolvedValue(false)
;(generateUnsubscribeToken as Mock).mockReturnValue('mock-token-123')
- // Mock successful Resend response
mockSend.mockResolvedValue({
data: { id: 'test-email-id' },
error: null,
@@ -101,7 +82,6 @@ describe('mailer', () => {
error: null,
})
- // Mock successful Azure response
mockAzurePollUntilDone.mockResolvedValue({
status: 'Succeeded',
id: 'azure-email-id',
@@ -114,7 +94,6 @@ describe('mailer', () => {
describe('hasEmailService', () => {
it('should return true when email service is configured', () => {
- // The mailer module initializes with mocked env that has valid API keys
const result = hasEmailService()
expect(typeof result).toBe('boolean')
})
@@ -128,7 +107,6 @@ describe('mailer', () => {
})
expect(result.success).toBe(true)
- // Should not check unsubscribe status for transactional emails
expect(isUnsubscribed).not.toHaveBeenCalled()
})
@@ -175,6 +153,34 @@ describe('mailer', () => {
expect(result.success).toBe(true)
})
+ it('should sanitize CRLF characters in subjects before sending', async () => {
+ const result = await sendEmail({
+ to: 'test@example.com',
+ subject: 'Hello\r\nBcc: attacker@evil.com',
+ text: 'Plain text content',
+ })
+
+ expect(result.success).toBe(true)
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ subject: 'Hello Bcc: attacker@evil.com',
+ })
+ )
+ })
+
+ it('should reject reply-to values containing header control characters', async () => {
+ const result = await sendEmail({
+ to: 'test@example.com',
+ subject: 'Test Subject',
+ text: 'Plain text content',
+ replyTo: 'user@example.com\r\nBcc: attacker@evil.com',
+ })
+
+ expect(result.success).toBe(false)
+ expect(result.message).toBe('Failed to send email')
+ expect(mockSend).not.toHaveBeenCalled()
+ })
+
it('should handle multiple recipients as array', async () => {
const recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com']
const result = await sendEmail({
@@ -184,12 +190,10 @@ describe('mailer', () => {
})
expect(result.success).toBe(true)
- // Should use first recipient for unsubscribe check
expect(isUnsubscribed).toHaveBeenCalledWith('user1@example.com', 'marketing')
})
it('should handle general exceptions gracefully', async () => {
- // Mock an unexpected error before any email service call
;(isUnsubscribed as Mock).mockRejectedValue(new Error('Database connection failed'))
const result = await sendEmail({
@@ -222,6 +226,23 @@ describe('mailer', () => {
expect(result.results.length).toBeGreaterThanOrEqual(0)
})
+ it('should sanitize CRLF characters in batch email subjects', async () => {
+ await sendBatchEmails({
+ emails: [
+ {
+ ...testEmailOptions,
+ subject: 'Batch\r\nCc: attacker@evil.com',
+ },
+ ],
+ })
+
+ expect(mockBatchSend).toHaveBeenCalledWith([
+ expect.objectContaining({
+ subject: 'Batch Cc: attacker@evil.com',
+ }),
+ ])
+ })
+
it('should handle transactional emails without unsubscribe check', async () => {
const batchEmails = [
{ ...testEmailOptions, to: 'user1@example.com', emailType: 'transactional' as EmailType },
@@ -230,7 +251,6 @@ describe('mailer', () => {
await sendBatchEmails({ emails: batchEmails })
- // Should not check unsubscribe for transactional emails
expect(isUnsubscribed).not.toHaveBeenCalled()
})
})
diff --git a/apps/sim/lib/messaging/email/mailer.ts b/apps/sim/lib/messaging/email/mailer.ts
index 9d3c7b33378..0a7a8dbc6e7 100644
--- a/apps/sim/lib/messaging/email/mailer.ts
+++ b/apps/sim/lib/messaging/email/mailer.ts
@@ -4,7 +4,7 @@ import { Resend } from 'resend'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'
-import { getFromEmailAddress } from '@/lib/messaging/email/utils'
+import { getFromEmailAddress, hasEmailHeaderControlChars } from '@/lib/messaging/email/utils'
const logger = createLogger('Mailer')
@@ -57,6 +57,17 @@ interface ProcessedEmailData {
replyTo?: string
}
+interface PreparedEmailHeaderData {
+ to: string | string[]
+ subject: string
+ senderEmail: string
+ replyTo?: string
+}
+
+function sanitizeEmailSubject(subject: string): string {
+ return subject.replace(/[\r\n]+/g, ' ').trim()
+}
+
const resendApiKey = env.RESEND_API_KEY
const azureConnectionString = env.AZURE_ACS_CONNECTION_STRING
@@ -172,17 +183,14 @@ function addUnsubscribeData(
async function processEmailData(options: EmailOptions): Promise {
const {
to,
- subject,
html,
text,
- from,
emailType = 'transactional',
includeUnsubscribe = true,
attachments,
- replyTo,
} = options
- const senderEmail = from || getFromEmailAddress()
+ const preparedHeaders = prepareEmailHeaders(options)
let finalHtml = html
let finalText = text
@@ -197,14 +205,43 @@ async function processEmailData(options: EmailOptions): Promise ({
getEmailDomain: vi.fn().mockReturnValue('fallback.com'),
}))
-import { getFromEmailAddress } from './utils'
-
describe('getFromEmailAddress', () => {
it('should return the configured FROM_EMAIL_ADDRESS', () => {
const result = getFromEmailAddress()
@@ -36,7 +40,6 @@ describe('getFromEmailAddress', () => {
it('should contain an @ symbol in the email', () => {
const result = getFromEmailAddress()
- // Either contains @ directly or in angle brackets
expect(result.includes('@')).toBe(true)
})
@@ -46,3 +49,21 @@ describe('getFromEmailAddress', () => {
expect(result1).toBe(result2)
})
})
+
+describe('email header safety', () => {
+ it('rejects CRLF characters consistently', () => {
+ const injectedHeader = 'Hello\r\nBcc: attacker@example.com'
+
+ expect(EMAIL_HEADER_CONTROL_CHARS_REGEX.test(injectedHeader)).toBe(true)
+ expect(hasEmailHeaderControlChars(injectedHeader)).toBe(true)
+ expect(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX.test(injectedHeader)).toBe(false)
+ })
+
+ it('allows plain header content', () => {
+ const safeHeader = 'Product feedback'
+
+ expect(EMAIL_HEADER_CONTROL_CHARS_REGEX.test(safeHeader)).toBe(false)
+ expect(hasEmailHeaderControlChars(safeHeader)).toBe(false)
+ expect(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX.test(safeHeader)).toBe(true)
+ })
+})
diff --git a/apps/sim/lib/messaging/email/utils.ts b/apps/sim/lib/messaging/email/utils.ts
index 2c26737e107..b7fa5060ab7 100644
--- a/apps/sim/lib/messaging/email/utils.ts
+++ b/apps/sim/lib/messaging/email/utils.ts
@@ -1,6 +1,14 @@
import { env } from '@/lib/core/config/env'
import { getEmailDomain } from '@/lib/core/utils/urls'
+export const EMAIL_HEADER_CONTROL_CHARS_REGEX = /[\r\n]/
+
+export const NO_EMAIL_HEADER_CONTROL_CHARS_REGEX = /^[^\r\n]*$/
+
+export function hasEmailHeaderControlChars(value: string): boolean {
+ return EMAIL_HEADER_CONTROL_CHARS_REGEX.test(value)
+}
+
/**
* Get the from email address, preferring FROM_EMAIL_ADDRESS over EMAIL_DOMAIN
*/
diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts
index ddbad423d35..9f6b4387fa8 100755
--- a/scripts/generate-docs.ts
+++ b/scripts/generate-docs.ts
@@ -123,7 +123,7 @@ async function generateIconMapping(): Promise> {
// For icon mapping, we need ALL blocks including hidden ones
// because V2 blocks inherit icons from legacy blocks via spread
// First, extract the primary icon from the file (usually the legacy block's icon)
- const primaryIcon = extractIconName(fileContent)
+ const primaryIcon = extractIconNameFromContent(fileContent)
// Find all block exports and their types
const exportRegex = /export\s+const\s+(\w+)Block\s*:\s*BlockConfig[^=]*=\s*\{/g
@@ -315,6 +315,49 @@ function extractOperationsFromContent(blockContent: string): { label: string; id
return []
}
+/**
+ * Extract a mapping from operation id → tool id by scanning switch/case/return
+ * patterns in a block file. Handles both simple returns and ternary returns
+ * (for ternaries, takes the last quoted tool-like string, which is typically
+ * the default/list variant). Also picks up named helper functions referenced
+ * from tools.config.tool (e.g. selectGmailToolId).
+ */
+function extractSwitchCaseToolMapping(fileContent: string): Map {
+ const mapping = new Map()
+ const caseRegex = /\bcase\s+['"]([^'"]+)['"]\s*:/g
+ let caseMatch: RegExpExecArray | null
+
+ while ((caseMatch = caseRegex.exec(fileContent)) !== null) {
+ const opId = caseMatch[1]
+ if (mapping.has(opId)) continue
+
+ const searchStart = caseMatch.index + caseMatch[0].length
+ const searchEnd = Math.min(searchStart + 300, fileContent.length)
+ const segment = fileContent.substring(searchStart, searchEnd)
+
+ const returnIdx = segment.search(/\breturn\b/)
+ if (returnIdx === -1) continue
+
+ const afterReturn = segment.substring(returnIdx + 'return'.length)
+ // Limit scope to before the next case/default to avoid capturing sibling cases
+ const nextCaseIdx = afterReturn.search(/\bcase\b|\bdefault\b/)
+ const returnScope = nextCaseIdx > 0 ? afterReturn.substring(0, nextCaseIdx) : afterReturn
+
+ const toolMatches = [...returnScope.matchAll(/['"]([a-z][a-z0-9_]+)['"]/g)]
+ // Take the last tool-like string (underscore = tool ID pattern); for ternaries this
+ // is the fallback/list variant
+ const toolId = toolMatches
+ .map((m) => m[1])
+ .filter((id) => id.includes('_'))
+ .pop()
+ if (toolId) {
+ mapping.set(opId, toolId)
+ }
+ }
+
+ return mapping
+}
+
/**
* Scan all tool files under apps/sim/tools/ and build a map from tool ID to description.
* Used to enrich operation entries with descriptions.
@@ -331,7 +374,8 @@ async function buildToolDescriptionMap(): Promise {
try {
const toolFiles = await glob(`${toolsDir}/**/*.ts`)
for (const file of toolFiles) {
- if (file.endsWith('index.ts') || file.endsWith('types.ts')) continue
+ const basename = path.basename(file)
+ if (basename === 'index.ts' || basename === 'types.ts') continue
const content = fs.readFileSync(file, 'utf-8')
// Find every `id: 'tool_id'` occurrence in the file. For each, search
@@ -512,6 +556,7 @@ async function writeIntegrationsJson(iconMapping: Record): Promi
for (const blockFile of blockFiles) {
const fileContent = fs.readFileSync(blockFile, 'utf-8')
+ const switchCaseMap = extractSwitchCaseToolMapping(fileContent)
const configs = extractAllBlockConfigs(fileContent)
for (const config of configs) {
@@ -542,16 +587,33 @@ async function writeIntegrationsJson(iconMapping: Record): Promi
const rawOps: { label: string; id: string }[] = (config as any).operations || []
// Enrich each operation with a description from the tool registry.
- // Primary lookup: derive toolId as `{baseType}_{operationId}` and check
- // the map directly. Fallback: some blocks use short op IDs that don't
- // match tool IDs (e.g. Slack uses "send" while the tool ID is
- // "slack_message"). In that case, find the tool in tools.access whose
- // name exactly matches the operation label.
+ // Lookup order:
+ // 1. Derive toolId as `{baseType}_{operationId}` and check directly.
+ // 2. Check switch/case mapping parsed from tools.config.tool (handles
+ // cases where op IDs differ from tool IDs, e.g. get_carts → list_carts,
+ // or send_gmail → gmail_send).
+ // 3. Find the tool in tools.access whose name exactly matches the label.
const toolsAccess: string[] = (config as any).tools?.access || []
const operations: OperationInfo[] = rawOps.map(({ label, id }) => {
const toolId = `${baseType}_${id}`
let opDesc = toolDescMap.get(toolId) || toolDescMap.get(id) || ''
+ if (!opDesc) {
+ const switchMappedId = switchCaseMap.get(id)
+ if (switchMappedId) {
+ opDesc = toolDescMap.get(switchMappedId) || ''
+ // Also check versioned variants in tools.access (e.g. gmail_send_v2)
+ if (!opDesc) {
+ for (const tId of toolsAccess) {
+ if (tId === switchMappedId || tId.startsWith(`${switchMappedId}_v`)) {
+ opDesc = toolDescMap.get(tId) || ''
+ if (opDesc) break
+ }
+ }
+ }
+ }
+ }
+
if (!opDesc && toolsAccess.length > 0) {
for (const tId of toolsAccess) {
if (toolNameMap.get(tId)?.toLowerCase() === label.toLowerCase()) {
@@ -575,9 +637,7 @@ async function writeIntegrationsJson(iconMapping: Record): Promi
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
- // Detect auth type from the original block file content
- const blockFileContent = fs.readFileSync(blockFile, 'utf-8')
- const authType = extractAuthType(blockFileContent)
+ const authType = extractAuthType(fileContent)
integrations.push({
type: blockType,
@@ -618,7 +678,7 @@ function extractAllBlockConfigs(fileContent: string): BlockConfig[] {
const configs: BlockConfig[] = []
// First, extract the primary icon from the file (for V2 blocks that inherit via spread)
- const primaryIcon = extractIconName(fileContent)
+ const primaryIcon = extractIconNameFromContent(fileContent)
// Find all block exports in the file
const exportRegex = /export\s+const\s+(\w+)Block\s*:\s*BlockConfig[^=]*=\s*\{/g
@@ -770,10 +830,7 @@ function extractBlockConfigFromContent(
extractEnumPropertyFromContent(blockContent, 'integrationType') ||
baseConfig?.integrationType ||
null
- const tags =
- extractArrayPropertyFromContent(blockContent, 'tags') ||
- baseConfig?.tags ||
- null
+ const tags = extractArrayPropertyFromContent(blockContent, 'tags') || baseConfig?.tags || null
return {
type: blockType,
@@ -977,209 +1034,7 @@ function extractOutputsFromContent(content: string): Record {
function extractToolsAccessFromContent(content: string): string[] {
const accessMatch = content.match(/access\s*:\s*\[\s*([^\]]+)\s*\]/)
if (!accessMatch) return []
-
- const accessContent = accessMatch[1]
- const tools: string[] = []
-
- const toolMatches = accessContent.match(/['"]([^'"]+)['"]/g)
- if (toolMatches) {
- toolMatches.forEach((toolText) => {
- const match = toolText.match(/['"]([^'"]+)['"]/)
- if (match) {
- tools.push(match[1])
- }
- })
- }
-
- return tools
-}
-
-// Legacy function for backward compatibility (icon mapping, etc.)
-function extractBlockConfig(fileContent: string): BlockConfig | null {
- const configs = extractAllBlockConfigs(fileContent)
- // Return first non-hidden block for legacy code paths
- return configs.length > 0 ? configs[0] : null
-}
-
-function findBlockType(content: string, blockName: string): string {
- const blockExportRegex = new RegExp(
- `export\\s+const\\s+${blockName}Block\\s*:[^{]*{[\\s\\S]*?type\\s*:\\s*['"]([^'"]+)['"][\\s\\S]*?}`,
- 'i'
- )
- const blockExportMatch = content.match(blockExportRegex)
- if (blockExportMatch) return blockExportMatch[1]
-
- const exportMatch = content.match(new RegExp(`export\\s+const\\s+${blockName}Block\\s*:`))
- if (exportMatch) {
- const afterExport = content.substring(exportMatch.index! + exportMatch[0].length)
-
- const blockStartMatch = afterExport.match(/{/)
- if (blockStartMatch) {
- const blockStart = blockStartMatch.index!
-
- let braceCount = 1
- let blockEnd = blockStart + 1
-
- while (blockEnd < afterExport.length && braceCount > 0) {
- if (afterExport[blockEnd] === '{') braceCount++
- else if (afterExport[blockEnd] === '}') braceCount--
- blockEnd++
- }
-
- const blockContent = afterExport.substring(blockStart, blockEnd)
- const typeMatch = blockContent.match(/type\s*:\s*['"]([^'"]+)['"]/)
- if (typeMatch) return typeMatch[1]
- }
- }
-
- return blockName
- .replace(/([A-Z])/g, '_$1')
- .toLowerCase()
- .replace(/^_/, '')
-}
-
-function extractStringProperty(content: string, propName: string): string | null {
- const singleQuoteMatch = content.match(new RegExp(`${propName}\\s*:\\s*'(.*?)'`, 'm'))
- if (singleQuoteMatch) return singleQuoteMatch[1]
-
- const doubleQuoteMatch = content.match(new RegExp(`${propName}\\s*:\\s*"(.*?)"`, 'm'))
- if (doubleQuoteMatch) return doubleQuoteMatch[1]
-
- const templateMatch = content.match(new RegExp(`${propName}\\s*:\\s*\`([^\`]+)\``, 's'))
- if (templateMatch) {
- let templateContent = templateMatch[1]
-
- templateContent = templateContent.replace(
- /\$\{[^}]*shouldEnableURLInput[^}]*\?[^:]*:[^}]*\}/g,
- 'Upload files directly. '
- )
- templateContent = templateContent.replace(/\$\{[^}]*shouldEnableURLInput[^}]*\}/g, 'false')
-
- templateContent = templateContent.replace(/\$\{[^}]+\}/g, '')
-
- templateContent = templateContent.replace(/\s+/g, ' ').trim()
-
- return templateContent
- }
-
- return null
-}
-
-function extractIconName(content: string): string | null {
- const iconMatch = content.match(/icon\s*:\s*(\w+Icon)/)
- return iconMatch ? iconMatch[1] : null
-}
-
-function extractOutputs(content: string): Record {
- const outputsStart = content.search(/outputs\s*:\s*{/)
- if (outputsStart === -1) return {}
-
- const openBracePos = content.indexOf('{', outputsStart)
- if (openBracePos === -1) return {}
-
- let braceCount = 1
- let pos = openBracePos + 1
-
- while (pos < content.length && braceCount > 0) {
- if (content[pos] === '{') {
- braceCount++
- } else if (content[pos] === '}') {
- braceCount--
- }
- pos++
- }
-
- if (braceCount === 0) {
- const outputsContent = content.substring(openBracePos + 1, pos - 1).trim()
- const outputs: Record = {}
-
- const fieldRegex = /(\w+)\s*:\s*{/g
- let match
- const fieldPositions: Array<{ name: string; start: number }> = []
-
- while ((match = fieldRegex.exec(outputsContent)) !== null) {
- fieldPositions.push({
- name: match[1],
- start: match.index + match[0].length - 1,
- })
- }
-
- fieldPositions.forEach((field) => {
- const startPos = field.start
- let braceCount = 1
- let endPos = startPos + 1
-
- while (endPos < outputsContent.length && braceCount > 0) {
- if (outputsContent[endPos] === '{') {
- braceCount++
- } else if (outputsContent[endPos] === '}') {
- braceCount--
- }
- endPos++
- }
-
- if (braceCount === 0) {
- const fieldContent = outputsContent.substring(startPos + 1, endPos - 1).trim()
-
- const typeMatch = fieldContent.match(/type\s*:\s*['"](.*?)['"]/)
- const description = extractDescription(fieldContent)
-
- if (typeMatch) {
- outputs[field.name] = {
- type: typeMatch[1],
- description: description || `${field.name} output from the block`,
- }
- }
- }
- })
-
- if (Object.keys(outputs).length > 0) {
- return outputs
- }
-
- const flatFieldMatches = outputsContent.match(/(\w+)\s*:\s*['"](.*?)['"]/g)
-
- if (flatFieldMatches && flatFieldMatches.length > 0) {
- flatFieldMatches.forEach((fieldMatch) => {
- const fieldParts = fieldMatch.match(/(\w+)\s*:\s*['"](.*?)['"]/)
- if (fieldParts) {
- const fieldName = fieldParts[1]
- const fieldType = fieldParts[2]
-
- outputs[fieldName] = {
- type: fieldType,
- description: `${fieldName} output from the block`,
- }
- }
- })
-
- if (Object.keys(outputs).length > 0) {
- return outputs
- }
- }
- }
-
- return {}
-}
-
-function extractToolsAccess(content: string): string[] {
- const accessMatch = content.match(/access\s*:\s*\[\s*([^\]]+)\s*\]/)
- if (!accessMatch) return []
-
- const accessContent = accessMatch[1]
- const tools: string[] = []
-
- const toolMatches = accessContent.match(/['"]([^'"]+)['"]/g)
- if (toolMatches) {
- toolMatches.forEach((toolText) => {
- const match = toolText.match(/['"]([^'"]+)['"]/)
- if (match) {
- tools.push(match[1])
- }
- })
- }
-
- return tools
+ return [...accessMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1])
}
/**
From 9d1b9763c56e5bd336d8aa37353aba549f7d34fd Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Wed, 25 Mar 2026 15:45:17 -0700
Subject: [PATCH 11/20] Feat(logs) upgrade mothership chat messages to error
(#3772)
* feat(log): enable info logs in staging and prod
* Upgrade info logs to error for message route
* Add to orchestrator, remove helm shennanigans
* Fix lint
---------
Co-authored-by: Theodore Li
---
apps/sim/app/api/copilot/chat/route.ts | 16 ++++++-------
apps/sim/app/api/copilot/chat/stream/route.ts | 8 +++----
apps/sim/app/api/mothership/chat/route.ts | 2 +-
apps/sim/app/api/v1/copilot/chat/route.ts | 2 +-
apps/sim/lib/copilot/chat-payload.ts | 2 +-
apps/sim/lib/copilot/chat-streaming.ts | 8 +++----
apps/sim/lib/copilot/orchestrator/index.ts | 24 +++++++++----------
.../orchestrator/sse/handlers/handlers.ts | 2 +-
.../sse/handlers/tool-execution.ts | 21 +++++++++-------
9 files changed, 44 insertions(+), 41 deletions(-)
diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts
index 2608a58d090..1c2ac412fed 100644
--- a/apps/sim/app/api/copilot/chat/route.ts
+++ b/apps/sim/app/api/copilot/chat/route.ts
@@ -193,7 +193,7 @@ export async function POST(req: NextRequest) {
const userMessageIdToUse = userMessageId || crypto.randomUUID()
try {
- logger.info(
+ logger.error(
appendCopilotLogContext('Received chat POST', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
@@ -250,7 +250,7 @@ export async function POST(req: NextRequest) {
actualChatId
)
agentContexts = processed
- logger.info(
+ logger.error(
appendCopilotLogContext('Contexts processed for request', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
@@ -357,7 +357,7 @@ export async function POST(req: NextRequest) {
)
try {
- logger.info(
+ logger.error(
appendCopilotLogContext('About to call Sim Agent', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
@@ -555,7 +555,7 @@ export async function POST(req: NextRequest) {
provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined,
}
- logger.info(
+ logger.error(
appendCopilotLogContext('Non-streaming response from orchestrator', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
@@ -600,7 +600,7 @@ export async function POST(req: NextRequest) {
// Start title generation in parallel if this is first message (non-streaming)
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
- logger.info(
+ logger.error(
appendCopilotLogContext('Starting title generation for non-streaming response', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
@@ -616,7 +616,7 @@ export async function POST(req: NextRequest) {
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
- logger.info(
+ logger.error(
appendCopilotLogContext(`Generated and saved title: ${title}`, {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
@@ -645,7 +645,7 @@ export async function POST(req: NextRequest) {
.where(eq(copilotChats.id, actualChatId!))
}
- logger.info(
+ logger.error(
appendCopilotLogContext('Returning non-streaming response', {
requestId: tracker.requestId,
messageId: userMessageIdToUse,
@@ -778,7 +778,7 @@ export async function GET(req: NextRequest) {
...(streamSnapshot ? { streamSnapshot } : {}),
}
- logger.info(
+ logger.error(
appendCopilotLogContext(`Retrieved chat ${chatId}`, {
messageId: chat.conversationId || undefined,
})
diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts
index 851fb642034..c442f72ed18 100644
--- a/apps/sim/app/api/copilot/chat/stream/route.ts
+++ b/apps/sim/app/api/copilot/chat/stream/route.ts
@@ -36,7 +36,7 @@ export async function GET(request: NextRequest) {
const toParam = url.searchParams.get('to')
const toEventId = toParam ? Number(toParam) : undefined
- logger.info(
+ logger.error(
appendCopilotLogContext('[Resume] Received resume request', {
messageId: streamId || undefined,
}),
@@ -53,7 +53,7 @@ export async function GET(request: NextRequest) {
}
const meta = (await getStreamMeta(streamId)) as StreamMeta | null
- logger.info(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), {
+ logger.error(appendCopilotLogContext('[Resume] Stream lookup', { messageId: streamId }), {
streamId,
fromEventId,
toEventId,
@@ -72,7 +72,7 @@ export async function GET(request: NextRequest) {
if (batchMode) {
const events = await readStreamEvents(streamId, fromEventId)
const filteredEvents = toEventId ? events.filter((e) => e.eventId <= toEventId) : events
- logger.info(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), {
+ logger.error(appendCopilotLogContext('[Resume] Batch response', { messageId: streamId }), {
streamId,
fromEventId,
toEventId,
@@ -124,7 +124,7 @@ export async function GET(request: NextRequest) {
const flushEvents = async () => {
const events = await readStreamEvents(streamId, lastEventId)
if (events.length > 0) {
- logger.info(
+ logger.error(
appendCopilotLogContext('[Resume] Flushing events', { messageId: streamId }),
{
streamId,
diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts
index c5fdddba6fb..5e51f4aa4c9 100644
--- a/apps/sim/app/api/mothership/chat/route.ts
+++ b/apps/sim/app/api/mothership/chat/route.ts
@@ -113,7 +113,7 @@ export async function POST(req: NextRequest) {
const userMessageId = providedMessageId || crypto.randomUUID()
userMessageIdForLogs = userMessageId
- logger.info(
+ logger.error(
appendCopilotLogContext('Received mothership chat start request', {
requestId: tracker.requestId,
messageId: userMessageId,
diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts
index 9a2912633b9..a222510bb55 100644
--- a/apps/sim/app/api/v1/copilot/chat/route.ts
+++ b/apps/sim/app/api/v1/copilot/chat/route.ts
@@ -83,7 +83,7 @@ export async function POST(req: NextRequest) {
const chatId = parsed.chatId || crypto.randomUUID()
messageId = crypto.randomUUID()
- logger.info(
+ logger.error(
appendCopilotLogContext('Received headless copilot chat start request', { messageId }),
{
workflowId: resolved.workflowId,
diff --git a/apps/sim/lib/copilot/chat-payload.ts b/apps/sim/lib/copilot/chat-payload.ts
index 783bb1e1eee..81731cf9dff 100644
--- a/apps/sim/lib/copilot/chat-payload.ts
+++ b/apps/sim/lib/copilot/chat-payload.ts
@@ -201,7 +201,7 @@ export async function buildCopilotRequestPayload(
})
}
if (mcpTools.length > 0) {
- logger.info(
+ logger.error(
appendCopilotLogContext('Added MCP tools to copilot payload', {
messageId: userMessageId,
}),
diff --git a/apps/sim/lib/copilot/chat-streaming.ts b/apps/sim/lib/copilot/chat-streaming.ts
index 0d090a892d3..6d090a4866b 100644
--- a/apps/sim/lib/copilot/chat-streaming.ts
+++ b/apps/sim/lib/copilot/chat-streaming.ts
@@ -440,7 +440,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
})
if (abortController.signal.aborted) {
- logger.info(
+ logger.error(
appendCopilotLogContext('Stream aborted by explicit stop', { requestId, messageId })
)
await eventWriter.close().catch(() => {})
@@ -456,7 +456,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
'An unexpected error occurred while processing the response.'
if (clientDisconnected) {
- logger.info(
+ logger.error(
appendCopilotLogContext('Stream failed after client disconnect', {
requestId,
messageId,
@@ -500,7 +500,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
await updateRunStatus(runId, 'complete', { completedAt: new Date() }).catch(() => {})
} catch (error) {
if (abortController.signal.aborted) {
- logger.info(
+ logger.error(
appendCopilotLogContext('Stream aborted by explicit stop', { requestId, messageId })
)
await eventWriter.close().catch(() => {})
@@ -509,7 +509,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
return
}
if (clientDisconnected) {
- logger.info(
+ logger.error(
appendCopilotLogContext('Stream errored after client disconnect', {
requestId,
messageId,
diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts
index 39d4a112037..a695351aa76 100644
--- a/apps/sim/lib/copilot/orchestrator/index.ts
+++ b/apps/sim/lib/copilot/orchestrator/index.ts
@@ -135,7 +135,7 @@ export async function orchestrateCopilotStream(
let claimedToolCallIds: string[] = []
let claimedByWorkerId: string | null = null
- logger.info(withLogContext('Starting copilot orchestration'), {
+ logger.error(withLogContext('Starting copilot orchestration'), {
goRoute,
workflowId,
workspaceId,
@@ -154,7 +154,7 @@ export async function orchestrateCopilotStream(
for (;;) {
context.streamComplete = false
- logger.info(withLogContext('Starting orchestration loop iteration'), {
+ logger.error(withLogContext('Starting orchestration loop iteration'), {
route,
hasPendingAsyncContinuation: Boolean(context.awaitingAsyncContinuation),
claimedToolCallCount: claimedToolCallIds.length,
@@ -167,7 +167,7 @@ export async function orchestrateCopilotStream(
const d = (event.data ?? {}) as Record
const response = (d.response ?? {}) as Record
if (response.async_pause) {
- logger.info(withLogContext('Detected async pause from copilot backend'), {
+ logger.error(withLogContext('Detected async pause from copilot backend'), {
route,
checkpointId:
typeof (response.async_pause as Record)?.checkpointId ===
@@ -200,7 +200,7 @@ export async function orchestrateCopilotStream(
loopOptions
)
- logger.info(withLogContext('Completed orchestration loop iteration'), {
+ logger.error(withLogContext('Completed orchestration loop iteration'), {
route,
streamComplete: context.streamComplete,
wasAborted: context.wasAborted,
@@ -209,7 +209,7 @@ export async function orchestrateCopilotStream(
})
if (claimedToolCallIds.length > 0) {
- logger.info(withLogContext('Marking async tool calls as delivered'), {
+ logger.error(withLogContext('Marking async tool calls as delivered'), {
toolCallIds: claimedToolCallIds,
})
await Promise.all(
@@ -222,7 +222,7 @@ export async function orchestrateCopilotStream(
}
if (options.abortSignal?.aborted || context.wasAborted) {
- logger.info(withLogContext('Stopping orchestration because request was aborted'), {
+ logger.error(withLogContext('Stopping orchestration because request was aborted'), {
pendingToolCallCount: Array.from(context.toolCalls.values()).filter(
(toolCall) => toolCall.status === 'pending' || toolCall.status === 'executing'
).length,
@@ -240,13 +240,13 @@ export async function orchestrateCopilotStream(
const continuation = context.awaitingAsyncContinuation
if (!continuation) {
- logger.info(withLogContext('No async continuation pending; finishing orchestration'))
+ logger.error(withLogContext('No async continuation pending; finishing orchestration'))
break
}
let resumeReady = false
let resumeRetries = 0
- logger.info(withLogContext('Processing async continuation'), {
+ logger.error(withLogContext('Processing async continuation'), {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
pendingToolCallIds: continuation.pendingToolCallIds,
@@ -442,7 +442,7 @@ export async function orchestrateCopilotStream(
}
if (resumeRetries < 3) {
resumeRetries++
- logger.info(withLogContext('Retrying async resume after claim contention'), {
+ logger.error(withLogContext('Retrying async resume after claim contention'), {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
workerId: resumeWorkerId,
@@ -473,7 +473,7 @@ export async function orchestrateCopilotStream(
]
claimedByWorkerId = claimedToolCallIds.length > 0 ? resumeWorkerId : null
- logger.info(withLogContext('Resuming async tool continuation'), {
+ logger.error(withLogContext('Resuming async tool continuation'), {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
workerId: resumeWorkerId,
@@ -539,7 +539,7 @@ export async function orchestrateCopilotStream(
checkpointId: continuation.checkpointId,
results,
}
- logger.info(withLogContext('Prepared async continuation payload for resume endpoint'), {
+ logger.error(withLogContext('Prepared async continuation payload for resume endpoint'), {
route,
checkpointId: continuation.checkpointId,
resultCount: results.length,
@@ -568,7 +568,7 @@ export async function orchestrateCopilotStream(
usage: context.usage,
cost: context.cost,
}
- logger.info(withLogContext('Completing copilot orchestration'), {
+ logger.error(withLogContext('Completing copilot orchestration'), {
success: result.success,
chatId: result.chatId,
hasRequestId: Boolean(result.requestId),
diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts
index a0385e5a42b..0cb6dc452d2 100644
--- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts
+++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts
@@ -309,7 +309,7 @@ export const sseHandlers: Record = {
const rid = typeof event.data === 'string' ? event.data : undefined
if (rid) {
context.requestId = rid
- logger.info(
+ logger.error(
appendCopilotLogContext('Mapped copilot message to Go trace ID', {
messageId: context.messageId,
}),
diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts
index e8442e1c76c..3c5b2208e4c 100644
--- a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts
+++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts
@@ -187,7 +187,7 @@ async function maybeWriteOutputToFile(
contentType
)
- logger.info(
+ logger.error(
appendCopilotLogContext('Tool output written to file', { messageId: context.messageId }),
{
toolName,
@@ -397,7 +397,7 @@ async function maybeWriteOutputToTable(
}
})
- logger.info(
+ logger.error(
appendCopilotLogContext('Tool output written to table', { messageId: context.messageId }),
{
toolName,
@@ -524,7 +524,7 @@ async function maybeWriteReadCsvToTable(
}
})
- logger.info(
+ logger.error(
appendCopilotLogContext('Read output written to table', { messageId: context.messageId }),
{
toolName,
@@ -595,11 +595,14 @@ export async function executeToolAndReport(
toolCall.status = 'executing'
await markAsyncToolRunning(toolCall.id, 'sim-stream').catch(() => {})
- logger.info(appendCopilotLogContext('Tool execution started', { messageId: context.messageId }), {
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- params: toolCall.params,
- })
+ logger.error(
+ appendCopilotLogContext('Tool execution started', { messageId: context.messageId }),
+ {
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ params: toolCall.params,
+ }
+ )
try {
let result = await executeToolServerSide(toolCall, execContext)
@@ -686,7 +689,7 @@ export async function executeToolAndReport(
: raw && typeof raw === 'object'
? JSON.stringify(raw).slice(0, 200)
: undefined
- logger.info(
+ logger.error(
appendCopilotLogContext('Tool execution succeeded', { messageId: context.messageId }),
{
toolCallId: toolCall.id,
From 104ad0300426e480a1359d0b882f6fc9154e5b8c Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 25 Mar 2026 16:04:13 -0700
Subject: [PATCH 12/20] fix(notifications): auto-dismiss info-level workflow
notifications (#3774)
---
.../w/[workflowId]/components/notifications/notifications.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx
index 48a4b43dbd6..fc736e0d026 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx
@@ -26,7 +26,7 @@ const ACTION_LABELS: Record = {
} as const
function isAutoDismissable(n: Notification): boolean {
- return n.level === 'error' && !!n.workflowId
+ return !!n.workflowId
}
function NotificationCountdownRing({ onPause }: { onPause: () => void }) {
@@ -54,7 +54,7 @@ function NotificationCountdownRing({ onPause }: { onPause: () => void }) {
* Positioned in the bottom-right workspace area, reactive to panel width and terminal height.
* Shows both global notifications and workflow-specific notifications.
*
- * Workflow error notifications auto-dismiss after {@link AUTO_DISMISS_MS}ms with a countdown
+ * Workflow-scoped notifications auto-dismiss after {@link AUTO_DISMISS_MS}ms with a countdown
* ring. Clicking the ring pauses all timers until the notification stack clears.
*/
interface NotificationsProps {
From 5a5c33d326f6cb83e6f829f946dbd79059a6a29b Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 25 Mar 2026 17:34:23 -0700
Subject: [PATCH 13/20] fix(client): network drops reconnecting behaviour
(#3775)
* fix(client): network drops reconnecting behaviour
* address bugbot comments
* address comments
* address queued message conflicts during retries
* fix more review comments
* fix branch
* fix non-clear bug
* fix
---
.../mothership-chat/mothership-chat.tsx | 9 +-
.../app/workspace/[workspaceId]/home/home.tsx | 2 +
.../[workspaceId]/home/hooks/use-chat.ts | 754 ++++++++++++++----
.../w/[workflowId]/components/panel/panel.tsx | 2 +
apps/sim/hooks/queries/tasks.ts | 5 +-
apps/sim/lib/copilot/chat-streaming.ts | 47 +-
6 files changed, 674 insertions(+), 145 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index e3d32331d25..abc9e7272bd 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -23,6 +23,7 @@ import type { ChatContext } from '@/stores/panel'
interface MothershipChatProps {
messages: ChatMessage[]
isSending: boolean
+ isReconnecting?: boolean
onSubmit: (
text: string,
fileAttachments?: FileAttachmentForApi[],
@@ -71,6 +72,7 @@ const LAYOUT_STYLES = {
export function MothershipChat({
messages,
isSending,
+ isReconnecting = false,
onSubmit,
onStopGeneration,
messageQueue,
@@ -88,7 +90,8 @@ export function MothershipChat({
className,
}: MothershipChatProps) {
const styles = LAYOUT_STYLES[layout]
- const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
+ const isStreamActive = isSending || isReconnecting
+ const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive)
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)
@@ -131,7 +134,7 @@ export function MothershipChat({
msg.content ?? ''
)
const isLastAssistant = index === messages.length - 1
- const isThisStreaming = isSending && isLastAssistant
+ const isThisStreaming = isStreamActive && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return
@@ -175,7 +178,7 @@ export function MothershipChat({
/>
= {
const DEPLOY_TOOL_NAMES = new Set(['deploy_api', 'deploy_chat', 'deploy_mcp', 'redeploy'])
const RECONNECT_TAIL_ERROR =
'Live reconnect failed before the stream finished. The latest response may be incomplete.'
+const TERMINAL_STREAM_STATUSES = new Set(['complete', 'error', 'cancelled'])
+
+interface StreamEventEnvelope {
+ eventId: number
+ streamId: string
+ event: Record
+}
+
+interface StreamBatchResponse {
+ success: boolean
+ events: StreamEventEnvelope[]
+ status: string
+}
+
+interface StreamTerminationResult {
+ sawStreamError: boolean
+ sawDoneEvent: boolean
+ lastEventId: number
+}
+
+interface StreamProcessingOptions {
+ expectedGen?: number
+ initialLastEventId?: number
+ preserveExistingState?: boolean
+}
+
+interface AttachToStreamOptions {
+ streamId: string
+ assistantId: string
+ expectedGen: number
+ snapshot?: StreamSnapshot | null
+ initialLastEventId?: number
+}
+
+interface AttachToStreamResult {
+ aborted: boolean
+ error: boolean
+}
+
+interface PendingStreamRecovery {
+ streamId: string
+ snapshot?: StreamSnapshot | null
+}
+
+function isTerminalStreamStatus(status?: string | null): boolean {
+ return Boolean(status && TERMINAL_STREAM_STATUSES.has(status))
+}
+
+function isActiveStreamConflictError(input: unknown): boolean {
+ if (typeof input !== 'string') return false
+ return input.includes('A response is already in progress for this chat')
+}
+
+function buildReplayStream(events: StreamEventEnvelope[]): ReadableStream {
+ const encoder = new TextEncoder()
+ return new ReadableStream({
+ start(controller) {
+ if (events.length > 0) {
+ const payload = events
+ .map(
+ (entry) =>
+ `data: ${JSON.stringify({ ...entry.event, eventId: entry.eventId, streamId: entry.streamId })}\n\n`
+ )
+ .join('')
+ controller.enqueue(encoder.encode(payload))
+ }
+ controller.close()
+ },
+ })
+}
function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock {
const mapped: ContentBlock = {
@@ -339,15 +415,22 @@ export function useChat(
const [messageQueue, setMessageQueue] = useState([])
const messageQueueRef = useRef([])
messageQueueRef.current = messageQueue
+ const [pendingRecoveryMessage, setPendingRecoveryMessage] = useState(null)
+ const pendingRecoveryMessageRef = useRef(null)
+ pendingRecoveryMessageRef.current = pendingRecoveryMessage
const sendMessageRef = useRef(async () => {})
const processSSEStreamRef = useRef<
(
reader: ReadableStreamDefaultReader,
assistantId: string,
- expectedGen?: number
- ) => Promise
- >(async () => false)
+ options?: StreamProcessingOptions
+ ) => Promise
+ >(async () => ({
+ sawStreamError: false,
+ sawDoneEvent: false,
+ lastEventId: 0,
+ }))
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
const abortControllerRef = useRef(null)
@@ -359,10 +442,12 @@ export function useChat(
const appliedChatIdRef = useRef(undefined)
const pendingUserMsgRef = useRef<{ id: string; content: string } | null>(null)
const streamIdRef = useRef(undefined)
+ const lastEventIdRef = useRef(0)
const sendingRef = useRef(false)
const streamGenRef = useRef(0)
const streamingContentRef = useRef('')
const streamingBlocksRef = useRef([])
+ const clientExecutionStartedRef = useRef>(new Set())
const executionStream = useExecutionStream()
const isHomePage = pathname.endsWith('/home')
@@ -420,6 +505,10 @@ export function useChat(
abortControllerRef.current = null
sendingRef.current = false
setIsSending(false)
+ setIsReconnecting(false)
+ lastEventIdRef.current = 0
+ pendingRecoveryMessageRef.current = null
+ setPendingRecoveryMessage(null)
if (abandonedChatId) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(abandonedChatId) })
}
@@ -441,6 +530,10 @@ export function useChat(
setStreamingFile(null)
streamingFileRef.current = null
setMessageQueue([])
+ lastEventIdRef.current = 0
+ clientExecutionStartedRef.current.clear()
+ pendingRecoveryMessageRef.current = null
+ setPendingRecoveryMessage(null)
}, [initialChatId, queryClient])
useEffect(() => {
@@ -461,180 +554,374 @@ export function useChat(
setStreamingFile(null)
streamingFileRef.current = null
setMessageQueue([])
+ lastEventIdRef.current = 0
+ clientExecutionStartedRef.current.clear()
+ pendingRecoveryMessageRef.current = null
+ setPendingRecoveryMessage(null)
}, [isHomePage])
- useEffect(() => {
- if (!chatHistory || appliedChatIdRef.current === chatHistory.id) return
+ const fetchStreamBatch = useCallback(
+ async (
+ streamId: string,
+ fromEventId: number,
+ signal?: AbortSignal
+ ): Promise => {
+ const response = await fetch(
+ `${COPILOT_CHAT_STREAM_API_PATH}?streamId=${encodeURIComponent(streamId)}&from=${fromEventId}&batch=true`,
+ { signal }
+ )
+
+ if (!response.ok) {
+ throw new Error(`Stream resume batch failed: ${response.status}`)
+ }
- const activeStreamId = chatHistory.activeStreamId
- const snapshot = chatHistory.streamSnapshot
- appliedChatIdRef.current = chatHistory.id
- const mappedMessages = chatHistory.messages.map(mapStoredMessage)
- const shouldPreserveActiveStreamingMessage =
- sendingRef.current && Boolean(activeStreamId) && activeStreamId === streamIdRef.current
-
- if (shouldPreserveActiveStreamingMessage) {
- setMessages((prev) => {
- const localStreamingAssistant = prev[prev.length - 1]
- if (localStreamingAssistant?.role !== 'assistant') {
- return mappedMessages
- }
+ return response.json()
+ },
+ []
+ )
+
+ const attachToExistingStream = useCallback(
+ async ({
+ streamId,
+ assistantId,
+ expectedGen,
+ snapshot,
+ initialLastEventId = 0,
+ }: AttachToStreamOptions): Promise => {
+ let latestEventId = initialLastEventId
+ let seedEvents = snapshot?.events ?? []
+ let streamStatus = snapshot?.status ?? 'unknown'
+ let attachAttempt = 0
- const nextMessages =
- mappedMessages[mappedMessages.length - 1]?.role === 'assistant'
- ? mappedMessages.slice(0, -1)
- : mappedMessages
+ setIsSending(true)
+ setIsReconnecting(true)
+ setError(null)
- return [...nextMessages, localStreamingAssistant]
+ logger.info('Attaching to existing stream', {
+ streamId,
+ expectedGen,
+ initialLastEventId,
+ seedEventCount: seedEvents.length,
+ streamStatus,
})
- } else {
- setMessages(mappedMessages)
- }
- if (chatHistory.resources.some((r) => r.id === 'streaming-file')) {
- fetch('/api/copilot/chat/resources', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- chatId: chatHistory.id,
- resourceType: 'file',
- resourceId: 'streaming-file',
- }),
- }).catch(() => {})
- }
+ try {
+ while (streamGenRef.current === expectedGen) {
+ if (seedEvents.length > 0) {
+ const replayResult = await processSSEStreamRef.current(
+ buildReplayStream(seedEvents).getReader(),
+ assistantId,
+ {
+ expectedGen,
+ initialLastEventId: latestEventId,
+ preserveExistingState: true,
+ }
+ )
+ latestEventId = Math.max(
+ replayResult.lastEventId,
+ seedEvents[seedEvents.length - 1]?.eventId ?? latestEventId
+ )
+ lastEventIdRef.current = latestEventId
+ seedEvents = []
+
+ if (replayResult.sawStreamError) {
+ logger.warn('Replay stream ended with error event', { streamId, latestEventId })
+ return { aborted: false, error: true }
+ }
+ }
- const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file')
- if (persistedResources.length > 0) {
- setResources(persistedResources)
- setActiveResourceId(persistedResources[persistedResources.length - 1].id)
+ if (isTerminalStreamStatus(streamStatus)) {
+ logger.info('Existing stream already reached terminal status', {
+ streamId,
+ latestEventId,
+ streamStatus,
+ })
+ if (streamStatus === 'error') {
+ setError(RECONNECT_TAIL_ERROR)
+ }
+ return { aborted: false, error: streamStatus === 'error' }
+ }
+
+ const activeAbortController = abortControllerRef.current
+ if (!activeAbortController) {
+ return { aborted: true, error: false }
+ }
+
+ logger.info('Opening live stream tail', {
+ streamId,
+ fromEventId: latestEventId,
+ attempt: attachAttempt,
+ })
- for (const resource of persistedResources) {
- if (resource.type !== 'workflow') continue
- ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
+ const sseRes = await fetch(
+ `${COPILOT_CHAT_STREAM_API_PATH}?streamId=${encodeURIComponent(streamId)}&from=${latestEventId}`,
+ { signal: activeAbortController.signal }
+ )
+ if (!sseRes.ok || !sseRes.body) {
+ throw new Error(RECONNECT_TAIL_ERROR)
+ }
+
+ setIsReconnecting(false)
+
+ const liveResult = await processSSEStreamRef.current(
+ sseRes.body.getReader(),
+ assistantId,
+ {
+ expectedGen,
+ initialLastEventId: latestEventId,
+ preserveExistingState: true,
+ }
+ )
+ latestEventId = Math.max(latestEventId, liveResult.lastEventId)
+ lastEventIdRef.current = latestEventId
+
+ if (liveResult.sawStreamError) {
+ logger.warn('Live stream tail ended with error event', { streamId, latestEventId })
+ return { aborted: false, error: true }
+ }
+
+ attachAttempt += 1
+ setIsReconnecting(true)
+
+ logger.warn('Live stream ended without terminal event, fetching replay batch', {
+ streamId,
+ latestEventId,
+ attempt: attachAttempt,
+ })
+
+ const batch = await fetchStreamBatch(
+ streamId,
+ latestEventId,
+ activeAbortController.signal
+ )
+ seedEvents = batch.events
+ streamStatus = batch.status
+
+ if (batch.events.length > 0) {
+ latestEventId = batch.events[batch.events.length - 1].eventId
+ lastEventIdRef.current = latestEventId
+ }
+
+ logger.info('Fetched replay batch after non-terminal stream close', {
+ streamId,
+ latestEventId,
+ streamStatus,
+ eventCount: batch.events.length,
+ attempt: attachAttempt,
+ })
+
+ if (batch.events.length === 0 && !isTerminalStreamStatus(batch.status)) {
+ logger.info('No new replay events yet; reopening active stream tail', {
+ streamId,
+ latestEventId,
+ streamStatus,
+ attempt: attachAttempt,
+ })
+ if (activeAbortController.signal.aborted || streamGenRef.current !== expectedGen) {
+ return { aborted: true, error: false }
+ }
+ }
+ }
+
+ return { aborted: true, error: false }
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') {
+ return { aborted: true, error: false }
+ }
+
+ logger.warn('Failed to attach to existing stream', {
+ streamId,
+ latestEventId,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ setError(err instanceof Error ? err.message : RECONNECT_TAIL_ERROR)
+ return { aborted: false, error: true }
+ } finally {
+ setIsReconnecting(false)
}
- } else if (chatHistory.resources.some((r) => r.id === 'streaming-file')) {
- setResources([])
- setActiveResourceId(null)
- }
+ },
+ [fetchStreamBatch]
+ )
+
+ const applyChatHistorySnapshot = useCallback(
+ (history: TaskChatHistory, options?: { preserveActiveStreamingMessage?: boolean }) => {
+ const preserveActiveStreamingMessage = options?.preserveActiveStreamingMessage ?? false
+ const activeStreamId = history.activeStreamId
+ appliedChatIdRef.current = history.id
+
+ const mappedMessages = history.messages.map(mapStoredMessage)
+ const shouldPreserveActiveStreamingMessage =
+ preserveActiveStreamingMessage &&
+ sendingRef.current &&
+ Boolean(activeStreamId) &&
+ activeStreamId === streamIdRef.current
+
+ if (shouldPreserveActiveStreamingMessage) {
+ setMessages((prev) => {
+ const localStreamingAssistant = prev[prev.length - 1]
+ if (localStreamingAssistant?.role !== 'assistant') {
+ return mappedMessages
+ }
+
+ const nextMessages =
+ mappedMessages[mappedMessages.length - 1]?.role === 'assistant'
+ ? mappedMessages.slice(0, -1)
+ : mappedMessages
+
+ return [...nextMessages, localStreamingAssistant]
+ })
+ } else {
+ setMessages(mappedMessages)
+ }
+
+ if (history.resources.some((r) => r.id === 'streaming-file')) {
+ fetch('/api/copilot/chat/resources', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ chatId: history.id,
+ resourceType: 'file',
+ resourceId: 'streaming-file',
+ }),
+ }).catch(() => {})
+ }
+
+ const persistedResources = history.resources.filter((r) => r.id !== 'streaming-file')
+ if (persistedResources.length > 0) {
+ setResources(persistedResources)
+ setActiveResourceId(persistedResources[persistedResources.length - 1].id)
+
+ for (const resource of persistedResources) {
+ if (resource.type !== 'workflow') continue
+ ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
+ }
+ } else if (history.resources.some((r) => r.id === 'streaming-file')) {
+ setResources([])
+ setActiveResourceId(null)
+ }
+ },
+ [workspaceId]
+ )
+
+ const preparePendingStreamRecovery = useCallback(
+ async (chatId: string): Promise => {
+ const latestHistory = await fetchChatHistory(chatId)
+ queryClient.setQueryData(taskKeys.detail(chatId), latestHistory)
+ applyChatHistorySnapshot(latestHistory)
+
+ if (!latestHistory.activeStreamId) {
+ return null
+ }
+
+ return {
+ streamId: latestHistory.activeStreamId,
+ snapshot: latestHistory.streamSnapshot,
+ }
+ },
+ [applyChatHistorySnapshot, queryClient]
+ )
+
+ useEffect(() => {
+ if (!chatHistory || appliedChatIdRef.current === chatHistory.id) return
+
+ const activeStreamId = chatHistory.activeStreamId
+ const snapshot = chatHistory.streamSnapshot
+ applyChatHistorySnapshot(chatHistory, { preserveActiveStreamingMessage: true })
if (activeStreamId && !sendingRef.current) {
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
streamIdRef.current = activeStreamId
+ lastEventIdRef.current = snapshot?.events?.[snapshot.events.length - 1]?.eventId ?? 0
sendingRef.current = true
- setIsReconnecting(true)
+ streamingContentRef.current = ''
+ streamingBlocksRef.current = []
+ clientExecutionStartedRef.current.clear()
const assistantId = crypto.randomUUID()
const reconnect = async () => {
- let reconnectFailed = false
try {
- const encoder = new TextEncoder()
-
- const batchEvents = snapshot?.events ?? []
- const streamStatus = snapshot?.status ?? ''
-
- if (batchEvents.length === 0 && streamStatus === 'unknown') {
- reconnectFailed = true
- setError(RECONNECT_TAIL_ERROR)
- return
- }
-
- setIsSending(true)
- setIsReconnecting(false)
-
- const lastEventId =
- batchEvents.length > 0 ? batchEvents[batchEvents.length - 1].eventId : 0
- const isStreamDone =
- streamStatus === 'complete' || streamStatus === 'error' || streamStatus === 'cancelled'
-
- const combinedStream = new ReadableStream({
- async start(controller) {
- if (batchEvents.length > 0) {
- const sseText = batchEvents
- .map((e) => `data: ${JSON.stringify(e.event)}\n`)
- .join('\n')
- controller.enqueue(encoder.encode(`${sseText}\n`))
- }
-
- if (!isStreamDone) {
- try {
- const sseRes = await fetch(
- `/api/copilot/chat/stream?streamId=${activeStreamId}&from=${lastEventId}`,
- { signal: abortController.signal }
- )
- if (!sseRes.ok || !sseRes.body) {
- reconnectFailed = true
- logger.warn('SSE tail reconnect returned no readable body', {
- status: sseRes.status,
- streamId: activeStreamId,
- })
- setError(RECONNECT_TAIL_ERROR)
- } else {
- const reader = sseRes.body.getReader()
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- controller.enqueue(value)
- }
- }
- } catch (err) {
- if (!(err instanceof Error && err.name === 'AbortError')) {
- reconnectFailed = true
- logger.warn('SSE tail failed during reconnect', err)
- setError(RECONNECT_TAIL_ERROR)
- }
- }
- }
-
- controller.close()
- },
- })
-
- const hadStreamError = await processSSEStreamRef.current(
- combinedStream.getReader(),
+ const result = await attachToExistingStream({
+ streamId: activeStreamId,
assistantId,
- gen
- )
- if (hadStreamError) {
- reconnectFailed = true
+ expectedGen: gen,
+ snapshot,
+ initialLastEventId: lastEventIdRef.current,
+ })
+ if (streamGenRef.current === gen && !result.aborted) {
+ finalizeRef.current(result.error ? { error: true } : undefined)
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
- reconnectFailed = true
- } finally {
- setIsReconnecting(false)
+ logger.warn('Unexpected error during reconnect', {
+ streamId: activeStreamId,
+ chatId: chatHistory.id,
+ error: err instanceof Error ? err.message : String(err),
+ })
if (streamGenRef.current === gen) {
- finalizeRef.current(reconnectFailed ? { error: true } : undefined)
+ try {
+ finalizeRef.current({ error: true })
+ } catch (finalizeError) {
+ logger.error('Reconnect fallback finalize failed', {
+ streamId: activeStreamId,
+ chatId: chatHistory.id,
+ error:
+ finalizeError instanceof Error ? finalizeError.message : String(finalizeError),
+ })
+ sendingRef.current = false
+ setIsSending(false)
+ setIsReconnecting(false)
+ abortControllerRef.current = null
+ setError('Failed to reconnect to the active stream')
+ }
+ }
+ } finally {
+ if (abortControllerRef.current === abortController) {
+ abortControllerRef.current = null
}
}
}
reconnect()
}
- }, [chatHistory, workspaceId, queryClient])
+ }, [applyChatHistorySnapshot, attachToExistingStream, chatHistory, queryClient])
const processSSEStream = useCallback(
async (
reader: ReadableStreamDefaultReader,
assistantId: string,
- expectedGen?: number
+ options?: StreamProcessingOptions
) => {
+ const { expectedGen, initialLastEventId = 0, preserveExistingState = false } = options ?? {}
const decoder = new TextDecoder()
streamReaderRef.current = reader
let buffer = ''
- const blocks: ContentBlock[] = []
+ const blocks: ContentBlock[] = preserveExistingState ? [...streamingBlocksRef.current] : []
const toolMap = new Map()
const toolArgsMap = new Map>()
- const clientExecutionStarted = new Set()
+ const clientExecutionStarted = clientExecutionStartedRef.current
let activeSubagent: string | undefined
let activeCompactionId: string | undefined
- let runningText = ''
+ let runningText = preserveExistingState ? streamingContentRef.current : ''
let lastContentSource: 'main' | 'subagent' | null = null
let streamRequestId: string | undefined
+ let lastEventId = initialLastEventId
+ let sawDoneEvent = false
- streamingContentRef.current = ''
- streamingBlocksRef.current = []
+ if (!preserveExistingState) {
+ streamingContentRef.current = ''
+ streamingBlocksRef.current = []
+ }
+
+ for (const [index, block] of blocks.entries()) {
+ if (block.type === 'tool_call' && block.toolCall?.id) {
+ toolMap.set(block.toolCall.id, index)
+ if (block.toolCall.params) {
+ toolArgsMap.set(block.toolCall.id, block.toolCall.params)
+ }
+ }
+ }
const ensureTextBlock = (): ContentBlock => {
const last = blocks[blocks.length - 1]
@@ -716,6 +1003,14 @@ export function useChat(
continue
}
+ if (typeof (parsed as SSEPayload & { eventId?: unknown }).eventId === 'number') {
+ lastEventId = Math.max(
+ lastEventId,
+ (parsed as SSEPayload & { eventId: number }).eventId
+ )
+ lastEventIdRef.current = lastEventId
+ }
+
logger.debug('SSE event received', parsed)
switch (parsed.type) {
case 'chat_id': {
@@ -1167,6 +1462,10 @@ export function useChat(
appendInlineErrorTag(buildInlineErrorTag(parsed))
break
}
+ case 'done': {
+ sawDoneEvent = true
+ break
+ }
}
}
}
@@ -1175,7 +1474,11 @@ export function useChat(
streamReaderRef.current = null
}
}
- return sawStreamError
+ return {
+ sawStreamError,
+ sawDoneEvent,
+ lastEventId,
+ }
},
[workspaceId, queryClient, addResource, removeResource]
)
@@ -1247,6 +1550,16 @@ export function useChat(
const messagesRef = useRef(messages)
messagesRef.current = messages
+ const visibleMessageQueue = useMemo(
+ () =>
+ pendingRecoveryMessage
+ ? [
+ pendingRecoveryMessage,
+ ...messageQueue.filter((msg) => msg.id !== pendingRecoveryMessage.id),
+ ]
+ : messageQueue,
+ [messageQueue, pendingRecoveryMessage]
+ )
const finalize = useCallback(
(options?: { error?: boolean }) => {
@@ -1267,6 +1580,21 @@ export function useChat(
return
}
+ const recoveryMessage = pendingRecoveryMessageRef.current
+ if (recoveryMessage) {
+ setPendingRecoveryMessage(null)
+ const gen = streamGenRef.current
+ queueMicrotask(() => {
+ if (streamGenRef.current !== gen) return
+ sendMessageRef.current(
+ recoveryMessage.content,
+ recoveryMessage.fileAttachments,
+ recoveryMessage.contexts
+ )
+ })
+ return
+ }
+
const next = messageQueueRef.current[0]
if (next) {
setMessageQueue((prev) => prev.filter((m) => m.id !== next.id))
@@ -1307,6 +1635,8 @@ export function useChat(
pendingUserMsgRef.current = { id: userMessageId, content: message }
streamIdRef.current = userMessageId
+ lastEventIdRef.current = 0
+ clientExecutionStartedRef.current.clear()
const storedAttachments: TaskStoredFileAttachment[] | undefined =
fileAttachments && fileAttachments.length > 0
@@ -1320,6 +1650,9 @@ export function useChat(
: undefined
const requestChatId = selectedChatIdRef.current ?? chatIdRef.current
+ const previousChatHistory = requestChatId
+ ? queryClient.getQueryData(taskKeys.detail(requestChatId))
+ : undefined
if (requestChatId) {
const cachedUserMsg: TaskStoredMessage = {
id: userMessageId,
@@ -1339,6 +1672,7 @@ export function useChat(
}
const userAttachments = storedAttachments?.map(toDisplayAttachment)
+ const previousMessages = messagesRef.current
const messageContexts = contexts?.map((c) => ({
kind: c.kind,
@@ -1402,20 +1736,132 @@ export function useChat(
if (!response.body) throw new Error('No response body')
- const hadStreamError = await processSSEStream(response.body.getReader(), assistantId, gen)
+ const termination = await processSSEStream(response.body.getReader(), assistantId, {
+ expectedGen: gen,
+ })
if (streamGenRef.current === gen) {
- finalize(hadStreamError ? { error: true } : undefined)
+ if (termination.sawStreamError) {
+ finalize({ error: true })
+ return
+ }
+
+ const batch = await fetchStreamBatch(
+ userMessageId,
+ termination.lastEventId,
+ abortController.signal
+ )
+ if (streamGenRef.current !== gen) {
+ return
+ }
+ if (isTerminalStreamStatus(batch.status)) {
+ finalize(batch.status === 'error' ? { error: true } : undefined)
+ return
+ }
+
+ logger.warn(
+ 'Primary stream ended without terminal event, attempting in-place reconnect',
+ {
+ streamId: userMessageId,
+ lastEventId: termination.lastEventId,
+ streamStatus: batch.status,
+ sawDoneEvent: termination.sawDoneEvent,
+ }
+ )
+
+ const reconnectResult = await attachToExistingStream({
+ streamId: userMessageId,
+ assistantId,
+ expectedGen: gen,
+ snapshot: {
+ events: batch.events,
+ status: batch.status,
+ },
+ initialLastEventId:
+ batch.events[batch.events.length - 1]?.eventId ?? termination.lastEventId,
+ })
+
+ if (streamGenRef.current === gen && !reconnectResult.aborted) {
+ finalize(reconnectResult.error ? { error: true } : undefined)
+ }
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
- setError(err instanceof Error ? err.message : 'Failed to send message')
+ const errorMessage = err instanceof Error ? err.message : 'Failed to send message'
+ if (requestChatId && isActiveStreamConflictError(errorMessage)) {
+ logger.info('Active stream conflict detected while sending message; reattaching', {
+ chatId: requestChatId,
+ attemptedStreamId: userMessageId,
+ })
+
+ if (previousChatHistory) {
+ queryClient.setQueryData(taskKeys.detail(requestChatId), previousChatHistory)
+ }
+ setMessages(previousMessages)
+ const queuedMessage: QueuedMessage = {
+ id: crypto.randomUUID(),
+ content: message,
+ fileAttachments,
+ contexts,
+ }
+ pendingRecoveryMessageRef.current = queuedMessage
+ setPendingRecoveryMessage(queuedMessage)
+
+ try {
+ const pendingRecovery = await preparePendingStreamRecovery(requestChatId)
+ if (!pendingRecovery) {
+ setError(errorMessage)
+ if (streamGenRef.current === gen) {
+ finalize({ error: true })
+ }
+ return
+ }
+
+ streamIdRef.current = pendingRecovery.streamId
+ lastEventIdRef.current =
+ pendingRecovery.snapshot?.events?.[pendingRecovery.snapshot.events.length - 1]
+ ?.eventId ?? 0
+
+ const rehydratedMessages = messagesRef.current
+ const lastAssistantMsg = [...rehydratedMessages]
+ .reverse()
+ .find((m) => m.role === 'assistant')
+ const recoveryAssistantId = lastAssistantMsg?.id ?? assistantId
+
+ const reconnectResult = await attachToExistingStream({
+ streamId: pendingRecovery.streamId,
+ assistantId: recoveryAssistantId,
+ expectedGen: gen,
+ snapshot: pendingRecovery.snapshot,
+ initialLastEventId: lastEventIdRef.current,
+ })
+
+ if (streamGenRef.current === gen && !reconnectResult.aborted) {
+ finalize(reconnectResult.error ? { error: true } : undefined)
+ }
+ return
+ } catch (recoveryError) {
+ logger.warn('Failed to recover active stream after conflict', {
+ chatId: requestChatId,
+ error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
+ })
+ }
+ }
+
+ setError(errorMessage)
if (streamGenRef.current === gen) {
finalize({ error: true })
}
return
}
},
- [workspaceId, queryClient, processSSEStream, finalize]
+ [
+ workspaceId,
+ queryClient,
+ processSSEStream,
+ finalize,
+ attachToExistingStream,
+ preparePendingStreamRecovery,
+ ]
)
sendMessageRef.current = sendMessage
@@ -1434,6 +1880,10 @@ export function useChat(
abortControllerRef.current = null
sendingRef.current = false
setIsSending(false)
+ setIsReconnecting(false)
+ lastEventIdRef.current = 0
+ pendingRecoveryMessageRef.current = null
+ setPendingRecoveryMessage(null)
setMessages((prev) =>
prev.map((msg) => {
@@ -1521,24 +1971,47 @@ export function useChat(
}, [invalidateChatQueries, persistPartialResponse, executionStream])
const removeFromQueue = useCallback((id: string) => {
+ if (pendingRecoveryMessageRef.current?.id === id) {
+ pendingRecoveryMessageRef.current = null
+ setPendingRecoveryMessage(null)
+ return
+ }
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
setMessageQueue((prev) => prev.filter((m) => m.id !== id))
}, [])
const sendNow = useCallback(
async (id: string) => {
- const msg = messageQueueRef.current.find((m) => m.id === id)
+ const recoveryMessage = pendingRecoveryMessageRef.current
+ const msg =
+ recoveryMessage?.id === id
+ ? recoveryMessage
+ : messageQueueRef.current.find((m) => m.id === id)
if (!msg) return
// Eagerly update ref so a rapid second click finds the message already gone
- messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
+ if (recoveryMessage?.id === id) {
+ pendingRecoveryMessageRef.current = null
+ setPendingRecoveryMessage(null)
+ } else {
+ messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
+ }
await stopGeneration()
- setMessageQueue((prev) => prev.filter((m) => m.id !== id))
+ if (recoveryMessage?.id !== id) {
+ setMessageQueue((prev) => prev.filter((m) => m.id !== id))
+ }
await sendMessage(msg.content, msg.fileAttachments, msg.contexts)
},
[stopGeneration, sendMessage]
)
const editQueuedMessage = useCallback((id: string): QueuedMessage | undefined => {
+ const recoveryMessage = pendingRecoveryMessageRef.current
+ if (recoveryMessage?.id === id) {
+ pendingRecoveryMessageRef.current = null
+ setPendingRecoveryMessage(null)
+ return recoveryMessage
+ }
+
const msg = messageQueueRef.current.find((m) => m.id === id)
if (!msg) return undefined
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
@@ -1552,6 +2025,9 @@ export function useChat(
abortControllerRef.current = null
streamGenRef.current++
sendingRef.current = false
+ lastEventIdRef.current = 0
+ clientExecutionStartedRef.current.clear()
+ pendingRecoveryMessageRef.current = null
}
}, [])
@@ -1569,7 +2045,7 @@ export function useChat(
addResource,
removeResource,
reorderResources,
- messageQueue,
+ messageQueue: visibleMessageQueue,
removeFromQueue,
sendNow,
editQueuedMessage,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
index f5a65ab0760..0ea9cb6c51a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
@@ -318,6 +318,7 @@ export const Panel = memo(function Panel() {
const {
messages: copilotMessages,
isSending: copilotIsSending,
+ isReconnecting: copilotIsReconnecting,
sendMessage: copilotSendMessage,
stopGeneration: copilotStopGeneration,
resolvedChatId: copilotResolvedChatId,
@@ -812,6 +813,7 @@ export const Panel = memo(function Panel() {
className='min-h-0 flex-1'
messages={copilotMessages}
isSending={copilotIsSending}
+ isReconnecting={copilotIsReconnecting}
onSubmit={handleCopilotSubmit}
onStopGeneration={copilotStopGeneration}
messageQueue={copilotMessageQueue}
diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts
index 8bc3c10c536..6e071b60588 100644
--- a/apps/sim/hooks/queries/tasks.ts
+++ b/apps/sim/hooks/queries/tasks.ts
@@ -129,7 +129,10 @@ export function useTasks(workspaceId?: string) {
})
}
-async function fetchChatHistory(chatId: string, signal?: AbortSignal): Promise {
+export async function fetchChatHistory(
+ chatId: string,
+ signal?: AbortSignal
+): Promise {
const response = await fetch(`/api/copilot/chat?chatId=${chatId}`, { signal })
if (!response.ok) {
diff --git a/apps/sim/lib/copilot/chat-streaming.ts b/apps/sim/lib/copilot/chat-streaming.ts
index 6d090a4866b..b3d465667d6 100644
--- a/apps/sim/lib/copilot/chat-streaming.ts
+++ b/apps/sim/lib/copilot/chat-streaming.ts
@@ -294,6 +294,21 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
return new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
+ const markClientDisconnected = (reason: string) => {
+ if (clientDisconnected) return
+ clientDisconnected = true
+ logger.info(
+ appendCopilotLogContext('Client disconnected from live SSE stream', {
+ requestId,
+ messageId,
+ }),
+ {
+ streamId,
+ runId,
+ reason,
+ }
+ )
+ }
await resetStreamBuffer(streamId)
await setStreamMeta(streamId, { status: 'active', userId, executionId, runId })
@@ -381,7 +396,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
)
}
} catch {
- clientDisconnected = true
+ markClientDisconnected('enqueue_failed')
}
}
@@ -424,7 +439,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
try {
controller.enqueue(encoder.encode(': keepalive\n\n'))
} catch {
- clientDisconnected = true
+ markClientDisconnected('keepalive_failed')
}
}, 15_000)
@@ -498,6 +513,18 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
await eventWriter.close()
await setStreamMeta(streamId, { status: 'complete', userId, executionId, runId })
await updateRunStatus(runId, 'complete', { completedAt: new Date() }).catch(() => {})
+ if (clientDisconnected) {
+ logger.info(
+ appendCopilotLogContext('Orchestration completed after client disconnect', {
+ requestId,
+ messageId,
+ }),
+ {
+ streamId,
+ runId,
+ }
+ )
+ }
} catch (error) {
if (abortController.signal.aborted) {
logger.error(
@@ -544,6 +571,12 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
error: errorMessage,
}).catch(() => {})
} finally {
+ logger.info(appendCopilotLogContext('Closing live SSE stream', { requestId, messageId }), {
+ streamId,
+ runId,
+ clientDisconnected,
+ aborted: abortController.signal.aborted,
+ })
clearInterval(keepaliveInterval)
if (abortPoller) {
clearInterval(abortPoller)
@@ -566,6 +599,16 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
}
},
cancel() {
+ logger.info(
+ appendCopilotLogContext('ReadableStream cancel received from client', {
+ requestId,
+ messageId,
+ }),
+ {
+ streamId,
+ runId,
+ }
+ )
clientDisconnected = true
if (eventWriter) {
eventWriter.flush().catch(() => {})
From 794d5eab5ea1c8c6ec30a85656b6069790987d58 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 25 Mar 2026 19:39:14 -0700
Subject: [PATCH 14/20] fix(explicit-user-abort): separate explicit user abort
semantics (#3776)
* fix(explicit-user-abort): separate explicit user abort semantics
* address comments
* more
---
.../[workspaceId]/home/hooks/use-chat.ts | 143 ++++++++++++------
apps/sim/lib/copilot/chat-streaming.ts | 21 ++-
apps/sim/lib/copilot/orchestrator/index.ts | 1 +
.../sse/handlers/handlers.test.ts | 10 +-
.../sse/handlers/tool-execution.ts | 10 +-
.../orchestrator/tool-executor/index.ts | 1 +
apps/sim/lib/copilot/orchestrator/types.ts | 4 +
.../sim/lib/copilot/tools/server/base-tool.ts | 4 +-
8 files changed, 139 insertions(+), 55 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index f2a41c89b96..5f21e42cc4b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -92,6 +92,9 @@ const DEPLOY_TOOL_NAMES = new Set(['deploy_api', 'deploy_chat', 'deploy_mcp', 'r
const RECONNECT_TAIL_ERROR =
'Live reconnect failed before the stream finished. The latest response may be incomplete.'
const TERMINAL_STREAM_STATUSES = new Set(['complete', 'error', 'cancelled'])
+const MAX_RECONNECT_ATTEMPTS = 10
+const RECONNECT_BASE_DELAY_MS = 1000
+const RECONNECT_MAX_DELAY_MS = 30_000
interface StreamEventEnvelope {
eventId: number
@@ -1565,6 +1568,7 @@ export function useChat(
(options?: { error?: boolean }) => {
sendingRef.current = false
setIsSending(false)
+ setIsReconnecting(false)
abortControllerRef.current = null
invalidateChatQueries()
@@ -1609,6 +1613,47 @@ export function useChat(
)
finalizeRef.current = finalize
+ const resumeOrFinalize = useCallback(
+ async (opts: {
+ streamId: string
+ assistantId: string
+ gen: number
+ fromEventId: number
+ snapshot?: StreamSnapshot | null
+ signal?: AbortSignal
+ }): Promise => {
+ const { streamId, assistantId, gen, fromEventId, snapshot, signal } = opts
+
+ const batch =
+ snapshot ??
+ (await (async () => {
+ const b = await fetchStreamBatch(streamId, fromEventId, signal)
+ if (streamGenRef.current !== gen) return null
+ return { events: b.events, status: b.status } as StreamSnapshot
+ })())
+
+ if (!batch || streamGenRef.current !== gen) return
+
+ if (isTerminalStreamStatus(batch.status)) {
+ finalize(batch.status === 'error' ? { error: true } : undefined)
+ return
+ }
+
+ const reconnectResult = await attachToExistingStream({
+ streamId,
+ assistantId,
+ expectedGen: gen,
+ snapshot: batch,
+ initialLastEventId: batch.events[batch.events.length - 1]?.eventId ?? fromEventId,
+ })
+
+ if (streamGenRef.current === gen && !reconnectResult.aborted) {
+ finalize(reconnectResult.error ? { error: true } : undefined)
+ }
+ },
+ [fetchStreamBatch, attachToExistingStream, finalize]
+ )
+
const sendMessage = useCallback(
async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
if (!message.trim() || !workspaceId) return
@@ -1745,44 +1790,13 @@ export function useChat(
return
}
- const batch = await fetchStreamBatch(
- userMessageId,
- termination.lastEventId,
- abortController.signal
- )
- if (streamGenRef.current !== gen) {
- return
- }
- if (isTerminalStreamStatus(batch.status)) {
- finalize(batch.status === 'error' ? { error: true } : undefined)
- return
- }
-
- logger.warn(
- 'Primary stream ended without terminal event, attempting in-place reconnect',
- {
- streamId: userMessageId,
- lastEventId: termination.lastEventId,
- streamStatus: batch.status,
- sawDoneEvent: termination.sawDoneEvent,
- }
- )
-
- const reconnectResult = await attachToExistingStream({
+ await resumeOrFinalize({
streamId: userMessageId,
assistantId,
- expectedGen: gen,
- snapshot: {
- events: batch.events,
- status: batch.status,
- },
- initialLastEventId:
- batch.events[batch.events.length - 1]?.eventId ?? termination.lastEventId,
+ gen,
+ fromEventId: termination.lastEventId,
+ signal: abortController.signal,
})
-
- if (streamGenRef.current === gen && !reconnectResult.aborted) {
- finalize(reconnectResult.error ? { error: true } : undefined)
- }
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
@@ -1827,17 +1841,13 @@ export function useChat(
.find((m) => m.role === 'assistant')
const recoveryAssistantId = lastAssistantMsg?.id ?? assistantId
- const reconnectResult = await attachToExistingStream({
+ await resumeOrFinalize({
streamId: pendingRecovery.streamId,
assistantId: recoveryAssistantId,
- expectedGen: gen,
+ gen,
+ fromEventId: lastEventIdRef.current,
snapshot: pendingRecovery.snapshot,
- initialLastEventId: lastEventIdRef.current,
})
-
- if (streamGenRef.current === gen && !reconnectResult.aborted) {
- finalize(reconnectResult.error ? { error: true } : undefined)
- }
return
} catch (recoveryError) {
logger.warn('Failed to recover active stream after conflict', {
@@ -1847,6 +1857,53 @@ export function useChat(
}
}
+ const activeStreamId = streamIdRef.current
+ if (activeStreamId && streamGenRef.current === gen) {
+ for (let attempt = 0; attempt < MAX_RECONNECT_ATTEMPTS; attempt++) {
+ if (streamGenRef.current !== gen) return
+ if (abortControllerRef.current?.signal.aborted) return
+
+ const delayMs = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS)
+ logger.info('Reconnect attempt after network error', {
+ streamId: activeStreamId,
+ attempt: attempt + 1,
+ maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ delayMs,
+ error: errorMessage,
+ })
+
+ setIsReconnecting(true)
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
+
+ if (streamGenRef.current !== gen) return
+ if (abortControllerRef.current?.signal.aborted) return
+
+ try {
+ await resumeOrFinalize({
+ streamId: activeStreamId,
+ assistantId,
+ gen,
+ fromEventId: lastEventIdRef.current,
+ signal: abortController.signal,
+ })
+ return
+ } catch (reconnectErr) {
+ if (reconnectErr instanceof Error && reconnectErr.name === 'AbortError') return
+ logger.warn('Reconnect attempt failed', {
+ streamId: activeStreamId,
+ attempt: attempt + 1,
+ error: reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr),
+ })
+ }
+ }
+
+ logger.error('All reconnect attempts exhausted', {
+ streamId: activeStreamId,
+ maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ })
+ setIsReconnecting(false)
+ }
+
setError(errorMessage)
if (streamGenRef.current === gen) {
finalize({ error: true })
@@ -1859,7 +1916,7 @@ export function useChat(
queryClient,
processSSEStream,
finalize,
- attachToExistingStream,
+ resumeOrFinalize,
preparePendingStreamRecovery,
]
)
diff --git a/apps/sim/lib/copilot/chat-streaming.ts b/apps/sim/lib/copilot/chat-streaming.ts
index b3d465667d6..46d9ff758fc 100644
--- a/apps/sim/lib/copilot/chat-streaming.ts
+++ b/apps/sim/lib/copilot/chat-streaming.ts
@@ -23,9 +23,12 @@ const CHAT_STREAM_LOCK_TTL_SECONDS = 2 * 60 * 60
const STREAM_ABORT_TTL_SECONDS = 10 * 60
const STREAM_ABORT_POLL_MS = 1000
-// Registry of in-flight Sim→Go streams so the explicit abort endpoint can
-// reach them. Keyed by streamId, cleaned up when the stream completes.
-const activeStreams = new Map()
+interface ActiveStreamEntry {
+ abortController: AbortController
+ userStopController: AbortController
+}
+
+const activeStreams = new Map()
// Tracks in-flight streams by chatId so that a subsequent request for the
// same chat can force-abort the previous stream and wait for it to settle
@@ -184,9 +187,10 @@ export async function abortActiveStream(streamId: string): Promise {
})
}
}
- const controller = activeStreams.get(streamId)
- if (!controller) return published
- controller.abort()
+ const entry = activeStreams.get(streamId)
+ if (!entry) return published
+ entry.userStopController.abort()
+ entry.abortController.abort()
activeStreams.delete(streamId)
return true
}
@@ -285,7 +289,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
let eventWriter: ReturnType | null = null
let clientDisconnected = false
const abortController = new AbortController()
- activeStreams.set(streamId, abortController)
+ const userStopController = new AbortController()
+ activeStreams.set(streamId, { abortController, userStopController })
if (chatId && !pendingChatStreamAlreadyRegistered) {
registerPendingChatStream(chatId, streamId)
@@ -348,6 +353,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
try {
const shouldAbort = await redis.get(getStreamAbortKey(streamId))
if (shouldAbort && !abortController.signal.aborted) {
+ userStopController.abort()
abortController.abort()
await redis.del(getStreamAbortKey(streamId))
}
@@ -449,6 +455,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
executionId,
runId,
abortSignal: abortController.signal,
+ userStopSignal: userStopController.signal,
onEvent: async (event) => {
await pushEvent(event)
},
diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts
index a695351aa76..d07553ca645 100644
--- a/apps/sim/lib/copilot/orchestrator/index.ts
+++ b/apps/sim/lib/copilot/orchestrator/index.ts
@@ -116,6 +116,7 @@ export async function orchestrateCopilotStream(
execContext.executionId = executionId
execContext.runId = runId
execContext.abortSignal = options.abortSignal
+ execContext.userStopSignal = options.userStopSignal
const payloadMsgId = requestPayload?.messageId
const messageId = typeof payloadMsgId === 'string' ? payloadMsgId : crypto.randomUUID()
diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts
index 98c4952ba33..84099340c83 100644
--- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts
+++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts
@@ -120,7 +120,9 @@ describe('sse-handlers tool lifecycle', () => {
it('marks an in-flight tool as cancelled when aborted mid-execution', async () => {
const abortController = new AbortController()
+ const userStopController = new AbortController()
execContext.abortSignal = abortController.signal
+ execContext.userStopSignal = userStopController.signal
executeToolServerSide.mockImplementationOnce(
() =>
@@ -137,9 +139,15 @@ describe('sse-handlers tool lifecycle', () => {
} as any,
context,
execContext,
- { interactive: false, timeout: 1000, abortSignal: abortController.signal }
+ {
+ interactive: false,
+ timeout: 1000,
+ abortSignal: abortController.signal,
+ userStopSignal: userStopController.signal,
+ }
)
+ userStopController.abort()
abortController.abort()
await new Promise((resolve) => setTimeout(resolve, 10))
diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts
index 3c5b2208e4c..48bcfe729f0 100644
--- a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts
+++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts
@@ -240,9 +240,13 @@ function abortRequested(
execContext: ExecutionContext,
options?: OrchestratorOptions
): boolean {
- return Boolean(
- options?.abortSignal?.aborted || execContext.abortSignal?.aborted || context.wasAborted
- )
+ if (options?.userStopSignal?.aborted || execContext.userStopSignal?.aborted) {
+ return true
+ }
+ if (context.wasAborted) {
+ return true
+ }
+ return false
}
function cancelledCompletion(message: string): AsyncToolCompletion {
diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts
index 85778ed36cf..aa29e862bc6 100644
--- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts
+++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts
@@ -1269,6 +1269,7 @@ async function executeServerToolDirect(
chatId: context.chatId,
messageId: context.messageId,
abortSignal: context.abortSignal,
+ userStopSignal: context.userStopSignal,
})
const resultRecord =
diff --git a/apps/sim/lib/copilot/orchestrator/types.ts b/apps/sim/lib/copilot/orchestrator/types.ts
index 791f6632373..18978eda207 100644
--- a/apps/sim/lib/copilot/orchestrator/types.ts
+++ b/apps/sim/lib/copilot/orchestrator/types.ts
@@ -164,6 +164,8 @@ export interface OrchestratorOptions {
onComplete?: (result: OrchestratorResult) => void | Promise
onError?: (error: Error) => void | Promise
abortSignal?: AbortSignal
+ /** Fires only on explicit user stop, never on passive transport disconnect. */
+ userStopSignal?: AbortSignal
interactive?: boolean
}
@@ -199,6 +201,8 @@ export interface ExecutionContext {
executionId?: string
runId?: string
abortSignal?: AbortSignal
+ /** Fires only on explicit user stop, never on passive transport disconnect. */
+ userStopSignal?: AbortSignal
userTimezone?: string
userPermission?: string
decryptedEnvVars?: Record
diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts
index 1eca5a063e6..e898785c5d2 100644
--- a/apps/sim/lib/copilot/tools/server/base-tool.ts
+++ b/apps/sim/lib/copilot/tools/server/base-tool.ts
@@ -7,13 +7,15 @@ export interface ServerToolContext {
chatId?: string
messageId?: string
abortSignal?: AbortSignal
+ /** Fires only on explicit user stop, never on passive transport disconnect. */
+ userStopSignal?: AbortSignal
}
export function assertServerToolNotAborted(
context?: ServerToolContext,
message = 'Request aborted before tool mutation could be applied.'
): void {
- if (context?.abortSignal?.aborted) {
+ if (context?.userStopSignal?.aborted) {
throw new Error(message)
}
}
From 7b96b0e8e8d6484bd4e19cbcc8edadd06860d275 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 25 Mar 2026 20:05:23 -0700
Subject: [PATCH 15/20] add logs
---
.../[workspaceId]/home/hooks/use-chat.ts | 22 +++++++++++++++----
1 file changed, 18 insertions(+), 4 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 5f21e42cc4b..05fe1df81b1 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -1799,8 +1799,19 @@ export function useChat(
})
}
} catch (err) {
- if (err instanceof Error && err.name === 'AbortError') return
+ if (err instanceof Error && err.name === 'AbortError') {
+ logger.error('Send aborted (AbortError)', { streamId: userMessageId })
+ return
+ }
const errorMessage = err instanceof Error ? err.message : 'Failed to send message'
+ logger.error('Send catch block entered', {
+ streamId: userMessageId,
+ errorMessage,
+ errorName: err instanceof Error ? err.name : 'unknown',
+ gen,
+ currentGen: streamGenRef.current,
+ hasActiveStreamId: Boolean(streamIdRef.current),
+ })
if (requestChatId && isActiveStreamConflictError(errorMessage)) {
logger.info('Active stream conflict detected while sending message; reattaching', {
chatId: requestChatId,
@@ -1859,9 +1870,12 @@ export function useChat(
const activeStreamId = streamIdRef.current
if (activeStreamId && streamGenRef.current === gen) {
+ const reconnectController = new AbortController()
+ abortControllerRef.current = reconnectController
+
for (let attempt = 0; attempt < MAX_RECONNECT_ATTEMPTS; attempt++) {
if (streamGenRef.current !== gen) return
- if (abortControllerRef.current?.signal.aborted) return
+ if (reconnectController.signal.aborted) return
const delayMs = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS)
logger.info('Reconnect attempt after network error', {
@@ -1876,7 +1890,7 @@ export function useChat(
await new Promise((resolve) => setTimeout(resolve, delayMs))
if (streamGenRef.current !== gen) return
- if (abortControllerRef.current?.signal.aborted) return
+ if (reconnectController.signal.aborted) return
try {
await resumeOrFinalize({
@@ -1884,7 +1898,7 @@ export function useChat(
assistantId,
gen,
fromEventId: lastEventIdRef.current,
- signal: abortController.signal,
+ signal: reconnectController.signal,
})
return
} catch (reconnectErr) {
From 7583c8fbf4dc62a5276dd2e5c64e25ef31ffacf6 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 25 Mar 2026 20:06:15 -0700
Subject: [PATCH 16/20] feat(misc): skills import, MCP modal, workmark,
dispatch modals, collapsed tasks and workflows manipulation, README (#3777)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: skills import, MCP modal updates, wordmark icon, tool-input improvements
- Add skills import functionality (route + components + utils)
- Update MCP deploy modal
- Add Wordmark emcn icon + logo SVG assets
- Improve tool-input component
- Update README branding to new wordmark
- Add ban-spam-accounts admin script
* fix: resolve build error and audit findings from simplify review
- Add BUILT_IN_TOOL_TYPES export to blocks/utils.ts (was removed from
tool-input.tsx but never added to the new import target — caused build
error "Export BUILT_IN_TOOL_TYPES doesn't exist in target module")
- Export Wordmark from emcn icons barrel (index.ts)
- Derive isDragging from dragCounter in skill-import.tsx instead of
maintaining redundant state that could desync
- Replace manual AbortController/setTimeout with AbortSignal.timeout()
in skills import API route (Node 17.3+ supported, cleaner no-cleanup)
- Use useId() for SVG gradient ID in wordmark.tsx to prevent duplicate
ID collisions if rendered multiple times on the same page
* fix(scripts): fix docs mismatch and N+1 query in ban-spam-accounts
- Fix comment: default pattern is @vapu.xyz, not @sharebot.net
- Replace per-user stats loop with a single aggregated JOIN query
* feat: wire wordmark into sidebar, fix credential selector modal dispatch
- Show Wordmark (icon + text) in the expanded sidebar instead of the
bare Sim icon; collapsed state keeps the small Sim icon unchanged
- Untrack scripts/ban-spam-accounts.ts (gitignored; one-off script)
- Credential selector: open OAuthRequiredModal inline instead of
navigating to Settings → Integrations (matches MCP/tool-input pattern)
- Credential selector: update billing import from getSubscriptionAccessState
to getSubscriptionStatus; drop writePendingCredentialCreateRequest and
useSettingsNavigation dependencies
* feat(misc): misc UX/UI improvements
* more random fixes
* more random fixes
* fix: address PR review findings from cursor bugbot
- settings-sidebar: use getSubscriptionAccessState instead of getSubscriptionStatus
so billingBlocked and status validity are checked; add requiresMax gating so
max-plan-only nav items (inbox) are hidden for lower-tier users
- credential-selector: same getSubscriptionAccessState migration for credential sets
visibility check
- mothership chats PATCH: change else if to if for isUnread so both title and
isUnread can be updated in a single request
- skills import: check Content-Length header before reading response body to avoid
loading oversized files into memory
* fix(skills): add ZIP file size guard before extraction
Checks file.size > 5 MB before calling extractSkillFromZip to prevent
zip bombs from exhausting browser memory at the client-side upload path.
* feat(settings-sidebar): show locked upsell items with plan badge
Sim Mailer (requiresMax) and Email Polling (requiresTeam) now always
appear in the settings sidebar when billing is enabled and the
deployment is hosted. If the user lacks the required plan they see a
small MAX / TEAM badge next to the label and are taken to the page
which already contains the upgrade prompt.
Enterprise (Access Control, SSO) and Team management stay hard-hidden
for lower tiers. Admin/superuser items stay truly hidden.
* fix(settings-sidebar): remove flex-1 from label span to fix text centering
* feat(settings-sidebar): remove team gate from email polling, keep only mailer max gate
* feat(subscription): billing details layout and Enterprise card improvements
- Move Enterprise plan card into the plan grid (auto-fit columns) instead
of a separate standalone section below billing details
- Refactor billing details section: remove outer border/background,
separate each row with top border + padding for cleaner separation
- Update button variants: Add Credits → active, Invoices → active
* fix(mothership): prevent lastSeenAt conflict when both title and isUnread are patched together
Co-Authored-By: Claude Sonnet 4.6
* fix(sidebar): prevent double-save race in flyout inline rename on Enter+blur
Co-Authored-By: Claude Sonnet 4.6
* fix(skills): normalize CRLF line endings before parsing SKILL.md frontmatter
Co-Authored-By: Claude Sonnet 4.6
---------
Co-authored-by: Claude Sonnet 4.6
---
README.md | 63 +--
apps/sim/app/_styles/globals.css | 12 +
apps/sim/app/api/copilot/chat/rename/route.ts | 65 ---
.../api/mothership/chats/[chatId]/route.ts | 217 ++++++++
.../app/api/mothership/chats/read/route.ts | 43 --
apps/sim/app/api/skills/import/route.ts | 107 ++++
.../components/conversation-list-item.tsx | 16 +-
.../mothership-chat/mothership-chat.tsx | 2 +-
.../app/workspace/[workspaceId]/home/home.tsx | 2 +-
.../integrations/integrations-manager.tsx | 473 +++++++++++-------
.../skills/components/skill-import.tsx | 283 +++++++++++
.../skills/components/skill-modal.tsx | 227 +++++----
.../skills/components/utils.test.ts | 191 +++++++
.../components/skills/components/utils.ts | 111 ++++
.../credit-balance/credit-balance.tsx | 2 +-
.../components/subscription/subscription.tsx | 57 +--
.../[workspaceId]/settings/navigation.ts | 4 +-
.../deploy-modal/components/mcp/mcp.tsx | 42 +-
.../components/connect-credential-modal.tsx | 211 ++++++++
.../components/oauth-required-modal.tsx | 2 +-
.../credential-selector.tsx | 72 +--
.../components/tools/credential-selector.tsx | 77 ++-
.../components/tool-input/tool-input.tsx | 41 +-
.../collapsed-sidebar-menu.tsx | 335 ++++++++++++-
.../w/components/sidebar/components/index.ts | 2 +
.../settings-sidebar/settings-sidebar.tsx | 69 +--
.../components/context-menu/context-menu.tsx | 72 ++-
.../w/components/sidebar/hooks/index.ts | 1 +
.../sidebar/hooks/use-flyout-inline-rename.ts | 101 ++++
.../sidebar/hooks/use-hover-menu.ts | 63 ++-
.../w/components/sidebar/sidebar.tsx | 412 ++++++++++-----
apps/sim/blocks/blocks/hubspot.ts | 12 +-
apps/sim/blocks/utils.ts | 23 +
apps/sim/components/emcn/icons/index.ts | 1 +
apps/sim/components/emcn/icons/wordmark.tsx | 58 +++
apps/sim/hooks/queries/credentials.ts | 32 +-
apps/sim/hooks/queries/tasks.ts | 56 ++-
apps/sim/public/logo/wordmark-dark.svg | 37 ++
apps/sim/public/logo/wordmark.svg | 37 ++
39 files changed, 2824 insertions(+), 807 deletions(-)
delete mode 100644 apps/sim/app/api/copilot/chat/rename/route.ts
create mode 100644 apps/sim/app/api/mothership/chats/[chatId]/route.ts
delete mode 100644 apps/sim/app/api/mothership/chats/read/route.ts
create mode 100644 apps/sim/app/api/skills/import/route.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.test.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename.ts
create mode 100644 apps/sim/components/emcn/icons/wordmark.tsx
create mode 100644 apps/sim/public/logo/wordmark-dark.svg
create mode 100644 apps/sim/public/logo/wordmark.svg
diff --git a/README.md b/README.md
index 17e2ad1ae50..6738087611d 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,20 @@
-
+
+
+
+
+
The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.
-
+
-
+
@@ -42,7 +46,7 @@ Upload documents to a vector store and let agents answer questions grounded in y
### Cloud-hosted: [sim.ai](https://sim.ai)
-
+
### Self-hosted: NPM Package
@@ -70,43 +74,7 @@ docker compose -f docker-compose.prod.yml up -d
Open [http://localhost:3000](http://localhost:3000)
-#### Using Local Models with Ollama
-
-Run Sim with local AI models using [Ollama](https://ollama.ai) - no external APIs required:
-
-```bash
-# Start with GPU support (automatically downloads gemma3:4b model)
-docker compose -f docker-compose.ollama.yml --profile setup up -d
-
-# For CPU-only systems:
-docker compose -f docker-compose.ollama.yml --profile cpu --profile setup up -d
-```
-
-Wait for the model to download, then visit [http://localhost:3000](http://localhost:3000). Add more models with:
-```bash
-docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
-```
-
-#### Using an External Ollama Instance
-
-If Ollama is running on your host machine, use `host.docker.internal` instead of `localhost`:
-
-```bash
-OLLAMA_URL=http://host.docker.internal:11434 docker compose -f docker-compose.prod.yml up -d
-```
-
-On Linux, use your host's IP address or add `extra_hosts: ["host.docker.internal:host-gateway"]` to the compose file.
-
-#### Using vLLM
-
-Sim supports [vLLM](https://docs.vllm.ai/) for self-hosted models. Set `VLLM_BASE_URL` and optionally `VLLM_API_KEY` in your environment.
-
-### Self-hosted: Dev Containers
-
-1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
-2. Open the project and click "Reopen in Container" when prompted
-3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
- - This starts both the main application and the realtime socket server
+Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
### Self-hosted: Manual Setup
@@ -159,18 +127,7 @@ Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
## Environment Variables
-Key environment variables for self-hosted deployments. See [`.env.example`](apps/sim/.env.example) for defaults or [`env.ts`](apps/sim/lib/core/config/env.ts) for the full list.
-
-| Variable | Required | Description |
-|----------|----------|-------------|
-| `DATABASE_URL` | Yes | PostgreSQL connection string with pgvector |
-| `BETTER_AUTH_SECRET` | Yes | Auth secret (`openssl rand -hex 32`) |
-| `BETTER_AUTH_URL` | Yes | Your app URL (e.g., `http://localhost:3000`) |
-| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL (same as above) |
-| `ENCRYPTION_KEY` | Yes | Encrypts environment variables (`openssl rand -hex 32`) |
-| `INTERNAL_API_SECRET` | Yes | Encrypts internal API routes (`openssl rand -hex 32`) |
-| `API_ENCRYPTION_KEY` | Yes | Encrypts API keys (`openssl rand -hex 32`) |
-| `COPILOT_API_KEY` | No | API key from sim.ai for Copilot features |
+See the [environment variables reference](https://docs.sim.ai/self-hosting/environment-variables) for the full list, or [`apps/sim/.env.example`](apps/sim/.env.example) for defaults.
## Tech Stack
diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index 6512d7212f1..b8bd485ee75 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -54,11 +54,23 @@ html[data-sidebar-collapsed] .sidebar-container .text-small {
transition: opacity 60ms ease;
}
+.sidebar-container .sidebar-collapse-show {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease-out;
+}
+
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
opacity: 0;
}
+.sidebar-container[data-collapsed] .sidebar-collapse-show,
+html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-show {
+ opacity: 1;
+ pointer-events: auto;
+}
+
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
display: none;
}
diff --git a/apps/sim/app/api/copilot/chat/rename/route.ts b/apps/sim/app/api/copilot/chat/rename/route.ts
deleted file mode 100644
index 3dedbea5cba..00000000000
--- a/apps/sim/app/api/copilot/chat/rename/route.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { db } from '@sim/db'
-import { copilotChats } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { and, eq } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { z } from 'zod'
-import { getSession } from '@/lib/auth'
-import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
-import { taskPubSub } from '@/lib/copilot/task-events'
-
-const logger = createLogger('RenameChatAPI')
-
-const RenameChatSchema = z.object({
- chatId: z.string().min(1),
- title: z.string().min(1).max(200),
-})
-
-export async function PATCH(request: NextRequest) {
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
- }
-
- const body = await request.json()
- const { chatId, title } = RenameChatSchema.parse(body)
-
- const chat = await getAccessibleCopilotChat(chatId, session.user.id)
- if (!chat) {
- return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
- }
-
- const now = new Date()
- const [updated] = await db
- .update(copilotChats)
- .set({ title, updatedAt: now, lastSeenAt: now })
- .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
- .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId })
-
- if (!updated) {
- return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
- }
-
- logger.info('Chat renamed', { chatId, title })
-
- if (updated.workspaceId) {
- taskPubSub?.publishStatusChanged({
- workspaceId: updated.workspaceId,
- chatId,
- type: 'renamed',
- })
- }
-
- return NextResponse.json({ success: true })
- } catch (error) {
- if (error instanceof z.ZodError) {
- return NextResponse.json(
- { success: false, error: 'Invalid request data', details: error.errors },
- { status: 400 }
- )
- }
- logger.error('Error renaming chat:', error)
- return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 })
- }
-}
diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts
new file mode 100644
index 00000000000..b51b24a4ace
--- /dev/null
+++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts
@@ -0,0 +1,217 @@
+import { db } from '@sim/db'
+import { copilotChats } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq, sql } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
+import { appendCopilotLogContext } from '@/lib/copilot/logging'
+import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
+import {
+ authenticateCopilotRequestSessionOnly,
+ createBadRequestResponse,
+ createInternalServerErrorResponse,
+ createUnauthorizedResponse,
+} from '@/lib/copilot/request-helpers'
+import { taskPubSub } from '@/lib/copilot/task-events'
+
+const logger = createLogger('MothershipChatAPI')
+
+const UpdateChatSchema = z
+ .object({
+ title: z.string().trim().min(1).max(200).optional(),
+ isUnread: z.boolean().optional(),
+ })
+ .refine((data) => data.title !== undefined || data.isUnread !== undefined, {
+ message: 'At least one field must be provided',
+ })
+
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ chatId: string }> }
+) {
+ try {
+ const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ if (!isAuthenticated || !userId) {
+ return createUnauthorizedResponse()
+ }
+
+ const { chatId } = await params
+ if (!chatId) {
+ return createBadRequestResponse('chatId is required')
+ }
+
+ const chat = await getAccessibleCopilotChat(chatId, userId)
+ if (!chat || chat.type !== 'mothership') {
+ return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
+ }
+
+ let streamSnapshot: {
+ events: Array<{ eventId: number; streamId: string; event: Record }>
+ status: string
+ } | null = null
+
+ if (chat.conversationId) {
+ try {
+ const [meta, events] = await Promise.all([
+ getStreamMeta(chat.conversationId),
+ readStreamEvents(chat.conversationId, 0),
+ ])
+
+ streamSnapshot = {
+ events: events || [],
+ status: meta?.status || 'unknown',
+ }
+ } catch (error) {
+ logger.warn(
+ appendCopilotLogContext('Failed to read stream snapshot for mothership chat', {
+ messageId: chat.conversationId || undefined,
+ }),
+ {
+ chatId,
+ conversationId: chat.conversationId,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ )
+ }
+ }
+
+ return NextResponse.json({
+ success: true,
+ chat: {
+ id: chat.id,
+ title: chat.title,
+ messages: Array.isArray(chat.messages) ? chat.messages : [],
+ conversationId: chat.conversationId || null,
+ resources: Array.isArray(chat.resources) ? chat.resources : [],
+ createdAt: chat.createdAt,
+ updatedAt: chat.updatedAt,
+ ...(streamSnapshot ? { streamSnapshot } : {}),
+ },
+ })
+ } catch (error) {
+ logger.error('Error fetching mothership chat:', error)
+ return createInternalServerErrorResponse('Failed to fetch chat')
+ }
+}
+
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: Promise<{ chatId: string }> }
+) {
+ try {
+ const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ if (!isAuthenticated || !userId) {
+ return createUnauthorizedResponse()
+ }
+
+ const { chatId } = await params
+ if (!chatId) {
+ return createBadRequestResponse('chatId is required')
+ }
+
+ const body = await request.json()
+ const { title, isUnread } = UpdateChatSchema.parse(body)
+
+ const updates: Record = {}
+
+ if (title !== undefined) {
+ const now = new Date()
+ updates.title = title
+ updates.updatedAt = now
+ if (isUnread === undefined) {
+ updates.lastSeenAt = now
+ }
+ }
+ if (isUnread !== undefined) {
+ updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())`
+ }
+
+ const [updatedChat] = await db
+ .update(copilotChats)
+ .set(updates)
+ .where(
+ and(
+ eq(copilotChats.id, chatId),
+ eq(copilotChats.userId, userId),
+ eq(copilotChats.type, 'mothership')
+ )
+ )
+ .returning({
+ id: copilotChats.id,
+ workspaceId: copilotChats.workspaceId,
+ })
+
+ if (!updatedChat) {
+ return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
+ }
+
+ if (title !== undefined && updatedChat.workspaceId) {
+ taskPubSub?.publishStatusChanged({
+ workspaceId: updatedChat.workspaceId,
+ chatId,
+ type: 'renamed',
+ })
+ }
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return createBadRequestResponse('Invalid request data')
+ }
+ logger.error('Error updating mothership chat:', error)
+ return createInternalServerErrorResponse('Failed to update chat')
+ }
+}
+
+export async function DELETE(
+ _request: NextRequest,
+ { params }: { params: Promise<{ chatId: string }> }
+) {
+ try {
+ const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
+ if (!isAuthenticated || !userId) {
+ return createUnauthorizedResponse()
+ }
+
+ const { chatId } = await params
+ if (!chatId) {
+ return createBadRequestResponse('chatId is required')
+ }
+
+ const chat = await getAccessibleCopilotChat(chatId, userId)
+ if (!chat || chat.type !== 'mothership') {
+ return NextResponse.json({ success: true })
+ }
+
+ const [deletedChat] = await db
+ .delete(copilotChats)
+ .where(
+ and(
+ eq(copilotChats.id, chatId),
+ eq(copilotChats.userId, userId),
+ eq(copilotChats.type, 'mothership')
+ )
+ )
+ .returning({
+ workspaceId: copilotChats.workspaceId,
+ })
+
+ if (!deletedChat) {
+ return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
+ }
+
+ if (deletedChat.workspaceId) {
+ taskPubSub?.publishStatusChanged({
+ workspaceId: deletedChat.workspaceId,
+ chatId,
+ type: 'deleted',
+ })
+ }
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error('Error deleting mothership chat:', error)
+ return createInternalServerErrorResponse('Failed to delete chat')
+ }
+}
diff --git a/apps/sim/app/api/mothership/chats/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts
deleted file mode 100644
index e75ffd28d36..00000000000
--- a/apps/sim/app/api/mothership/chats/read/route.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { db } from '@sim/db'
-import { copilotChats } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { and, eq, sql } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { z } from 'zod'
-import {
- authenticateCopilotRequestSessionOnly,
- createBadRequestResponse,
- createInternalServerErrorResponse,
- createUnauthorizedResponse,
-} from '@/lib/copilot/request-helpers'
-
-const logger = createLogger('MarkTaskReadAPI')
-
-const MarkReadSchema = z.object({
- chatId: z.string().min(1),
-})
-
-export async function POST(request: NextRequest) {
- try {
- const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
- if (!isAuthenticated || !userId) {
- return createUnauthorizedResponse()
- }
-
- const body = await request.json()
- const { chatId } = MarkReadSchema.parse(body)
-
- await db
- .update(copilotChats)
- .set({ lastSeenAt: sql`GREATEST(${copilotChats.updatedAt}, NOW())` })
- .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
-
- return NextResponse.json({ success: true })
- } catch (error) {
- if (error instanceof z.ZodError) {
- return createBadRequestResponse('chatId is required')
- }
- logger.error('Error marking task as read:', error)
- return createInternalServerErrorResponse('Failed to mark task as read')
- }
-}
diff --git a/apps/sim/app/api/skills/import/route.ts b/apps/sim/app/api/skills/import/route.ts
new file mode 100644
index 00000000000..9cbc6e32290
--- /dev/null
+++ b/apps/sim/app/api/skills/import/route.ts
@@ -0,0 +1,107 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+
+const logger = createLogger('SkillsImportAPI')
+
+const FETCH_TIMEOUT_MS = 15_000
+
+const ImportSchema = z.object({
+ url: z.string().url('A valid URL is required'),
+})
+
+/**
+ * Converts a standard GitHub file URL to its raw.githubusercontent.com equivalent.
+ *
+ * Supported formats:
+ * github.com/{owner}/{repo}/blob/{branch}/{path}
+ * raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} (passthrough)
+ */
+function toRawGitHubUrl(url: string): string {
+ const parsed = new URL(url)
+
+ if (parsed.hostname === 'raw.githubusercontent.com') {
+ return url
+ }
+
+ if (parsed.hostname !== 'github.com') {
+ throw new Error('Only GitHub URLs are supported')
+ }
+
+ const segments = parsed.pathname.split('/').filter(Boolean)
+ if (segments.length < 5 || segments[2] !== 'blob') {
+ throw new Error(
+ 'Invalid GitHub URL format. Expected: https://github.com/{owner}/{repo}/blob/{branch}/{path}'
+ )
+ }
+
+ const [owner, repo, , branch, ...pathParts] = segments
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathParts.join('/')}`
+}
+
+/** POST - Fetch a SKILL.md from a GitHub URL and return its raw content */
+export async function POST(req: NextRequest) {
+ const requestId = generateRequestId()
+
+ try {
+ const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
+ if (!authResult.success || !authResult.userId) {
+ logger.warn(`[${requestId}] Unauthorized skill import attempt`)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const body = await req.json()
+ const { url } = ImportSchema.parse(body)
+
+ let rawUrl: string
+ try {
+ rawUrl = toRawGitHubUrl(url)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Invalid URL'
+ return NextResponse.json({ error: message }, { status: 400 })
+ }
+
+ const response = await fetch(rawUrl, {
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+ headers: { Accept: 'text/plain' },
+ })
+
+ if (!response.ok) {
+ logger.warn(`[${requestId}] GitHub fetch failed`, {
+ status: response.status,
+ url: rawUrl,
+ })
+ return NextResponse.json(
+ { error: `Failed to fetch file (HTTP ${response.status}). Is the repository public?` },
+ { status: 502 }
+ )
+ }
+
+ const contentLength = response.headers.get('content-length')
+ if (contentLength && Number.parseInt(contentLength, 10) > 100_000) {
+ return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
+ }
+
+ const content = await response.text()
+
+ if (content.length > 100_000) {
+ return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
+ }
+
+ return NextResponse.json({ content })
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
+ }
+
+ if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
+ logger.warn(`[${requestId}] GitHub fetch timed out`)
+ return NextResponse.json({ error: 'Request timed out' }, { status: 504 })
+ }
+
+ logger.error(`[${requestId}] Error importing skill`, error)
+ return NextResponse.json({ error: 'Failed to import skill' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx
index 9c3c10fd675..af9bc4f612f 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx
@@ -8,6 +8,7 @@ interface ConversationListItemProps {
isUnread?: boolean
className?: string
titleClassName?: string
+ statusIndicatorClassName?: string
actions?: ReactNode
}
@@ -17,6 +18,7 @@ export function ConversationListItem({
isUnread = false,
className,
titleClassName,
+ statusIndicatorClassName,
actions,
}: ConversationListItemProps) {
return (
@@ -24,10 +26,20 @@ export function ConversationListItem({
{isActive && (
-
+
)}
{!isActive && isUnread && (
-
+
)}
{title}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index abc9e7272bd..237d08d9ba5 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -48,7 +48,7 @@ interface MothershipChatProps {
const LAYOUT_STYLES = {
'mothership-view': {
scrollContainer:
- 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]',
+ 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]',
content: 'mx-auto max-w-[42rem] space-y-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
index ee8eaf6126c..0c6b7d6acd5 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
@@ -299,7 +299,7 @@ export function Home({ chatId }: HomeProps = {}) {
if (!hasMessages && !chatId) {
return (
-
+
(null)
const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('')
const [selectedDisplayNameDraft, setSelectedDisplayNameDraft] = useState('')
- const [showCreateOAuthRequiredModal, setShowCreateOAuthRequiredModal] = useState(false)
+ const [createStep, setCreateStep] = useState<1 | 2>(1)
+ const [serviceSearch, setServiceSearch] = useState('')
const [copyIdSuccess, setCopyIdSuccess] = useState(false)
const [credentialToDelete, setCredentialToDelete] = useState(null)
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
@@ -125,6 +123,7 @@ export function IntegrationsManager() {
selectedCredential?.id
)
+ const createDraft = useCreateCredentialDraft()
const createCredential = useCreateWorkspaceCredential()
const updateCredential = useUpdateWorkspaceCredential()
const deleteCredential = useDeleteWorkspaceCredential()
@@ -155,12 +154,18 @@ export function IntegrationsManager() {
const sortedCredentials = useMemo(() => {
return [...filteredCredentials].sort((a, b) => {
- const aDate = new Date(a.updatedAt).getTime()
- const bDate = new Date(b.updatedAt).getTime()
- return bDate - aDate
+ const aProvider = a.providerId || ''
+ const bProvider = b.providerId || ''
+ return aProvider.localeCompare(bProvider)
})
}, [filteredCredentials])
+ const filteredAvailableIntegrations = useMemo(() => {
+ if (!searchTerm.trim()) return oauthConnections
+ const normalized = searchTerm.toLowerCase()
+ return oauthConnections.filter((service) => service.name.toLowerCase().includes(normalized))
+ }, [oauthConnections, searchTerm])
+
const oauthServiceOptions = useMemo(
() =>
oauthConnections.map((service) => ({
@@ -202,6 +207,14 @@ export function IntegrationsManager() {
return getCanonicalScopesForProvider(createOAuthProviderId)
}, [selectedOAuthService, createOAuthProviderId])
+ const createDisplayScopes = useMemo(
+ () =>
+ createOAuthRequiredScopes.filter(
+ (s) => !s.includes('userinfo.email') && !s.includes('userinfo.profile')
+ ),
+ [createOAuthRequiredScopes]
+ )
+
const existingOAuthDisplayName = useMemo(() => {
const name = createDisplayName.trim()
if (!name) return null
@@ -237,6 +250,8 @@ export function IntegrationsManager() {
...(isDisplayNameDirty ? { displayName: selectedDisplayNameDraft.trim() } : {}),
...(isDescriptionDirty ? { description: selectedDescriptionDraft.trim() || null } : {}),
})
+ if (isDisplayNameDirty) setSelectedDisplayNameDraft((v) => v.trim())
+ if (isDescriptionDirty) setSelectedDescriptionDraft((v) => v.trim())
}
await refetchCredentials()
@@ -254,15 +269,17 @@ export function IntegrationsManager() {
setShowUnsavedChangesAlert(true)
} else {
setSelectedCredentialId(null)
+ setSelectedDescriptionDraft('')
+ setSelectedDisplayNameDraft('')
}
}, [isDetailsDirty, isSavingDetails])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
- setSelectedDescriptionDraft(selectedCredential?.description || '')
- setSelectedDisplayNameDraft(selectedCredential?.displayName || '')
+ setSelectedDescriptionDraft('')
+ setSelectedDisplayNameDraft('')
setSelectedCredentialId(null)
- }, [selectedCredential])
+ }, [])
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
@@ -290,7 +307,6 @@ export function IntegrationsManager() {
pendingReturnOriginRef.current = request.returnOrigin
setShowCreateModal(true)
- setShowCreateOAuthRequiredModal(false)
setCreateError(null)
setCreateDescription('')
setCreateOAuthProviderId(request.providerId)
@@ -330,18 +346,6 @@ export function IntegrationsManager() {
}
}, [workspaceId, applyPendingCredentialCreateRequest])
- useEffect(() => {
- if (!selectedCredential) {
- setSelectedDescriptionDraft('')
- setSelectedDisplayNameDraft('')
- return
- }
-
- setDetailsError(null)
- setSelectedDescriptionDraft(selectedCredential.description || '')
- setSelectedDisplayNameDraft(selectedCredential.displayName)
- }, [selectedCredential])
-
const isSelectedAdmin = selectedCredential?.role === 'admin'
const selectedOAuthServiceConfig = useMemo(() => {
if (
@@ -360,28 +364,16 @@ export function IntegrationsManager() {
setCreateDescription('')
setCreateOAuthProviderId('')
setCreateError(null)
- setShowCreateOAuthRequiredModal(false)
+ setCreateStep(1)
+ setServiceSearch('')
pendingReturnOriginRef.current = undefined
}
const handleSelectCredential = (credential: WorkspaceCredential) => {
setSelectedCredentialId(credential.id)
setDetailsError(null)
- }
-
- const handleCreateCredential = async () => {
- if (!workspaceId) return
- setCreateError(null)
-
- if (!selectedOAuthService) {
- setCreateError('Select an OAuth service before connecting.')
- return
- }
- if (!createDisplayName.trim()) {
- setCreateError('Display name is required.')
- return
- }
- setShowCreateOAuthRequiredModal(true)
+ setSelectedDescriptionDraft(credential.description || '')
+ setSelectedDisplayNameDraft(credential.displayName)
}
const handleConnectOAuthService = async () => {
@@ -398,15 +390,11 @@ export function IntegrationsManager() {
setCreateError(null)
try {
- await fetch('/api/credentials/draft', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- workspaceId,
- providerId: selectedOAuthService.providerId,
- displayName,
- description: createDescription.trim() || undefined,
- }),
+ await createDraft.mutateAsync({
+ workspaceId,
+ providerId: selectedOAuthService.providerId,
+ displayName,
+ description: createDescription.trim() || undefined,
})
const oauthPreCount = credentials.filter(
@@ -490,6 +478,8 @@ export function IntegrationsManager() {
if (selectedCredentialId === credentialToDelete.id) {
setSelectedCredentialId(null)
+ setSelectedDescriptionDraft('')
+ setSelectedDisplayNameDraft('')
}
setShowDeleteConfirmDialog(false)
setCredentialToDelete(null)
@@ -539,16 +529,12 @@ export function IntegrationsManager() {
setDetailsError(null)
try {
- await fetch('/api/credentials/draft', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- workspaceId,
- providerId: selectedCredential.providerId,
- displayName: selectedCredential.displayName,
- description: selectedCredential.description || undefined,
- credentialId: selectedCredential.id,
- }),
+ await createDraft.mutateAsync({
+ workspaceId,
+ providerId: selectedCredential.providerId,
+ displayName: selectedCredential.displayName,
+ description: selectedCredential.description || undefined,
+ credentialId: selectedCredential.id,
})
const oauthPreCount = credentials.filter(
@@ -618,8 +604,31 @@ export function IntegrationsManager() {
}
const hasCredentials = oauthCredentials && oauthCredentials.length > 0
+
+ const connectedProviderIds = useMemo(
+ () => new Set(oauthCredentials.map((c) => c.providerId).filter(Boolean) as string[]),
+ [oauthCredentials]
+ )
+
const showNoResults =
- searchTerm.trim() && sortedCredentials.length === 0 && oauthCredentials.length > 0
+ searchTerm.trim() &&
+ sortedCredentials.length === 0 &&
+ filteredAvailableIntegrations.length === 0
+
+ const handleAddForProvider = useCallback((providerId: string) => {
+ setCreateOAuthProviderId(providerId)
+ setCreateStep(2)
+ setCreateDisplayName('')
+ setCreateDescription('')
+ setCreateError(null)
+ setShowCreateModal(true)
+ }, [])
+
+ const filteredServices = useMemo(() => {
+ if (!serviceSearch.trim()) return oauthServiceOptions
+ const q = serviceSearch.toLowerCase()
+ return oauthServiceOptions.filter((s) => s.label.toLowerCase().includes(q))
+ }, [oauthServiceOptions, serviceSearch])
const createModalJsx = (
- Connect Integration
-
- {(createError || existingOAuthDisplayName) && (
-
- {createError && (
-
- {createError}
-
- )}
- {existingOAuthDisplayName && (
-
- An integration named "{existingOAuthDisplayName.displayName}" already exists.
-
- )}
-
- )}
-
-
-
-
Account
-
-
option.value === createOAuthProviderId)
- ?.label || ''
- }
- selectedValue={createOAuthProviderId}
- onChange={(value) => {
- setCreateOAuthProviderId(value)
- setCreateError(null)
- }}
- placeholder='Select OAuth service'
- searchable
- searchPlaceholder='Search services...'
- overlayContent={
- createOAuthProviderId
- ? (() => {
- const config = getServiceConfigByProviderId(createOAuthProviderId)
- const label =
- oauthServiceOptions.find((o) => o.value === createOAuthProviderId)
- ?.label || ''
- return (
-
- {config &&
- createElement(config.icon, {
- className: 'h-[14px] w-[14px] flex-shrink-0',
- })}
- {label}
-
- )
- })()
- : undefined
- }
+ {createStep === 1 ? (
+ <>
+ Connect Integration
+
+
+
+
+ setServiceSearch(e.target.value)}
+ className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
+ autoFocus
/>
+
+ {filteredServices.map((service) => {
+ const config = getServiceConfigByProviderId(service.value)
+ return (
+
{
+ setCreateOAuthProviderId(service.value)
+ setCreateStep(2)
+ setServiceSearch('')
+ }}
+ className='flex items-center gap-[10px] rounded-[6px] px-[8px] py-[8px] text-left hover:bg-[var(--surface-5)]'
+ >
+
+ {config ? (
+ createElement(config.icon, { className: 'h-4 w-4' })
+ ) : (
+
+ {service.label.slice(0, 2)}
+
+ )}
+
+
+ {service.label}
+
+
+ )
+ })}
+ {filteredServices.length === 0 && (
+
+ No services found
+
+ )}
+
-
-
- Display name*
-
-
setCreateDisplayName(event.target.value)}
- placeholder='Integration name'
- autoComplete='off'
- data-lpignore='true'
- className='mt-[6px]'
- />
+
+
+ setShowCreateModal(false)}>
+ Cancel
+
+
+ >
+ ) : (
+ <>
+
+
+ {
+ setCreateStep(1)
+ setCreateError(null)
+ }}
+ className='flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--text-muted)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
+ aria-label='Back'
+ >
+ ←
+
+
+ Connect{' '}
+ {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
+
-
-
Description
-
setCreateDescription(event.target.value)}
- placeholder='Optional description'
- maxLength={500}
- autoComplete='off'
- data-lpignore='true'
- className='mt-[6px] min-h-[80px] resize-none'
- />
+
+
+ {(createError || existingOAuthDisplayName) && (
+
+ {createError && (
+
+ {createError}
+
+ )}
+ {existingOAuthDisplayName && (
+
+ An integration named "{existingOAuthDisplayName.displayName}" already exists.
+
+ )}
+
+ )}
+
+
+
+ {selectedOAuthService &&
+ createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })}
+
+
+
+ Connect your {selectedOAuthService?.name} account
+
+
+ Grant access to use {selectedOAuthService?.name} in your workflows
+
+
+
+
+ {createDisplayScopes.length > 0 && (
+
+
+
+ Permissions requested
+
+
+
+ {createDisplayScopes.map((scope) => (
+
+
+
+
+
+ {getScopeDescription(scope)}
+
+
+ ))}
+
+
+ )}
+
+
+
+ Display name*
+
+ setCreateDisplayName(event.target.value)}
+ placeholder='Integration name'
+ autoComplete='off'
+ data-lpignore='true'
+ className='mt-[6px]'
+ autoFocus
+ />
+
+
+ Description
+ setCreateDescription(event.target.value)}
+ placeholder='Optional description'
+ maxLength={500}
+ autoComplete='off'
+ data-lpignore='true'
+ className='mt-[6px] min-h-[80px] resize-none'
+ />
+
-
-
-
-
- setShowCreateModal(false)}>
- Cancel
-
-
- {connectOAuthService.isPending ? 'Connecting...' : 'Connect'}
-
-
+
+
+ {
+ setCreateStep(1)
+ setCreateError(null)
+ }}
+ >
+ Back
+
+
+ {connectOAuthService.isPending ? 'Connecting...' : 'Connect'}
+
+
+ >
+ )}
)
- const oauthRequiredModalJsx = showCreateOAuthRequiredModal && createOAuthProviderId && (
- setShowCreateOAuthRequiredModal(false)}
- provider={createOAuthProviderId as OAuthProvider}
- toolName={resolveProviderLabel(createOAuthProviderId)}
- requiredScopes={createOAuthRequiredScopes}
- newScopes={[]}
- serviceId={selectedOAuthService?.id || createOAuthProviderId}
- onConnect={async () => {
- await handleConnectOAuthService()
- }}
- />
- )
-
const handleCloseDeleteDialog = () => {
setShowDeleteConfirmDialog(false)
setCredentialToDelete(null)
@@ -1083,7 +1163,6 @@ export function IntegrationsManager() {
{createModalJsx}
- {oauthRequiredModalJsx}
{deleteConfirmDialogJsx}
{unsavedChangesAlertJsx}
>
@@ -1124,17 +1203,12 @@ export function IntegrationsManager() {
- ) : !hasCredentials ? (
-
- Click "Connect" above to get started
-
) : (
{sortedCredentials.map((credential) => {
const serviceConfig = credential.providerId
? getServiceConfigByProviderId(credential.providerId)
: null
-
return (
@@ -1169,18 +1243,53 @@ export function IntegrationsManager() {
)
})}
+
{showNoResults && (
No integrations found matching “{searchTerm}”
)}
+
+ {filteredAvailableIntegrations.length > 0 && (
+
+
+ Available integrations
+
+ {filteredAvailableIntegrations.map((service) => {
+ const serviceConfig = getServiceConfigByProviderId(service.providerId)
+ const isConnected = connectedProviderIds.has(service.providerId)
+ return (
+
+
+ {serviceConfig && (
+
+ {createElement(serviceConfig.icon, { className: 'h-4 w-4' })}
+
+ )}
+
{service.name}
+
+
handleAddForProvider(service.providerId)}
+ >
+ {isConnected ? 'Add account' : 'Connect'}
+
+
+ )
+ })}
+
+ )}
)}
{createModalJsx}
- {oauthRequiredModalJsx}
{deleteConfirmDialogJsx}
>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx
new file mode 100644
index 00000000000..4e4347b3f23
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx
@@ -0,0 +1,283 @@
+'use client'
+
+import type { ChangeEvent } from 'react'
+import { useCallback, useRef, useState } from 'react'
+import { Loader2 } from 'lucide-react'
+import { Button, Input, Label, Textarea } from '@/components/emcn'
+import { Upload } from '@/components/emcn/icons'
+import { cn } from '@/lib/core/utils/cn'
+import { extractSkillFromZip, parseSkillMarkdown } from './utils'
+
+interface ImportedSkill {
+ name: string
+ description: string
+ content: string
+}
+
+interface SkillImportProps {
+ onImport: (data: ImportedSkill) => void
+}
+
+type ImportState = 'idle' | 'loading' | 'error'
+
+const ACCEPTED_EXTENSIONS = ['.md', '.zip']
+
+function isAcceptedFile(file: File): boolean {
+ const name = file.name.toLowerCase()
+ return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))
+}
+
+export function SkillImport({ onImport }: SkillImportProps) {
+ const fileInputRef = useRef
(null)
+
+ const [dragCounter, setDragCounter] = useState(0)
+ const isDragging = dragCounter > 0
+ const [fileState, setFileState] = useState('idle')
+ const [fileError, setFileError] = useState('')
+
+ const [githubUrl, setGithubUrl] = useState('')
+ const [githubState, setGithubState] = useState('idle')
+ const [githubError, setGithubError] = useState('')
+
+ const [pasteContent, setPasteContent] = useState('')
+ const [pasteError, setPasteError] = useState('')
+
+ const processFile = useCallback(
+ async (file: File) => {
+ if (!isAcceptedFile(file)) {
+ setFileError('Unsupported file type. Use .md or .zip files.')
+ setFileState('error')
+ return
+ }
+
+ setFileState('loading')
+ setFileError('')
+
+ try {
+ let rawContent: string
+
+ if (file.name.toLowerCase().endsWith('.zip')) {
+ if (file.size > 5 * 1024 * 1024) {
+ setFileError('ZIP file is too large (max 5 MB)')
+ setFileState('error')
+ return
+ }
+ rawContent = await extractSkillFromZip(file)
+ } else {
+ rawContent = await file.text()
+ }
+
+ const parsed = parseSkillMarkdown(rawContent)
+ setFileState('idle')
+ onImport(parsed)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to process file'
+ setFileError(message)
+ setFileState('error')
+ }
+ },
+ [onImport]
+ )
+
+ const handleFileChange = useCallback(
+ (e: ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (file) processFile(file)
+ if (fileInputRef.current) fileInputRef.current.value = ''
+ },
+ [processFile]
+ )
+
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setDragCounter((prev) => prev + 1)
+ }, [])
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setDragCounter((prev) => prev - 1)
+ }, [])
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ e.dataTransfer.dropEffect = 'copy'
+ }, [])
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setDragCounter(0)
+
+ const file = e.dataTransfer.files?.[0]
+ if (file) processFile(file)
+ },
+ [processFile]
+ )
+
+ const handleGithubImport = useCallback(async () => {
+ const trimmed = githubUrl.trim()
+ if (!trimmed) {
+ setGithubError('Please enter a GitHub URL')
+ setGithubState('error')
+ return
+ }
+
+ setGithubState('loading')
+ setGithubError('')
+
+ try {
+ const res = await fetch('/api/skills/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url: trimmed }),
+ })
+
+ const data = await res.json()
+
+ if (!res.ok) {
+ throw new Error(data.error || `Import failed (HTTP ${res.status})`)
+ }
+
+ const parsed = parseSkillMarkdown(data.content)
+ setGithubState('idle')
+ onImport(parsed)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to import from GitHub'
+ setGithubError(message)
+ setGithubState('error')
+ }
+ }, [githubUrl, onImport])
+
+ const handlePasteImport = useCallback(() => {
+ const trimmed = pasteContent.trim()
+ if (!trimmed) {
+ setPasteError('Please paste some content first')
+ return
+ }
+
+ setPasteError('')
+ const parsed = parseSkillMarkdown(trimmed)
+ onImport(parsed)
+ }, [pasteContent, onImport])
+
+ return (
+
+ {/* File drop zone */}
+
+
Upload File
+
fileInputRef.current?.click()}
+ onDragEnter={handleDragEnter}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ disabled={fileState === 'loading'}
+ className={cn(
+ 'flex w-full cursor-pointer flex-col items-center justify-center gap-[8px] rounded-[8px] border border-dashed px-[16px] py-[32px] transition-colors',
+ 'border-[var(--border-1)] bg-[var(--surface-1)] hover:bg-[var(--surface-4)]',
+ isDragging && 'border-[var(--surface-7)] bg-[var(--surface-4)]',
+ fileState === 'loading' && 'pointer-events-none opacity-60'
+ )}
+ >
+
+ {fileState === 'loading' ? (
+
+ ) : (
+
+ )}
+
+
+ {isDragging ? 'Drop file here' : 'Drop file here or click to browse'}
+
+
+ .md file with YAML frontmatter, or .zip containing a SKILL.md
+
+
+
+ {fileError &&
{fileError}
}
+
+
+
+
+ {/* GitHub URL */}
+
+
+ Import from GitHub
+
+
+ {
+ setGithubUrl(e.target.value)
+ if (githubError) setGithubError('')
+ }}
+ className='flex-1'
+ disabled={githubState === 'loading'}
+ />
+
+ {githubState === 'loading' ? (
+
+ ) : (
+ 'Fetch'
+ )}
+
+
+ {githubError &&
{githubError}
}
+
+
+
+
+ {/* Paste content */}
+
+
+ Paste SKILL.md Content
+
+
) => {
+ setPasteContent(e.target.value)
+ if (pasteError) setPasteError('')
+ }}
+ className='min-h-[120px] resize-y font-mono text-[14px]'
+ />
+ {pasteError && {pasteError}
}
+
+
+ Import
+
+
+
+
+ )
+}
+
+function Divider() {
+ return (
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal.tsx
index 8a0d001f412..678a80a3a13 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal.tsx
@@ -1,7 +1,7 @@
'use client'
import type { ChangeEvent } from 'react'
-import { useMemo, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -12,10 +12,15 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
+ ModalTabs,
+ ModalTabsContent,
+ ModalTabsList,
+ ModalTabsTrigger,
Textarea,
} from '@/components/emcn'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
+import { SkillImport } from './skill-import'
interface SkillModalProps {
open: boolean
@@ -34,6 +39,8 @@ interface FieldErrors {
general?: string
}
+type TabValue = 'create' | 'import'
+
export function SkillModal({
open,
onOpenChange,
@@ -52,6 +59,7 @@ export function SkillModal({
const [content, setContent] = useState('')
const [errors, setErrors] = useState({})
const [saving, setSaving] = useState(false)
+ const [activeTab, setActiveTab] = useState('create')
const [prevOpen, setPrevOpen] = useState(false)
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
@@ -60,6 +68,7 @@ export function SkillModal({
setDescription(initialValues?.description ?? '')
setContent(initialValues?.content ?? '')
setErrors({})
+ setActiveTab('create')
}
if (open !== prevOpen) setPrevOpen(open)
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
@@ -124,97 +133,137 @@ export function SkillModal({
}
}
+ const handleImport = useCallback(
+ (data: { name: string; description: string; content: string }) => {
+ setName(data.name)
+ setDescription(data.description)
+ setContent(data.content)
+ setErrors({})
+ setActiveTab('create')
+ },
+ []
+ )
+
+ const isEditing = !!initialValues
+
+ const createForm = (
+
+
+
+ Name
+
+
{
+ setName(e.target.value)
+ if (errors.name || errors.general)
+ setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
+ }}
+ />
+ {errors.name ? (
+
{errors.name}
+ ) : (
+
+ Lowercase letters, numbers, and hyphens (e.g. my-skill)
+
+ )}
+
+
+
+
+ Description
+
+
{
+ setDescription(e.target.value)
+ if (errors.description || errors.general)
+ setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
+ }}
+ maxLength={1024}
+ />
+ {errors.description && (
+
{errors.description}
+ )}
+
+
+
+
+ Content
+
+
) => {
+ setContent(e.target.value)
+ if (errors.content || errors.general)
+ setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
+ }}
+ className='min-h-[200px] resize-y font-mono text-[14px]'
+ />
+ {errors.content && {errors.content}
}
+
+
+ {errors.general &&
{errors.general}
}
+
+ )
+
+ const footer = (
+
+ {isEditing && onDelete ? (
+ onDelete(initialValues.id)}>
+ Delete
+
+ ) : (
+
+ )}
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {saving ? 'Saving...' : isEditing ? 'Update' : 'Create'}
+
+
+
+ )
+
return (
- {initialValues ? 'Edit Skill' : 'Create Skill'}
-
-
-
-
- Name
-
-
{
- setName(e.target.value)
- if (errors.name || errors.general)
- setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
- }}
- />
- {errors.name ? (
-
{errors.name}
- ) : (
-
- Lowercase letters, numbers, and hyphens (e.g. my-skill)
-
- )}
-
-
-
-
- Description
-
-
{
- setDescription(e.target.value)
- if (errors.description || errors.general)
- setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
- }}
- maxLength={1024}
- />
- {errors.description && (
-
{errors.description}
- )}
-
-
-
-
- Content
-
-
) => {
- setContent(e.target.value)
- if (errors.content || errors.general)
- setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
- }}
- className='min-h-[200px] resize-y font-mono text-[14px]'
- />
- {errors.content && (
- {errors.content}
- )}
-
-
- {errors.general && (
-
{errors.general}
- )}
-
-
-
- {initialValues && onDelete ? (
- onDelete(initialValues.id)}>
- Delete
-
- ) : (
-
- )}
-
- onOpenChange(false)}>
- Cancel
-
-
- {saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
-
-
-
+ {isEditing ? (
+ <>
+ Edit Skill
+ {createForm}
+ {footer}
+ >
+ ) : (
+ <>
+ Add Skill
+ setActiveTab(v as TabValue)}
+ className='flex min-h-0 flex-1 flex-col'
+ >
+
+ Create
+ Import
+
+
+ {createForm}
+
+
+
+
+
+ {activeTab === 'create' && footer}
+ >
+ )}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.test.ts
new file mode 100644
index 00000000000..e6cc61a40d3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.test.ts
@@ -0,0 +1,191 @@
+/**
+ * @vitest-environment node
+ */
+import JSZip from 'jszip'
+import { describe, expect, it } from 'vitest'
+import { extractSkillFromZip, parseSkillMarkdown } from './utils'
+
+describe('parseSkillMarkdown', () => {
+ it('parses standard SKILL.md with name, description, and body', () => {
+ const input = [
+ '---',
+ 'name: my-skill',
+ 'description: Does something useful',
+ '---',
+ '',
+ '# Instructions',
+ 'Use this skill to do things.',
+ ].join('\n')
+
+ expect(parseSkillMarkdown(input)).toEqual({
+ name: 'my-skill',
+ description: 'Does something useful',
+ content: '# Instructions\nUse this skill to do things.',
+ })
+ })
+
+ it('strips single and double quotes from frontmatter values', () => {
+ const input = '---\nname: \'my-skill\'\ndescription: "A quoted description"\n---\nBody'
+
+ expect(parseSkillMarkdown(input)).toEqual({
+ name: 'my-skill',
+ description: 'A quoted description',
+ content: 'Body',
+ })
+ })
+
+ it('preserves colons inside description values', () => {
+ const input = '---\nname: api-tool\ndescription: API key: required for auth\n---\nBody'
+
+ expect(parseSkillMarkdown(input)).toEqual({
+ name: 'api-tool',
+ description: 'API key: required for auth',
+ content: 'Body',
+ })
+ })
+
+ it('ignores unknown frontmatter fields', () => {
+ const input = '---\nname: x\ndescription: y\nauthor: someone\nversion: 2\n---\nBody'
+
+ const result = parseSkillMarkdown(input)
+ expect(result.name).toBe('x')
+ expect(result.description).toBe('y')
+ expect(result.content).toBe('Body')
+ })
+
+ it('infers name from heading when frontmatter has no name field', () => {
+ const input =
+ '---\ndescription: A tool for blocks\nargument-hint: \n---\n\n# Add Block Skill\n\nContent here.'
+
+ expect(parseSkillMarkdown(input)).toEqual({
+ name: 'add-block-skill',
+ description: 'A tool for blocks',
+ content: '# Add Block Skill\n\nContent here.',
+ })
+ })
+
+ it('infers name from heading when there is no frontmatter at all', () => {
+ const input = '# My Cool Tool\n\nSome instructions.'
+
+ expect(parseSkillMarkdown(input)).toEqual({
+ name: 'my-cool-tool',
+ description: '',
+ content: '# My Cool Tool\n\nSome instructions.',
+ })
+ })
+
+ it('returns empty name when there is no frontmatter and no heading', () => {
+ const input = 'Just some plain text without any structure.'
+
+ expect(parseSkillMarkdown(input)).toEqual({
+ name: '',
+ description: '',
+ content: 'Just some plain text without any structure.',
+ })
+ })
+
+ it('handles empty input', () => {
+ expect(parseSkillMarkdown('')).toEqual({
+ name: '',
+ description: '',
+ content: '',
+ })
+ })
+
+ it('handles frontmatter with empty name value', () => {
+ const input = '---\nname:\ndescription: Has a description\n---\n\n# Fallback Heading\nBody'
+
+ const result = parseSkillMarkdown(input)
+ expect(result.name).toBe('fallback-heading')
+ expect(result.description).toBe('Has a description')
+ })
+
+ it('handles frontmatter with no body', () => {
+ const input = '---\nname: solo\ndescription: Just frontmatter\n---'
+
+ expect(parseSkillMarkdown(input)).toEqual({
+ name: 'solo',
+ description: 'Just frontmatter',
+ content: '',
+ })
+ })
+
+ it('handles unclosed frontmatter as plain content', () => {
+ const input = '---\nname: broken\nno closing delimiter'
+
+ const result = parseSkillMarkdown(input)
+ expect(result.name).toBe('')
+ expect(result.content).toBe(input)
+ })
+
+ it('trims whitespace from input', () => {
+ const input = '\n\n ---\nname: trimmed\ndescription: yes\n---\nBody \n\n'
+
+ const result = parseSkillMarkdown(input)
+ expect(result.name).toBe('trimmed')
+ expect(result.content).toBe('Body')
+ })
+
+ it('truncates inferred heading names to 64 characters', () => {
+ const longHeading = `# ${'A'.repeat(100)}`
+ const result = parseSkillMarkdown(longHeading)
+ expect(result.name.length).toBeLessThanOrEqual(64)
+ })
+
+ it('sanitizes special characters in inferred heading names', () => {
+ const input = '# Hello, World! (v2) — Updated'
+ const result = parseSkillMarkdown(input)
+ expect(result.name).toBe('hello-world-v2-updated')
+ })
+
+ it('handles h2 and h3 headings for name inference', () => {
+ expect(parseSkillMarkdown('## Sub Heading').name).toBe('sub-heading')
+ expect(parseSkillMarkdown('### Third Level').name).toBe('third-level')
+ })
+
+ it('does not match h4+ headings for name inference', () => {
+ expect(parseSkillMarkdown('#### Too Deep').name).toBe('')
+ })
+
+ it('uses first heading even when multiple exist', () => {
+ const input = '# First\n\n## Second\n\n### Third'
+ expect(parseSkillMarkdown(input).name).toBe('first')
+ })
+})
+
+describe('extractSkillFromZip', () => {
+ async function makeZipBuffer(files: Record): Promise {
+ const zip = new JSZip()
+ for (const [path, content] of Object.entries(files)) {
+ zip.file(path, content)
+ }
+ return zip.generateAsync({ type: 'uint8array' })
+ }
+
+ it('extracts SKILL.md at root level', async () => {
+ const data = await makeZipBuffer({ 'SKILL.md': '---\nname: root\n---\nContent' })
+ const content = await extractSkillFromZip(data)
+ expect(content).toBe('---\nname: root\n---\nContent')
+ })
+
+ it('extracts SKILL.md from a nested directory', async () => {
+ const data = await makeZipBuffer({ 'my-skill/SKILL.md': '---\nname: nested\n---\nBody' })
+ const content = await extractSkillFromZip(data)
+ expect(content).toBe('---\nname: nested\n---\nBody')
+ })
+
+ it('prefers the shallowest SKILL.md when multiple exist', async () => {
+ const data = await makeZipBuffer({
+ 'deep/nested/SKILL.md': 'deep',
+ 'SKILL.md': 'root',
+ 'other/SKILL.md': 'other',
+ })
+ const content = await extractSkillFromZip(data)
+ expect(content).toBe('root')
+ })
+
+ it('throws when no SKILL.md is found', async () => {
+ const data = await makeZipBuffer({ 'README.md': 'No skill here' })
+ await expect(extractSkillFromZip(data)).rejects.toThrow('No SKILL.md file found')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.ts
new file mode 100644
index 00000000000..b8a7236924a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.ts
@@ -0,0 +1,111 @@
+import JSZip from 'jszip'
+
+interface ParsedSkill {
+ name: string
+ description: string
+ content: string
+}
+
+const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/
+
+/**
+ * Parses a SKILL.md string with optional YAML frontmatter into structured fields.
+ *
+ * Expected format:
+ * ```
+ * ---
+ * name: my-skill
+ * description: What this skill does
+ * ---
+ * # Markdown content here...
+ * ```
+ *
+ * If no frontmatter is present, the entire text becomes the content field.
+ */
+export function parseSkillMarkdown(raw: string): ParsedSkill {
+ const trimmed = raw.replace(/\r\n/g, '\n').trim()
+ const match = trimmed.match(FRONTMATTER_REGEX)
+
+ if (!match) {
+ return {
+ name: inferNameFromHeading(trimmed),
+ description: '',
+ content: trimmed,
+ }
+ }
+
+ const frontmatter = match[1]
+ const body = (match[2] ?? '').trim()
+
+ let name = ''
+ let description = ''
+
+ for (const line of frontmatter.split('\n')) {
+ const colonIdx = line.indexOf(':')
+ if (colonIdx === -1) continue
+
+ const key = line.slice(0, colonIdx).trim().toLowerCase()
+ const value = line
+ .slice(colonIdx + 1)
+ .trim()
+ .replace(/^['"]|['"]$/g, '')
+
+ if (key === 'name') {
+ name = value
+ } else if (key === 'description') {
+ description = value
+ }
+ }
+
+ if (!name) {
+ name = inferNameFromHeading(body)
+ }
+
+ return { name, description, content: body }
+}
+
+/**
+ * Derives a kebab-case name from the first markdown heading (e.g. `# Add Block Skill` -> `add-block-skill`).
+ */
+function inferNameFromHeading(markdown: string): string {
+ const headingMatch = markdown.match(/^#{1,3}\s+(.+)$/m)
+ if (!headingMatch) return ''
+
+ return headingMatch[1]
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+ .slice(0, 64)
+}
+
+/**
+ * Extracts the SKILL.md content from a ZIP archive.
+ * Searches for a file named SKILL.md at any depth within the archive.
+ * Accepts File, Blob, ArrayBuffer, or Uint8Array (anything JSZip supports).
+ */
+export async function extractSkillFromZip(
+ data: File | Blob | ArrayBuffer | Uint8Array
+): Promise {
+ const zip = await JSZip.loadAsync(data)
+
+ const candidates: string[] = []
+ zip.forEach((relativePath, entry) => {
+ if (!entry.dir && relativePath.endsWith('SKILL.md')) {
+ candidates.push(relativePath)
+ }
+ })
+
+ if (candidates.length === 0) {
+ throw new Error('No SKILL.md file found in the ZIP archive')
+ }
+
+ candidates.sort((a, b) => {
+ const depthA = a.split('/').length
+ const depthB = b.split('/').length
+ return depthA - depthB
+ })
+
+ const content = await zip.file(candidates[0])!.async('string')
+ return content
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx
index 22106be41cd..aeb05dd4c20 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx
@@ -103,7 +103,7 @@ export function CreditBalance({
{canPurchase && (
-
+
Add Credits
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx
index 75f75889bd3..ac36b354880 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx
@@ -711,7 +711,7 @@ export function Subscription() {
const showProCard = !isOnMaxTier
return (
-
+
{showProCard && (
+ {hasEnterprise && (
+
window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')}
+ />
+ )}
)
})()}
@@ -924,24 +933,26 @@ export function Subscription() {
{/* Billing details section */}
{(subscription.isPaid || (!isLoading && isTeamAdmin)) && (
-
+
{subscription.isPaid && permissions.canViewUsageInfo && (
-
refetchSubscription()}
- />
+
+ refetchSubscription()}
+ />
+
)}
{subscription.isPaid &&
subscriptionData?.data?.periodEnd &&
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
-
+
{isCancelledAtPeriodEnd ? 'Access Until' : 'Next Billing Date'}
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
@@ -950,16 +961,18 @@ export function Subscription() {
)}
{subscription.isPaid && permissions.canViewUsageInfo && (
-
+
+
+
)}
{subscription.isPaid &&
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
-
+
Invoices
{
@@ -995,7 +1008,7 @@ export function Subscription() {
)}
{!isLoading && isTeamAdmin && (
-
+
Billed Account
@@ -1040,18 +1053,6 @@ export function Subscription() {
)}
)}
-
- {/* Enterprise */}
- {hasEnterprise && (
-
window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')}
- inlineButton
- />
- )}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
index 0eabb85e3aa..1e564ed0598 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
@@ -61,6 +61,8 @@ export interface NavigationItem {
selfHostedOverride?: boolean
requiresSuperUser?: boolean
requiresAdminRole?: boolean
+ /** Show in the sidebar even when the user lacks the required plan, with an upgrade badge. */
+ showWhenLocked?: boolean
externalUrl?: string
}
@@ -137,13 +139,13 @@ export const allNavigationItems: NavigationItem[] = [
requiresMax: true,
requiresHosted: true,
selfHostedOverride: isInboxEnabled,
+ showWhenLocked: true,
},
{
id: 'credential-sets',
label: 'Email Polling',
icon: Mail,
section: 'system',
- requiresTeam: true,
requiresHosted: true,
selfHostedOverride: isCredentialSetsEnabled,
},
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx
index 0a95d9fd6be..1fe195150e8 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx
@@ -17,6 +17,8 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
+import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
+import { useAllowedMcpDomains, useCreateMcpServer } from '@/hooks/queries/mcp'
import {
useAddWorkflowMcpTool,
useDeleteWorkflowMcpTool,
@@ -26,7 +28,7 @@ import {
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
-import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
+import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -100,7 +102,11 @@ export function McpDeploy({
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
- const { navigateToSettings } = useSettingsNavigation()
+ const [showMcpModal, setShowMcpModal] = useState(false)
+
+ const createMcpServer = useCreateMcpServer()
+ const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
+ const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
const addToolMutation = useAddWorkflowMcpTool()
@@ -464,17 +470,27 @@ export function McpDeploy({
if (servers.length === 0) {
return (
-
-
- Create an MCP Server in Settings → MCP Servers first.
-
-
navigateToSettings({ section: 'workflow-mcp-servers' })}
- >
- Create MCP Server
-
-
+ <>
+
+
+ Create an MCP Server in Settings → MCP Servers first.
+
+
setShowMcpModal(true)}>
+ Create MCP Server
+
+
+
{
+ await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
+ }}
+ workspaceId={workspaceId}
+ availableEnvVars={availableEnvVars}
+ allowedMcpDomains={allowedMcpDomains}
+ />
+ >
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx
new file mode 100644
index 00000000000..91eeb4d325c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx
@@ -0,0 +1,211 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { createLogger } from '@sim/logger'
+import { Check } from 'lucide-react'
+import {
+ Button,
+ Input,
+ Label,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from '@/components/emcn'
+import { client } from '@/lib/auth/auth-client'
+import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
+import {
+ getCanonicalScopesForProvider,
+ getProviderIdFromServiceId,
+ OAUTH_PROVIDERS,
+ type OAuthProvider,
+ parseProvider,
+} from '@/lib/oauth'
+import { getScopeDescription } from '@/lib/oauth/utils'
+import { useCreateCredentialDraft } from '@/hooks/queries/credentials'
+
+const logger = createLogger('ConnectCredentialModal')
+
+export interface ConnectCredentialModalProps {
+ isOpen: boolean
+ onClose: () => void
+ provider: OAuthProvider
+ serviceId: string
+ workspaceId: string
+ workflowId: string
+ /** Number of existing credentials for this provider — used to detect a successful new connection. */
+ credentialCount: number
+}
+
+export function ConnectCredentialModal({
+ isOpen,
+ onClose,
+ provider,
+ serviceId,
+ workspaceId,
+ workflowId,
+ credentialCount,
+}: ConnectCredentialModalProps) {
+ const [displayName, setDisplayName] = useState('')
+ const [error, setError] = useState(null)
+
+ const createDraft = useCreateCredentialDraft()
+
+ const { providerName, ProviderIcon } = useMemo(() => {
+ const { baseProvider } = parseProvider(provider)
+ const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
+ let name = baseProviderConfig?.name || provider
+ let Icon = baseProviderConfig?.icon || (() => null)
+ if (baseProviderConfig) {
+ for (const [key, service] of Object.entries(baseProviderConfig.services)) {
+ if (key === serviceId || service.providerId === provider) {
+ name = service.name
+ Icon = service.icon
+ break
+ }
+ }
+ }
+ return { providerName: name, ProviderIcon: Icon }
+ }, [provider, serviceId])
+
+ const providerId = getProviderIdFromServiceId(serviceId)
+
+ const displayScopes = useMemo(
+ () =>
+ getCanonicalScopesForProvider(providerId).filter(
+ (scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
+ ),
+ [providerId]
+ )
+
+ const handleClose = () => {
+ setDisplayName('')
+ setError(null)
+ onClose()
+ }
+
+ const handleConnect = async () => {
+ const trimmedName = displayName.trim()
+ if (!trimmedName) {
+ setError('Display name is required.')
+ return
+ }
+
+ setError(null)
+
+ try {
+ await createDraft.mutateAsync({ workspaceId, providerId, displayName: trimmedName })
+
+ writeOAuthReturnContext({
+ origin: 'workflow',
+ workflowId,
+ displayName: trimmedName,
+ providerId,
+ preCount: credentialCount,
+ workspaceId,
+ requestedAt: Date.now(),
+ })
+
+ if (providerId === 'trello') {
+ window.location.href = '/api/auth/trello/authorize'
+ return
+ }
+
+ if (providerId === 'shopify') {
+ const returnUrl = encodeURIComponent(window.location.href)
+ window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
+ return
+ }
+
+ await client.oauth2.link({ providerId, callbackURL: window.location.href })
+ handleClose()
+ } catch (err) {
+ logger.error('Failed to initiate OAuth connection', { error: err })
+ setError('Failed to connect. Please try again.')
+ }
+ }
+
+ const isPending = createDraft.isPending
+
+ return (
+ !open && handleClose()}>
+
+ Connect {providerName}
+
+
+
+
+
+
+ Connect your {providerName} account
+
+
+ Grant access to use {providerName} in your workflow
+
+
+
+
+ {displayScopes.length > 0 && (
+
+
+
+ Permissions requested
+
+
+
+
+ )}
+
+
+
+ Display name *
+
+ {
+ setDisplayName(e.target.value)
+ setError(null)
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !isPending) void handleConnect()
+ }}
+ placeholder={`My ${providerName} account`}
+ autoComplete='off'
+ data-lpignore='true'
+ className='mt-[6px]'
+ />
+
+
+ {error &&
{error}
}
+
+
+
+
+ Cancel
+
+
+ {isPending ? 'Connecting...' : 'Connect'}
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
index acb70f0d49d..4ba510e5b92 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
@@ -153,7 +153,7 @@ export function OAuthRequiredModal({
Permissions requested
-
+
{displayScopes.map((scope) => (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index 6db7465bc9e..733c9f1ad3b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -1,13 +1,12 @@
'use client'
-import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
+import { createElement, useCallback, useMemo, useState } from 'react'
import { ExternalLink, Users } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
-import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -16,17 +15,18 @@ import {
parseProvider,
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
+import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets'
+import { useWorkspaceCredential } from '@/hooks/queries/credentials'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
-import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
@@ -50,6 +50,7 @@ export function CredentialSelector({
}: CredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
+ const [showConnectModal, setShowConnectModal] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
@@ -116,36 +117,11 @@ export function CredentialSelector({
[credentialSets, selectedCredentialSetId]
)
- const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState(null)
-
- useEffect(() => {
- if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
- setInaccessibleCredentialName(null)
- return
- }
-
- setInaccessibleCredentialName(null)
-
- let cancelled = false
- ;(async () => {
- try {
- const response = await fetch(
- `/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
- )
- if (!response.ok || cancelled) return
- const data = await response.json()
- if (!cancelled && data.credential?.displayName) {
- setInaccessibleCredentialName(data.credential.displayName)
- }
- } catch {
- // Ignore fetch errors
- }
- })()
-
- return () => {
- cancelled = true
- }
- }, [selectedId, selectedCredential, credentialsLoading, workspaceId])
+ const { data: inaccessibleCredential } = useWorkspaceCredential(
+ selectedId || undefined,
+ Boolean(selectedId) && !selectedCredential && !credentialsLoading && Boolean(workspaceId)
+ )
+ const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null
const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name
@@ -157,7 +133,6 @@ export function CredentialSelector({
const displayValue = isEditing ? editingValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
- const { navigateToSettings } = useSettingsNavigation()
const handleOpenChange = useCallback(
(isOpen: boolean) => {
@@ -199,21 +174,8 @@ export function CredentialSelector({
)
const handleAddCredential = useCallback(() => {
- writePendingCredentialCreateRequest({
- workspaceId,
- type: 'oauth',
- providerId: effectiveProviderId,
- displayName: '',
- serviceId,
- requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
- requestedAt: Date.now(),
- returnOrigin: activeWorkflowId
- ? { type: 'workflow', workflowId: activeWorkflowId }
- : undefined,
- })
-
- navigateToSettings({ section: 'integrations' })
- }, [workspaceId, effectiveProviderId, serviceId, activeWorkflowId])
+ setShowConnectModal(true)
+ }, [])
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -403,6 +365,18 @@ export function CredentialSelector({
)}
+ {showConnectModal && (
+ setShowConnectModal(false)}
+ provider={provider}
+ serviceId={serviceId}
+ workspaceId={workspaceId}
+ workflowId={activeWorkflowId || ''}
+ credentialCount={credentials.length}
+ />
+ )}
+
{showOAuthModal && (
{
@@ -71,11 +73,13 @@ export function ToolCredentialSelector({
const workspaceId = (params?.workspaceId as string) || ''
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
+ const [showConnectModal, setShowConnectModal] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
- const { activeWorkflowId } = useWorkflowRegistry()
- const { navigateToSettings } = useSettingsNavigation()
+ const { activeWorkflowId, workflows } = useWorkflowRegistry()
+ const effectiveWorkflowId =
+ activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined
const selectedId = value || ''
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
@@ -89,7 +93,7 @@ export function ToolCredentialSelector({
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
- workflowId: activeWorkflowId || undefined,
+ workflowId: effectiveWorkflowId,
})
const selectedCredential = useMemo(
@@ -97,36 +101,11 @@ export function ToolCredentialSelector({
[credentials, selectedId]
)
- const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState(null)
-
- useEffect(() => {
- if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
- setInaccessibleCredentialName(null)
- return
- }
-
- setInaccessibleCredentialName(null)
-
- let cancelled = false
- ;(async () => {
- try {
- const response = await fetch(
- `/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
- )
- if (!response.ok || cancelled) return
- const data = await response.json()
- if (!cancelled && data.credential?.displayName) {
- setInaccessibleCredentialName(data.credential.displayName)
- }
- } catch {
- // Ignore fetch errors
- }
- })()
-
- return () => {
- cancelled = true
- }
- }, [selectedId, selectedCredential, credentialsLoading, workspaceId])
+ const { data: inaccessibleCredential } = useWorkspaceCredential(
+ selectedId || undefined,
+ Boolean(selectedId) && !selectedCredential && !credentialsLoading && Boolean(workspaceId)
+ )
+ const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
@@ -164,18 +143,8 @@ export function ToolCredentialSelector({
)
const handleAddCredential = useCallback(() => {
- writePendingCredentialCreateRequest({
- workspaceId,
- type: 'oauth',
- providerId: effectiveProviderId,
- displayName: '',
- serviceId,
- requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
- requestedAt: Date.now(),
- })
-
- navigateToSettings({ section: 'integrations' })
- }, [workspaceId, effectiveProviderId, serviceId])
+ setShowConnectModal(true)
+ }, [])
const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({
@@ -261,6 +230,18 @@ export function ToolCredentialSelector({
)}
+ {showConnectModal && (
+
setShowConnectModal(false)}
+ provider={provider}
+ serviceId={serviceId}
+ workspaceId={workspaceId}
+ workflowId={effectiveWorkflowId || ''}
+ credentialCount={credentials.length}
+ />
+ )}
+
{showOAuthModal && (
(null)
const [draggedIndex, setDraggedIndex] = useState(null)
const [dragOverIndex, setDragOverIndex] = useState(null)
@@ -507,6 +495,9 @@ export const ToolInput = memo(function ToolInput({
const forceRefreshMcpTools = useForceRefreshMcpTools()
useMcpToolsEvents(workspaceId)
const { navigateToSettings } = useSettingsNavigation()
+ const createMcpServer = useCreateMcpServer()
+ const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
+ const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const mcpDataLoading = mcpLoading || mcpServersLoading
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
@@ -1379,7 +1370,7 @@ export const ToolInput = memo(function ToolInput({
icon: McpIcon,
onSelect: () => {
setOpen(false)
- navigateToSettings({ section: 'mcp' })
+ setMcpModalOpen(true)
},
disabled: isPreview,
})
@@ -2095,6 +2086,18 @@ export const ToolInput = memo(function ToolInput({
: undefined
}
/>
+
+ {
+ await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
+ }}
+ workspaceId={workspaceId}
+ availableEnvVars={availableEnvVars}
+ allowedMcpDomains={allowedMcpDomains}
+ />
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
index ff61e2ed52b..b9014e3b363 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
@@ -1,15 +1,19 @@
-import { Folder } from 'lucide-react'
+import type { MouseEvent as ReactMouseEvent } from 'react'
+import { Folder, MoreHorizontal, Plus } from 'lucide-react'
import Link from 'next/link'
import {
+ Blimp,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
+import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -17,19 +21,123 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
interface CollapsedSidebarMenuProps {
icon: React.ReactNode
hover: ReturnType
- onClick?: () => void
ariaLabel?: string
children: React.ReactNode
className?: string
+ primaryAction?: {
+ label: string
+ onSelect: () => void
+ }
+}
+
+interface CollapsedTaskFlyoutItemProps {
+ task: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
+ isCurrentRoute: boolean
+ isEditing?: boolean
+ editValue?: string
+ inputRef?: React.RefObject
+ isRenaming?: boolean
+ onEditValueChange?: (value: string) => void
+ onEditKeyDown?: (e: React.KeyboardEvent) => void
+ onEditBlur?: () => void
+ onContextMenu?: (e: ReactMouseEvent, taskId: string) => void
+ onMorePointerDown?: () => void
+ onMoreClick?: (e: ReactMouseEvent, taskId: string) => void
+}
+
+interface CollapsedWorkflowFlyoutItemProps {
+ workflow: WorkflowMetadata
+ href: string
+ isCurrentRoute?: boolean
+ isEditing?: boolean
+ editValue?: string
+ inputRef?: React.RefObject
+ isRenaming?: boolean
+ onEditValueChange?: (value: string) => void
+ onEditKeyDown?: (e: React.KeyboardEvent) => void
+ onEditBlur?: () => void
+ onContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
+ onMorePointerDown?: () => void
+ onMoreClick?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
+}
+
+const EDIT_ROW_CLASS =
+ 'mx-[2px] flex min-h-[30px] min-w-0 cursor-default select-none items-center gap-[8px] rounded-[5px] bg-[var(--surface-active)] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-body)]'
+
+function FlyoutMoreButton({
+ ariaLabel,
+ onPointerDown,
+ onClick,
+}: {
+ ariaLabel: string
+ onPointerDown?: () => void
+ onClick: (e: ReactMouseEvent) => void
+}) {
+ return (
+
+
+
+ )
+}
+
+function TaskStatusIcon({
+ isActive,
+ isUnread,
+ hideStatusOnHover = false,
+}: {
+ isActive?: boolean
+ isUnread?: boolean
+ hideStatusOnHover?: boolean
+}) {
+ return (
+
+
+ {isActive && (
+
+ )}
+ {!isActive && isUnread && (
+
+ )}
+
+ )
+}
+
+function WorkflowColorSwatch({ color }: { color: string }) {
+ return (
+
+ )
}
export function CollapsedSidebarMenu({
icon,
hover,
- onClick,
ariaLabel,
children,
className,
+ primaryAction,
}: CollapsedSidebarMenuProps) {
return (
@@ -47,13 +155,21 @@ export function CollapsedSidebarMenu({
type='button'
aria-label={ariaLabel}
className='mx-[2px] flex h-[30px] items-center rounded-[8px] px-[8px] hover:bg-[var(--surface-active)]'
- onClick={onClick}
>
{icon}
+ {primaryAction && (
+ <>
+
+
+ {primaryAction.label}
+
+
+ >
+ )}
{children}
@@ -61,14 +177,185 @@ export function CollapsedSidebarMenu({
)
}
+export function CollapsedTaskFlyoutItem({
+ task,
+ isCurrentRoute,
+ isEditing = false,
+ editValue,
+ inputRef,
+ isRenaming = false,
+ onEditValueChange,
+ onEditKeyDown,
+ onEditBlur,
+ onContextMenu,
+ onMorePointerDown,
+ onMoreClick,
+}: CollapsedTaskFlyoutItemProps) {
+ const showActions = task.id !== 'new' && onMoreClick
+
+ if (isEditing) {
+ return (
+
+
+ onEditValueChange?.(e.target.value)}
+ onKeyDown={onEditKeyDown}
+ onBlur={onEditBlur}
+ className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[12px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
+ maxLength={100}
+ disabled={isRenaming}
+ onClick={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ spellCheck='false'
+ />
+
+ )
+ }
+
+ return (
+
+ onContextMenu(e, task.id) : undefined
+ }
+ >
+
+
+ {showActions && (
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ onMoreClick?.(e, task.id)
+ }}
+ />
+ )}
+
+ )
+}
+
+export function CollapsedWorkflowFlyoutItem({
+ workflow,
+ href,
+ isCurrentRoute = false,
+ isEditing = false,
+ editValue,
+ inputRef,
+ isRenaming = false,
+ onEditValueChange,
+ onEditKeyDown,
+ onEditBlur,
+ onContextMenu,
+ onMorePointerDown,
+ onMoreClick,
+}: CollapsedWorkflowFlyoutItemProps) {
+ const showActions = !!onMoreClick
+
+ if (isEditing) {
+ return (
+
+
+ onEditValueChange?.(e.target.value)}
+ onKeyDown={onEditKeyDown}
+ onBlur={onEditBlur}
+ className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[12px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
+ maxLength={100}
+ disabled={isRenaming}
+ onClick={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ spellCheck='false'
+ />
+
+ )
+ }
+
+ return (
+
+ onContextMenu(e, workflow) : undefined}
+ >
+
+ {workflow.name}
+
+ {showActions && (
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ onMoreClick?.(e, workflow)
+ }}
+ />
+ )}
+
+ )
+}
+
export function CollapsedFolderItems({
nodes,
workflowsByFolder,
workspaceId,
+ currentWorkflowId,
+ editingWorkflowId,
+ editingValue,
+ editInputRef,
+ isRenamingWorkflow,
+ onEditValueChange,
+ onEditKeyDown,
+ onEditBlur,
+ onWorkflowContextMenu,
+ onWorkflowMorePointerDown,
+ onWorkflowMoreClick,
}: {
nodes: FolderTreeNode[]
workflowsByFolder: Record
workspaceId: string
+ currentWorkflowId?: string
+ editingWorkflowId?: string | null
+ editingValue?: string
+ editInputRef?: React.RefObject
+ isRenamingWorkflow?: boolean
+ onEditValueChange?: (value: string) => void
+ onEditKeyDown?: (e: React.KeyboardEvent) => void
+ onEditBlur?: () => void
+ onWorkflowContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
+ onWorkflowMorePointerDown?: () => void
+ onWorkflowMoreClick?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
}) {
return (
<>
@@ -96,21 +383,35 @@ export function CollapsedFolderItems({
nodes={folder.children}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
+ currentWorkflowId={currentWorkflowId}
+ editingWorkflowId={editingWorkflowId}
+ editingValue={editingValue}
+ editInputRef={editInputRef}
+ isRenamingWorkflow={isRenamingWorkflow}
+ onEditValueChange={onEditValueChange}
+ onEditKeyDown={onEditKeyDown}
+ onEditBlur={onEditBlur}
+ onWorkflowContextMenu={onWorkflowContextMenu}
+ onWorkflowMorePointerDown={onWorkflowMorePointerDown}
+ onWorkflowMoreClick={onWorkflowMoreClick}
/>
{folderWorkflows.map((workflow) => (
-
-
-
- {workflow.name}
-
-
+
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
index f122ee5de67..eb7970f3f3f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
@@ -1,6 +1,8 @@
export {
CollapsedFolderItems,
CollapsedSidebarMenu,
+ CollapsedTaskFlyoutItem,
+ CollapsedWorkflowFlyoutItem,
} from './collapsed-sidebar-menu/collapsed-sidebar-menu'
export { HelpModal } from './help-modal/help-modal'
export { NavItemContextMenu } from './nav-item-context-menu'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
index db86ce8e9c2..f18c961aa34 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
@@ -27,12 +27,12 @@ const SKELETON_SECTIONS = [3, 2, 2] as const
interface SettingsSidebarProps {
isCollapsed?: boolean
- showCollapsedContent?: boolean
+ showCollapsedTooltips?: boolean
}
export function SettingsSidebar({
isCollapsed = false,
- showCollapsedContent = false,
+ showCollapsedTooltips = false,
}: SettingsSidebarProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -74,55 +74,62 @@ export function SettingsSidebar({
}, [userId, ssoProvidersData?.providers, isLoadingSSO])
const navigationItems = useMemo(() => {
- return allNavigationItems.flatMap((item) => {
+ return allNavigationItems.filter((item) => {
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
- return []
+ return false
}
if (item.id === 'template-profile') {
- return []
+ return false
}
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
- return []
+ return false
}
if (item.id === 'mcp' && permissionConfig.disableMcpTools) {
- return []
+ return false
}
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) {
- return []
+ return false
}
if (item.id === 'skills' && permissionConfig.disableSkills) {
- return []
+ return false
}
if (item.selfHostedOverride && !isHosted) {
if (item.id === 'sso') {
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
- return !hasProviders || isSSOProviderOwner === true ? [{ ...item, disabled: false }] : []
+ return !hasProviders || isSSOProviderOwner === true
}
- return [{ ...item, disabled: false }]
+ return true
+ }
+
+ if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
+ return false
+ }
+
+ if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
+ return false
+ }
+
+ if (item.requiresMax && !subscriptionAccess.hasUsableMaxAccess && !item.showWhenLocked) {
+ return false
}
if (item.requiresHosted && !isHosted) {
- return []
+ return false
}
const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
const effectiveSuperUser = isSuperUser && superUserModeEnabled
if (item.requiresSuperUser && !effectiveSuperUser) {
- return []
+ return false
}
if (item.requiresAdminRole && !isSuperUser) {
- return []
+ return false
}
- const disabled =
- (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) ||
- (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) ||
- (item.requiresMax && !subscriptionAccess.hasUsableMaxAccess)
-
- return [{ ...item, disabled }]
+ return true
})
}, [
hasTeamPlan,
@@ -192,7 +199,7 @@ export function SettingsSidebar({
Back
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
Back
@@ -250,20 +257,22 @@ export function SettingsSidebar({
{sectionItems.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
- const disabled = Boolean(item.disabled)
+ const isLocked = item.requiresMax && !subscriptionAccess.hasUsableMaxAccess
const itemClassName = cn(
- 'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px]',
- disabled
- ? 'cursor-not-allowed opacity-50'
- : 'hover:bg-[var(--surface-active)]',
- active && !disabled && 'bg-[var(--surface-active)]'
+ 'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]',
+ active && 'bg-[var(--surface-active)]'
)
const content = (
<>
-
+
{item.label}
+ {isLocked && (
+
+ Max
+
+ )}
>
)
@@ -280,11 +289,9 @@ export function SettingsSidebar({
handlePrefetch(item.id)}
onFocus={() => handlePrefetch(item.id)}
onClick={() =>
- !disabled &&
router.replace(getSettingsHref({ section: item.id as SettingsSection }), {
scroll: false,
})
@@ -297,7 +304,7 @@ export function SettingsSidebar({
return (
{element}
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
{item.label}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
index ae179d5d79f..49e0caf5339 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
@@ -15,9 +15,11 @@ import {
import {
Check,
Duplicate,
+ Eye,
FolderPlus,
Lock,
LogOut,
+ Mail,
Palette,
Pencil,
Plus,
@@ -230,6 +232,8 @@ interface ContextMenuProps {
menuRef: React.RefObject
onClose: () => void
onOpenInNewTab?: () => void
+ onMarkAsRead?: () => void
+ onMarkAsUnread?: () => void
onRename?: () => void
onCreate?: () => void
onCreateFolder?: () => void
@@ -239,6 +243,8 @@ interface ContextMenuProps {
onColorChange?: (color: string) => void
currentColor?: string
showOpenInNewTab?: boolean
+ showMarkAsRead?: boolean
+ showMarkAsUnread?: boolean
showRename?: boolean
showCreate?: boolean
showCreateFolder?: boolean
@@ -246,6 +252,8 @@ interface ContextMenuProps {
showExport?: boolean
showColorChange?: boolean
disableExport?: boolean
+ disableMarkAsRead?: boolean
+ disableMarkAsUnread?: boolean
disableColorChange?: boolean
disableRename?: boolean
disableDuplicate?: boolean
@@ -259,6 +267,7 @@ interface ContextMenuProps {
showLock?: boolean
disableLock?: boolean
isLocked?: boolean
+ showDelete?: boolean
}
/**
@@ -271,6 +280,8 @@ export function ContextMenu({
menuRef,
onClose,
onOpenInNewTab,
+ onMarkAsRead,
+ onMarkAsUnread,
onRename,
onCreate,
onCreateFolder,
@@ -280,6 +291,8 @@ export function ContextMenu({
onColorChange,
currentColor,
showOpenInNewTab = false,
+ showMarkAsRead = false,
+ showMarkAsUnread = false,
showRename = true,
showCreate = false,
showCreateFolder = false,
@@ -287,6 +300,8 @@ export function ContextMenu({
showExport = false,
showColorChange = false,
disableExport = false,
+ disableMarkAsRead = false,
+ disableMarkAsUnread = false,
disableColorChange = false,
disableRename = false,
disableDuplicate = false,
@@ -300,6 +315,7 @@ export function ContextMenu({
showLock = false,
disableLock = false,
isLocked = false,
+ showDelete = true,
}: ContextMenuProps) {
const [hexInput, setHexInput] = useState(currentColor || '#ffffff')
@@ -346,6 +362,7 @@ export function ContextMenu({
}, [])
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
+ const hasStatusSection = (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread)
const hasEditSection =
(showRename && onRename) ||
(showCreate && onCreate) ||
@@ -387,7 +404,35 @@ export function ContextMenu({
Open in new tab
)}
- {hasNavigationSection && (hasEditSection || hasCopySection) && }
+ {hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && (
+
+ )}
+
+ {showMarkAsRead && onMarkAsRead && (
+ {
+ onMarkAsRead()
+ onClose()
+ }}
+ >
+
+ Mark as read
+
+ )}
+ {showMarkAsUnread && onMarkAsUnread && (
+ {
+ onMarkAsUnread()
+ onClose()
+ }}
+ >
+
+ Mark as unread
+
+ )}
+ {hasStatusSection && (hasEditSection || hasCopySection) && }
{showRename && onRename && (
)}
- {(hasNavigationSection || hasEditSection || hasCopySection) && }
+ {(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) &&
+ (showLeave || showDelete) && }
{showLeave && onLeave && (
)}
- {
- onDelete()
- onClose()
- }}
- >
-
- Delete
-
+ {showDelete && (
+ {
+ onDelete()
+ onClose()
+ }}
+ >
+
+ Delete
+
+ )}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts
index bdffa08fdf4..39646bc1295 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts
@@ -1,6 +1,7 @@
export { useAutoScroll } from './use-auto-scroll'
export { useContextMenu } from './use-context-menu'
export { type DropIndicator, useDragDrop } from './use-drag-drop'
+export { useFlyoutInlineRename } from './use-flyout-inline-rename'
export { useFolderExpand } from './use-folder-expand'
export { useFolderOperations } from './use-folder-operations'
export { useFolderSelection } from './use-folder-selection'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename.ts
new file mode 100644
index 00000000000..a492918d789
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename.ts
@@ -0,0 +1,101 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { createLogger } from '@sim/logger'
+
+const logger = createLogger('useFlyoutInlineRename')
+
+interface RenameTarget {
+ id: string
+ name: string
+}
+
+interface UseFlyoutInlineRenameProps {
+ itemType: string
+ onSave: (id: string, name: string) => Promise
+}
+
+export function useFlyoutInlineRename({ itemType, onSave }: UseFlyoutInlineRenameProps) {
+ const [editingTarget, setEditingTarget] = useState(null)
+ const [value, setValue] = useState('')
+ const [isSaving, setIsSaving] = useState(false)
+ const inputRef = useRef(null)
+ const cancelRequestedRef = useRef(false)
+ const isSavingRef = useRef(false)
+
+ useEffect(() => {
+ if (editingTarget && inputRef.current) {
+ inputRef.current.focus()
+ inputRef.current.select()
+ }
+ }, [editingTarget])
+
+ const startRename = useCallback((target: RenameTarget) => {
+ cancelRequestedRef.current = false
+ setEditingTarget(target)
+ setValue(target.name)
+ }, [])
+
+ const cancelRename = useCallback(() => {
+ cancelRequestedRef.current = true
+ setEditingTarget(null)
+ }, [])
+
+ const saveRename = useCallback(async () => {
+ if (cancelRequestedRef.current) {
+ cancelRequestedRef.current = false
+ return
+ }
+
+ if (!editingTarget || isSavingRef.current) {
+ return
+ }
+
+ const trimmedValue = value.trim()
+ if (!trimmedValue || trimmedValue === editingTarget.name) {
+ setEditingTarget(null)
+ return
+ }
+
+ isSavingRef.current = true
+ setIsSaving(true)
+ try {
+ await onSave(editingTarget.id, trimmedValue)
+ setEditingTarget(null)
+ } catch (error) {
+ logger.error(`Failed to rename ${itemType}:`, {
+ error,
+ itemId: editingTarget.id,
+ oldName: editingTarget.name,
+ newName: trimmedValue,
+ })
+ setValue(editingTarget.name)
+ } finally {
+ isSavingRef.current = false
+ setIsSaving(false)
+ }
+ }, [editingTarget, itemType, onSave, value])
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ void saveRename()
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ cancelRename()
+ }
+ },
+ [cancelRename, saveRename]
+ )
+
+ return {
+ editingId: editingTarget?.id ?? null,
+ value,
+ setValue,
+ isSaving,
+ inputRef,
+ startRename,
+ cancelRename,
+ saveRename,
+ handleKeyDown,
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts
index e48fc4c25da..9b494b80481 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts
@@ -12,6 +12,8 @@ const preventAutoFocus = (e: Event) => e.preventDefault()
export function useHoverMenu() {
const [isOpen, setIsOpen] = useState(false)
const closeTimerRef = useRef | null>(null)
+ const isLockedRef = useRef(false)
+ const hoverRegionCountRef = useRef(0)
const cancelClose = useCallback(() => {
if (closeTimerRef.current) {
@@ -29,8 +31,15 @@ export function useHoverMenu() {
}, [])
const scheduleClose = useCallback(() => {
+ if (isLockedRef.current) {
+ return
+ }
cancelClose()
- closeTimerRef.current = setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS)
+ closeTimerRef.current = setTimeout(() => {
+ if (!isLockedRef.current && hoverRegionCountRef.current === 0) {
+ setIsOpen(false)
+ }
+ }, CLOSE_DELAY_MS)
}, [cancelClose])
const open = useCallback(() => {
@@ -39,24 +48,64 @@ export function useHoverMenu() {
}, [cancelClose])
const close = useCallback(() => {
+ if (isLockedRef.current) {
+ return
+ }
cancelClose()
setIsOpen(false)
}, [cancelClose])
+ const setLocked = useCallback(
+ (locked: boolean) => {
+ isLockedRef.current = locked
+ cancelClose()
+ if (locked) {
+ setIsOpen(true)
+ } else if (hoverRegionCountRef.current === 0) {
+ setIsOpen(false)
+ }
+ },
+ [cancelClose]
+ )
+
+ const handleTriggerMouseEnter = useCallback(() => {
+ hoverRegionCountRef.current += 1
+ open()
+ }, [open])
+
+ const handleTriggerMouseLeave = useCallback(() => {
+ hoverRegionCountRef.current = Math.max(0, hoverRegionCountRef.current - 1)
+ scheduleClose()
+ }, [scheduleClose])
+
+ const handleContentMouseEnter = useCallback(() => {
+ hoverRegionCountRef.current += 1
+ cancelClose()
+ }, [cancelClose])
+
+ const handleContentMouseLeave = useCallback(() => {
+ hoverRegionCountRef.current = Math.max(0, hoverRegionCountRef.current - 1)
+ scheduleClose()
+ }, [scheduleClose])
+
const triggerProps = useMemo(
- () => ({ onMouseEnter: open, onMouseLeave: scheduleClose }) as const,
- [open, scheduleClose]
+ () =>
+ ({
+ onMouseEnter: handleTriggerMouseEnter,
+ onMouseLeave: handleTriggerMouseLeave,
+ }) as const,
+ [handleTriggerMouseEnter, handleTriggerMouseLeave]
)
const contentProps = useMemo(
() =>
({
- onMouseEnter: cancelClose,
- onMouseLeave: scheduleClose,
+ onMouseEnter: handleContentMouseEnter,
+ onMouseLeave: handleContentMouseLeave,
onCloseAutoFocus: preventAutoFocus,
}) as const,
- [cancelClose, scheduleClose]
+ [handleContentMouseEnter, handleContentMouseLeave]
)
- return { isOpen, open, close, triggerProps, contentProps }
+ return { isOpen, open, close, setLocked, triggerProps, contentProps }
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index d6726e23da4..8bdcf041264 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -33,10 +33,10 @@ import {
Settings,
Sim,
Table,
+ Wordmark,
} from '@/components/emcn/icons'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
-import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import {
START_NAV_TOUR_EVENT,
START_WORKFLOW_TOUR_EVENT,
@@ -47,6 +47,8 @@ import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-uti
import {
CollapsedFolderItems,
CollapsedSidebarMenu,
+ CollapsedTaskFlyoutItem,
+ CollapsedWorkflowFlyoutItem,
HelpModal,
NavItemContextMenu,
SearchModal,
@@ -58,6 +60,7 @@ import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import {
useContextMenu,
+ useFlyoutInlineRename,
useFolderOperations,
useHoverMenu,
useSidebarResize,
@@ -74,7 +77,14 @@ import {
} from '@/app/workspace/[workspaceId]/w/hooks'
import { getBrandConfig } from '@/ee/whitelabeling'
import { useFolders } from '@/hooks/queries/folders'
-import { useDeleteTask, useDeleteTasks, useRenameTask, useTasks } from '@/hooks/queries/tasks'
+import {
+ useDeleteTask,
+ useDeleteTasks,
+ useMarkTaskRead,
+ useMarkTaskUnread,
+ useRenameTask,
+ useTasks,
+} from '@/hooks/queries/tasks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useTaskEvents } from '@/hooks/use-task-events'
@@ -82,6 +92,7 @@ import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useFolderStore } from '@/stores/folders/store'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useSidebarStore } from '@/stores/sidebar/store'
+import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Sidebar')
@@ -99,7 +110,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
isSelected,
isActive,
isUnread,
- showCollapsedContent,
+ showCollapsedTooltips,
onMultiSelectClick,
onContextMenu,
onMorePointerDown,
@@ -110,7 +121,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
isSelected: boolean
isActive: boolean
isUnread: boolean
- showCollapsedContent: boolean
+ showCollapsedTooltips: boolean
onMultiSelectClick: (taskId: string, shiftKey: boolean, metaKey: boolean) => void
onContextMenu: (e: React.MouseEvent, taskId: string) => void
onMorePointerDown: () => void
@@ -171,7 +182,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
)}
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
{task.name}
@@ -191,12 +202,12 @@ interface SidebarNavItemData {
const SidebarNavItem = memo(function SidebarNavItem({
item,
active,
- showCollapsedContent,
+ showCollapsedTooltips,
onContextMenu,
}: {
item: SidebarNavItemData
active: boolean
- showCollapsedContent: boolean
+ showCollapsedTooltips: boolean
onContextMenu?: (e: React.MouseEvent, href: string) => void
}) {
const Icon = item.icon
@@ -245,7 +256,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
return (
{element}
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
{item.label}
@@ -296,7 +307,8 @@ export const Sidebar = memo(function Sidebar() {
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
const isOnWorkflowPage = !!workflowId
- const [showCollapsedContent, setShowCollapsedContent] = useState(isCollapsed)
+ // Delay collapsed tooltips until the width transition finishes.
+ const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed)
useLayoutEffect(() => {
if (!isCollapsed) {
@@ -306,10 +318,10 @@ export const Sidebar = memo(function Sidebar() {
useEffect(() => {
if (isCollapsed) {
- const timer = setTimeout(() => setShowCollapsedContent(true), 200)
+ const timer = setTimeout(() => setShowCollapsedTooltips(true), 200)
return () => clearTimeout(timer)
}
- setShowCollapsedContent(false)
+ setShowCollapsedTooltips(false)
}, [isCollapsed])
const workspaceFileInputRef = useRef(null)
@@ -398,6 +410,7 @@ export const Sidebar = memo(function Sidebar() {
useFolders(workspaceId)
const folders = useFolderStore((s) => s.folders)
const getFolderTree = useFolderStore((s) => s.getFolderTree)
+ const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const folderTree = useMemo(
() => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []),
@@ -450,7 +463,11 @@ export const Sidebar = memo(function Sidebar() {
const deleteTaskMutation = useDeleteTask(workspaceId)
const deleteTasksMutation = useDeleteTasks(workspaceId)
+ const markTaskReadMutation = useMarkTaskRead(workspaceId)
+ const markTaskUnreadMutation = useMarkTaskUnread(workspaceId)
const renameTaskMutation = useRenameTask(workspaceId)
+ const tasksHover = useHoverMenu()
+ const workflowsHover = useHoverMenu()
const {
isOpen: isTaskContextMenuOpen,
@@ -482,9 +499,11 @@ export const Sidebar = memo(function Sidebar() {
const handleTaskContextMenu = useCallback(
(e: React.MouseEvent, taskId: string) => {
captureTaskSelection(taskId)
+ tasksHover.setLocked(true)
+ preventTaskDismiss()
handleTaskContextMenuBase(e)
},
- [captureTaskSelection, handleTaskContextMenuBase]
+ [captureTaskSelection, handleTaskContextMenuBase, preventTaskDismiss, tasksHover]
)
const handleTaskMorePointerDown = useCallback(() => {
@@ -499,6 +518,7 @@ export const Sidebar = memo(function Sidebar() {
closeTaskContextMenu()
return
}
+ tasksHover.setLocked(true)
captureTaskSelection(taskId)
const rect = e.currentTarget.getBoundingClientRect()
handleTaskContextMenuBase({
@@ -508,7 +528,84 @@ export const Sidebar = memo(function Sidebar() {
clientY: rect.top,
} as React.MouseEvent)
},
- [isTaskContextMenuOpen, closeTaskContextMenu, captureTaskSelection, handleTaskContextMenuBase]
+ [
+ isTaskContextMenuOpen,
+ closeTaskContextMenu,
+ captureTaskSelection,
+ handleTaskContextMenuBase,
+ tasksHover,
+ ]
+ )
+
+ const {
+ isOpen: isCollapsedWorkflowContextMenuOpen,
+ position: collapsedWorkflowContextMenuPosition,
+ menuRef: collapsedWorkflowMenuRef,
+ handleContextMenu: handleCollapsedWorkflowContextMenuBase,
+ closeMenu: closeCollapsedWorkflowContextMenu,
+ preventDismiss: preventCollapsedWorkflowDismiss,
+ } = useContextMenu()
+
+ const collapsedWorkflowContextMenuRef = useRef<{
+ workflowId: string
+ workflowName: string
+ } | null>(null)
+
+ const captureCollapsedWorkflowSelection = useCallback(
+ (workflow: { id: string; name: string }) => {
+ collapsedWorkflowContextMenuRef.current = {
+ workflowId: workflow.id,
+ workflowName: workflow.name,
+ }
+ },
+ []
+ )
+
+ const handleCollapsedWorkflowContextMenu = useCallback(
+ (e: React.MouseEvent, workflow: { id: string; name: string }) => {
+ captureCollapsedWorkflowSelection(workflow)
+ workflowsHover.setLocked(true)
+ preventCollapsedWorkflowDismiss()
+ handleCollapsedWorkflowContextMenuBase(e)
+ },
+ [
+ captureCollapsedWorkflowSelection,
+ handleCollapsedWorkflowContextMenuBase,
+ preventCollapsedWorkflowDismiss,
+ workflowsHover,
+ ]
+ )
+
+ const handleCollapsedWorkflowMorePointerDown = useCallback(() => {
+ if (isCollapsedWorkflowContextMenuOpen) {
+ preventCollapsedWorkflowDismiss()
+ }
+ }, [isCollapsedWorkflowContextMenuOpen, preventCollapsedWorkflowDismiss])
+
+ const handleCollapsedWorkflowMoreClick = useCallback(
+ (e: React.MouseEvent, workflow: { id: string; name: string }) => {
+ if (isCollapsedWorkflowContextMenuOpen) {
+ closeCollapsedWorkflowContextMenu()
+ return
+ }
+
+ workflowsHover.setLocked(true)
+ captureCollapsedWorkflowSelection(workflow)
+ const rect = e.currentTarget.getBoundingClientRect()
+ handleCollapsedWorkflowContextMenuBase({
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ clientX: rect.right,
+ clientY: rect.top,
+ } as React.MouseEvent)
+ },
+ [
+ isCollapsedWorkflowContextMenuOpen,
+ closeCollapsedWorkflowContextMenu,
+ captureCollapsedWorkflowSelection,
+ handleCollapsedWorkflowContextMenuBase,
+ workflowsHover,
+ ]
)
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
@@ -653,6 +750,10 @@ export const Sidebar = memo(function Sidebar() {
const { selectedTasks, handleTaskClick } = useTaskSelection({ taskIds })
const isMultiTaskContextMenu = contextMenuSelectionRef.current.taskIds.length > 1
+ const activeTaskContextMenuItem =
+ !isMultiTaskContextMenu && contextMenuSelectionRef.current.taskIds.length === 1
+ ? tasks.find((task) => task.id === contextMenuSelectionRef.current.taskIds[0])
+ : null
const [isTaskDeleteModalOpen, setIsTaskDeleteModalOpen] = useState(false)
@@ -699,19 +800,31 @@ export const Sidebar = memo(function Sidebar() {
}, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, navigateToPage])
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
- const [renamingTaskId, setRenamingTaskId] = useState(null)
- const [renameValue, setRenameValue] = useState('')
- const tasksHover = useHoverMenu()
- const workflowsHover = useHoverMenu()
- const renameInputRef = useRef(null)
- const renameCanceledRef = useRef(false)
+ const taskFlyoutRename = useFlyoutInlineRename({
+ itemType: 'task',
+ onSave: async (taskId, name) => {
+ await renameTaskMutation.mutateAsync({ chatId: taskId, title: name })
+ },
+ })
+
+ const workflowFlyoutRename = useFlyoutInlineRename({
+ itemType: 'workflow',
+ onSave: async (workflowIdToRename, name) => {
+ await updateWorkflow(workflowIdToRename, { name })
+ collapsedWorkflowContextMenuRef.current = {
+ workflowId: workflowIdToRename,
+ workflowName: name,
+ }
+ },
+ })
useEffect(() => {
- if (renamingTaskId && renameInputRef.current) {
- renameInputRef.current.focus()
- renameInputRef.current.select()
- }
- }, [renamingTaskId])
+ tasksHover.setLocked(isTaskContextMenuOpen || !!taskFlyoutRename.editingId)
+ }, [isTaskContextMenuOpen, taskFlyoutRename.editingId, tasksHover.setLocked])
+
+ useEffect(() => {
+ workflowsHover.setLocked(isCollapsedWorkflowContextMenuOpen || !!workflowFlyoutRename.editingId)
+ }, [isCollapsedWorkflowContextMenuOpen, workflowFlyoutRename.editingId, workflowsHover.setLocked])
const handleTaskOpenInNewTab = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
@@ -719,51 +832,44 @@ export const Sidebar = memo(function Sidebar() {
window.open(`/workspace/${workspaceId}/task/${ids[0]}`, '_blank', 'noopener,noreferrer')
}, [workspaceId])
+ const handleMarkTaskAsRead = useCallback(() => {
+ const { taskIds: ids } = contextMenuSelectionRef.current
+ if (ids.length !== 1) return
+ markTaskReadMutation.mutate(ids[0])
+ }, [markTaskReadMutation])
+
+ const handleMarkTaskAsUnread = useCallback(() => {
+ const { taskIds: ids } = contextMenuSelectionRef.current
+ if (ids.length !== 1) return
+ markTaskUnreadMutation.mutate(ids[0])
+ }, [markTaskUnreadMutation])
+
const handleStartTaskRename = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
if (ids.length !== 1) return
const taskId = ids[0]
const task = tasks.find((t) => t.id === taskId)
if (!task) return
- renameCanceledRef.current = false
- setRenamingTaskId(taskId)
- setRenameValue(task.name)
- }, [tasks])
-
- const handleSaveTaskRename = useCallback(() => {
- if (renameCanceledRef.current) {
- renameCanceledRef.current = false
- return
- }
- const trimmed = renameValue.trim()
- if (!renamingTaskId || !trimmed) {
- setRenamingTaskId(null)
- return
- }
- const task = tasks.find((t) => t.id === renamingTaskId)
- if (task && trimmed !== task.name) {
- renameTaskMutation.mutate({ chatId: renamingTaskId, title: trimmed })
- }
- setRenamingTaskId(null)
- }, [renamingTaskId, renameValue, tasks, renameTaskMutation])
+ tasksHover.setLocked(true)
+ taskFlyoutRename.startRename({ id: taskId, name: task.name })
+ }, [taskFlyoutRename, tasks, tasksHover])
+
+ const handleCollapsedWorkflowOpenInNewTab = useCallback(() => {
+ const workflow = collapsedWorkflowContextMenuRef.current
+ if (!workflow) return
+ window.open(
+ `/workspace/${workspaceId}/w/${workflow.workflowId}`,
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ }, [workspaceId])
- const handleCancelTaskRename = useCallback(() => {
- renameCanceledRef.current = true
- setRenamingTaskId(null)
- }, [])
-
- const handleRenameKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault()
- handleSaveTaskRename()
- } else if (e.key === 'Escape') {
- e.preventDefault()
- handleCancelTaskRename()
- }
- },
- [handleSaveTaskRename, handleCancelTaskRename]
- )
+ const handleStartCollapsedWorkflowRename = useCallback(() => {
+ const workflow = collapsedWorkflowContextMenuRef.current
+ if (!workflow) return
+ workflowsHover.setLocked(true)
+ workflowFlyoutRename.startRename({ id: workflow.workflowId, name: workflow.workflowName })
+ }, [workflowFlyoutRename, workflowsHover])
const [hasOverflowTop, setHasOverflowTop] = useState(false)
const [hasOverflowBottom, setHasOverflowBottom] = useState(false)
@@ -998,14 +1104,34 @@ export const Sidebar = memo(function Sidebar() {
{/* Top bar: Logo + Collapse toggle */}
-
-
- {showCollapsedContent ? (
+
+
+ {brand.logoUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+
{brand.logoUrl ? (
- ) : (
-
- {brand.logoUrl ? (
-
- ) : (
-
- )}
-
+
+ {isCollapsed && (
+
+ Expand sidebar
+
)}
-
- {showCollapsedContent && (
-
- Expand sidebar
-
- )}
-
+
+
) : (
<>
@@ -1108,7 +1216,7 @@ export const Sidebar = memo(function Sidebar() {
key={`${item.id}-${isCollapsed}`}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
- showCollapsedContent={showCollapsedContent}
+ showCollapsedTooltips={showCollapsedTooltips}
onContextMenu={item.href ? handleNavItemContextMenu : undefined}
/>
))}
@@ -1125,7 +1233,7 @@ export const Sidebar = memo(function Sidebar() {
key={`${item.id}-${isCollapsed}`}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
- showCollapsedContent={showCollapsedContent}
+ showCollapsedTooltips={showCollapsedTooltips}
onContextMenu={handleNavItemContextMenu}
/>
))}
@@ -1169,9 +1277,12 @@ export const Sidebar = memo(function Sidebar() {
}
hover={tasksHover}
- onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
ariaLabel='Tasks'
className='mt-[6px]'
+ primaryAction={{
+ label: 'New task',
+ onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
+ }}
>
{tasksLoading ? (
@@ -1180,15 +1291,21 @@ export const Sidebar = memo(function Sidebar() {
) : (
tasks.map((task) => (
-
-
-
-
-
+ void taskFlyoutRename.saveRename()}
+ onContextMenu={handleTaskContextMenu}
+ onMorePointerDown={handleTaskMorePointerDown}
+ onMoreClick={handleTaskMoreClick}
+ />
))
)}
@@ -1200,7 +1317,7 @@ export const Sidebar = memo(function Sidebar() {
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
- const isRenaming = renamingTaskId === task.id
+ const isRenaming = taskFlyoutRename.editingId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
@@ -1211,11 +1328,11 @@ export const Sidebar = memo(function Sidebar() {
>
setRenameValue(e.target.value)}
- onKeyDown={handleRenameKeyDown}
- onBlur={handleSaveTaskRename}
+ ref={taskFlyoutRename.inputRef}
+ value={taskFlyoutRename.value}
+ onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
+ onKeyDown={taskFlyoutRename.handleKeyDown}
+ onBlur={() => void taskFlyoutRename.saveRename()}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
@@ -1230,7 +1347,7 @@ export const Sidebar = memo(function Sidebar() {
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
- showCollapsedContent={showCollapsedContent}
+ showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
@@ -1336,9 +1453,12 @@ export const Sidebar = memo(function Sidebar() {
/>
}
hover={workflowsHover}
- onClick={handleCreateWorkflow}
ariaLabel='Workflows'
className='mt-[6px]'
+ primaryAction={{
+ label: 'New workflow',
+ onSelect: handleCreateWorkflow,
+ }}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
@@ -1353,21 +1473,35 @@ export const Sidebar = memo(function Sidebar() {
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
+ currentWorkflowId={workflowId}
+ editingWorkflowId={workflowFlyoutRename.editingId}
+ editingValue={workflowFlyoutRename.value}
+ editInputRef={workflowFlyoutRename.inputRef}
+ isRenamingWorkflow={workflowFlyoutRename.isSaving}
+ onEditValueChange={workflowFlyoutRename.setValue}
+ onEditKeyDown={workflowFlyoutRename.handleKeyDown}
+ onEditBlur={() => void workflowFlyoutRename.saveRename()}
+ onWorkflowContextMenu={handleCollapsedWorkflowContextMenu}
+ onWorkflowMorePointerDown={handleCollapsedWorkflowMorePointerDown}
+ onWorkflowMoreClick={handleCollapsedWorkflowMoreClick}
/>
{(workflowsByFolder.root || []).map((workflow) => (
-
-
-
- {workflow.name}
-
-
+ void workflowFlyoutRename.saveRename()}
+ onContextMenu={handleCollapsedWorkflowContextMenu}
+ onMorePointerDown={handleCollapsedWorkflowMorePointerDown}
+ onMoreClick={handleCollapsedWorkflowMoreClick}
+ />
))}
>
)}
@@ -1417,7 +1551,7 @@ export const Sidebar = memo(function Sidebar() {
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
Help
@@ -1448,7 +1582,7 @@ export const Sidebar = memo(function Sidebar() {
key={`${item.id}-${isCollapsed}`}
item={item}
active={false}
- showCollapsedContent={showCollapsedContent}
+ showCollapsedTooltips={showCollapsedTooltips}
onContextMenu={item.href ? handleNavItemContextMenu : undefined}
/>
))}
@@ -1471,9 +1605,17 @@ export const Sidebar = memo(function Sidebar() {
menuRef={taskMenuRef}
onClose={closeTaskContextMenu}
onOpenInNewTab={handleTaskOpenInNewTab}
+ onMarkAsRead={handleMarkTaskAsRead}
+ onMarkAsUnread={handleMarkTaskAsUnread}
onRename={handleStartTaskRename}
onDelete={handleDeleteTask}
showOpenInNewTab={!isMultiTaskContextMenu}
+ showMarkAsRead={!isMultiTaskContextMenu && !!activeTaskContextMenuItem?.isUnread}
+ showMarkAsUnread={
+ !isMultiTaskContextMenu &&
+ !!activeTaskContextMenuItem &&
+ !activeTaskContextMenuItem.isUnread
+ }
showRename={!isMultiTaskContextMenu}
showDuplicate={false}
showColorChange={false}
@@ -1481,6 +1623,22 @@ export const Sidebar = memo(function Sidebar() {
disableDelete={!canEdit}
/>
+ {}}
+ showOpenInNewTab={true}
+ showRename={true}
+ showDuplicate={false}
+ showColorChange={false}
+ showDelete={false}
+ disableRename={!canEdit}
+ />
+
{/* Task Delete Confirmation Modal */}
) {
+ const gradientId = useId()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts
index c668309abf0..44c0a6e68ed 100644
--- a/apps/sim/hooks/queries/credentials.ts
+++ b/apps/sim/hooks/queries/credentials.ts
@@ -138,6 +138,28 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) {
})
}
+export function useCreateCredentialDraft() {
+ return useMutation({
+ mutationFn: async (payload: {
+ workspaceId: string
+ providerId: string
+ displayName: string
+ description?: string
+ credentialId?: string
+ }) => {
+ const response = await fetch('/api/credentials/draft', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || 'Failed to create credential draft')
+ }
+ },
+ })
+}
+
export function useCreateWorkspaceCredential() {
const queryClient = useQueryClient()
@@ -165,7 +187,7 @@ export function useCreateWorkspaceCredential() {
return response.json()
},
- onSuccess: () => {
+ onSettled: () => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.lists(),
})
@@ -198,7 +220,7 @@ export function useUpdateWorkspaceCredential() {
}
return response.json()
},
- onSuccess: (_data, variables) => {
+ onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
})
@@ -223,7 +245,7 @@ export function useDeleteWorkspaceCredential() {
}
return response.json()
},
- onSuccess: (_data, credentialId) => {
+ onSettled: (_data, _error, credentialId) => {
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) })
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
@@ -269,7 +291,7 @@ export function useUpsertWorkspaceCredentialMember() {
}
return response.json()
},
- onSuccess: (_data, variables) => {
+ onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.members(variables.credentialId),
})
@@ -295,7 +317,7 @@ export function useRemoveWorkspaceCredentialMember() {
}
return response.json()
},
- onSuccess: (_data, variables) => {
+ onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.members(variables.credentialId),
})
diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts
index 6e071b60588..e667393f588 100644
--- a/apps/sim/hooks/queries/tasks.ts
+++ b/apps/sim/hooks/queries/tasks.ts
@@ -133,7 +133,7 @@ export async function fetchChatHistory(
chatId: string,
signal?: AbortSignal
): Promise {
- const response = await fetch(`/api/copilot/chat?chatId=${chatId}`, { signal })
+ const response = await fetch(`/api/mothership/chats/${chatId}`, { signal })
if (!response.ok) {
throw new Error('Failed to load chat')
@@ -164,10 +164,8 @@ export function useChatHistory(chatId: string | undefined) {
}
async function deleteTask(chatId: string): Promise {
- const response = await fetch('/api/copilot/chat/delete', {
+ const response = await fetch(`/api/mothership/chats/${chatId}`, {
method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ chatId }),
})
if (!response.ok) {
throw new Error('Failed to delete task')
@@ -207,10 +205,10 @@ export function useDeleteTasks(workspaceId?: string) {
}
async function renameTask({ chatId, title }: { chatId: string; title: string }): Promise {
- const response = await fetch('/api/copilot/chat/rename', {
+ const response = await fetch(`/api/mothership/chats/${chatId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ chatId, title }),
+ body: JSON.stringify({ title }),
})
if (!response.ok) {
throw new Error('Failed to rename task')
@@ -382,16 +380,27 @@ export function useRemoveChatResource(chatId?: string) {
}
async function markTaskRead(chatId: string): Promise {
- const response = await fetch('/api/mothership/chats/read', {
- method: 'POST',
+ const response = await fetch(`/api/mothership/chats/${chatId}`, {
+ method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ chatId }),
+ body: JSON.stringify({ isUnread: false }),
})
if (!response.ok) {
throw new Error('Failed to mark task as read')
}
}
+async function markTaskUnread(chatId: string): Promise {
+ const response = await fetch(`/api/mothership/chats/${chatId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ isUnread: true }),
+ })
+ if (!response.ok) {
+ throw new Error('Failed to mark task as unread')
+ }
+}
+
/**
* Marks a task as read with optimistic update.
*/
@@ -420,3 +429,32 @@ export function useMarkTaskRead(workspaceId?: string) {
},
})
}
+
+/**
+ * Marks a task as unread with optimistic update.
+ */
+export function useMarkTaskUnread(workspaceId?: string) {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: markTaskUnread,
+ onMutate: async (chatId) => {
+ await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
+
+ const previousTasks = queryClient.getQueryData(taskKeys.list(workspaceId))
+
+ queryClient.setQueryData(taskKeys.list(workspaceId), (old) =>
+ old?.map((task) => (task.id === chatId ? { ...task, isUnread: true } : task))
+ )
+
+ return { previousTasks }
+ },
+ onError: (_err, _variables, context) => {
+ if (context?.previousTasks) {
+ queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks)
+ }
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
+ },
+ })
+}
diff --git a/apps/sim/public/logo/wordmark-dark.svg b/apps/sim/public/logo/wordmark-dark.svg
new file mode 100644
index 00000000000..8d35b8159b6
--- /dev/null
+++ b/apps/sim/public/logo/wordmark-dark.svg
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/sim/public/logo/wordmark.svg b/apps/sim/public/logo/wordmark.svg
new file mode 100644
index 00000000000..85b9625cbaf
--- /dev/null
+++ b/apps/sim/public/logo/wordmark.svg
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 1a14f4c13d1738559b782f92c3c9c892ee9374c2 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 25 Mar 2026 20:28:15 -0700
Subject: [PATCH 17/20] fix chatHistory reconnect effect
---
.../[workspaceId]/home/hooks/use-chat.ts | 108 +++++++++---------
1 file changed, 56 insertions(+), 52 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 05fe1df81b1..62723265b55 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -845,44 +845,62 @@ export function useChat(
const assistantId = crypto.randomUUID()
const reconnect = async () => {
- try {
- const result = await attachToExistingStream({
- streamId: activeStreamId,
- assistantId,
- expectedGen: gen,
- snapshot,
- initialLastEventId: lastEventIdRef.current,
- })
- if (streamGenRef.current === gen && !result.aborted) {
- finalizeRef.current(result.error ? { error: true } : undefined)
+ let lastAttemptError: string | undefined
+
+ for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
+ if (streamGenRef.current !== gen) return
+ if (abortControllerRef.current?.signal.aborted) return
+
+ if (attempt > 0) {
+ const delayMs = Math.min(
+ RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1),
+ RECONNECT_MAX_DELAY_MS
+ )
+ logger.error('Reconnect attempt (chatHistory)', {
+ streamId: activeStreamId,
+ attempt,
+ maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ delayMs,
+ error: lastAttemptError,
+ })
+ setIsReconnecting(true)
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
+ if (streamGenRef.current !== gen) return
+ if (abortControllerRef.current?.signal.aborted) return
}
- } catch (err) {
- if (err instanceof Error && err.name === 'AbortError') return
- logger.warn('Unexpected error during reconnect', {
- streamId: activeStreamId,
- chatId: chatHistory.id,
- error: err instanceof Error ? err.message : String(err),
- })
- if (streamGenRef.current === gen) {
- try {
- finalizeRef.current({ error: true })
- } catch (finalizeError) {
- logger.error('Reconnect fallback finalize failed', {
- streamId: activeStreamId,
- chatId: chatHistory.id,
- error:
- finalizeError instanceof Error ? finalizeError.message : String(finalizeError),
- })
- sendingRef.current = false
- setIsSending(false)
- setIsReconnecting(false)
- abortControllerRef.current = null
- setError('Failed to reconnect to the active stream')
+
+ try {
+ const result = await attachToExistingStream({
+ streamId: activeStreamId,
+ assistantId,
+ expectedGen: gen,
+ snapshot: attempt === 0 ? snapshot : undefined,
+ initialLastEventId: lastEventIdRef.current,
+ })
+ if (streamGenRef.current === gen && !result.aborted) {
+ finalizeRef.current(result.error ? { error: true } : undefined)
}
+ return
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') return
+ lastAttemptError = err instanceof Error ? err.message : String(err)
}
- } finally {
- if (abortControllerRef.current === abortController) {
+ }
+
+ logger.error('All reconnect attempts exhausted (chatHistory)', {
+ streamId: activeStreamId,
+ maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ })
+ setIsReconnecting(false)
+ if (streamGenRef.current === gen) {
+ try {
+ finalizeRef.current({ error: true })
+ } catch {
+ sendingRef.current = false
+ setIsSending(false)
+ setIsReconnecting(false)
abortControllerRef.current = null
+ setError('Failed to reconnect to the active stream')
}
}
}
@@ -1799,19 +1817,8 @@ export function useChat(
})
}
} catch (err) {
- if (err instanceof Error && err.name === 'AbortError') {
- logger.error('Send aborted (AbortError)', { streamId: userMessageId })
- return
- }
+ if (err instanceof Error && err.name === 'AbortError') return
const errorMessage = err instanceof Error ? err.message : 'Failed to send message'
- logger.error('Send catch block entered', {
- streamId: userMessageId,
- errorMessage,
- errorName: err instanceof Error ? err.name : 'unknown',
- gen,
- currentGen: streamGenRef.current,
- hasActiveStreamId: Boolean(streamIdRef.current),
- })
if (requestChatId && isActiveStreamConflictError(errorMessage)) {
logger.info('Active stream conflict detected while sending message; reattaching', {
chatId: requestChatId,
@@ -1870,12 +1877,9 @@ export function useChat(
const activeStreamId = streamIdRef.current
if (activeStreamId && streamGenRef.current === gen) {
- const reconnectController = new AbortController()
- abortControllerRef.current = reconnectController
-
for (let attempt = 0; attempt < MAX_RECONNECT_ATTEMPTS; attempt++) {
if (streamGenRef.current !== gen) return
- if (reconnectController.signal.aborted) return
+ if (abortControllerRef.current?.signal.aborted) return
const delayMs = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS)
logger.info('Reconnect attempt after network error', {
@@ -1890,7 +1894,7 @@ export function useChat(
await new Promise((resolve) => setTimeout(resolve, delayMs))
if (streamGenRef.current !== gen) return
- if (reconnectController.signal.aborted) return
+ if (abortControllerRef.current?.signal.aborted) return
try {
await resumeOrFinalize({
@@ -1898,7 +1902,7 @@ export function useChat(
assistantId,
gen,
fromEventId: lastEventIdRef.current,
- signal: reconnectController.signal,
+ signal: abortControllerRef.current?.signal,
})
return
} catch (reconnectErr) {
From 9e4fc5024ff6e73415264f0fa5e408c12c764d1c Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 25 Mar 2026 20:32:39 -0700
Subject: [PATCH 18/20] fix(retry): extract code into callback
---
.../[workspaceId]/home/hooks/use-chat.ts | 168 +++++++++---------
1 file changed, 80 insertions(+), 88 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 62723265b55..4bc03673e9f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -435,6 +435,14 @@ export function useChat(
lastEventId: 0,
}))
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
+ const retryReconnectRef = useRef<
+ (opts: {
+ streamId: string
+ assistantId: string
+ gen: number
+ initialSnapshot?: StreamSnapshot | null
+ }) => Promise
+ >(async () => false)
const abortControllerRef = useRef(null)
const streamReaderRef = useRef | null>(null)
@@ -845,54 +853,13 @@ export function useChat(
const assistantId = crypto.randomUUID()
const reconnect = async () => {
- let lastAttemptError: string | undefined
-
- for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
- if (streamGenRef.current !== gen) return
- if (abortControllerRef.current?.signal.aborted) return
-
- if (attempt > 0) {
- const delayMs = Math.min(
- RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1),
- RECONNECT_MAX_DELAY_MS
- )
- logger.error('Reconnect attempt (chatHistory)', {
- streamId: activeStreamId,
- attempt,
- maxAttempts: MAX_RECONNECT_ATTEMPTS,
- delayMs,
- error: lastAttemptError,
- })
- setIsReconnecting(true)
- await new Promise((resolve) => setTimeout(resolve, delayMs))
- if (streamGenRef.current !== gen) return
- if (abortControllerRef.current?.signal.aborted) return
- }
-
- try {
- const result = await attachToExistingStream({
- streamId: activeStreamId,
- assistantId,
- expectedGen: gen,
- snapshot: attempt === 0 ? snapshot : undefined,
- initialLastEventId: lastEventIdRef.current,
- })
- if (streamGenRef.current === gen && !result.aborted) {
- finalizeRef.current(result.error ? { error: true } : undefined)
- }
- return
- } catch (err) {
- if (err instanceof Error && err.name === 'AbortError') return
- lastAttemptError = err instanceof Error ? err.message : String(err)
- }
- }
-
- logger.error('All reconnect attempts exhausted (chatHistory)', {
+ const succeeded = await retryReconnectRef.current({
streamId: activeStreamId,
- maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ assistantId,
+ gen,
+ initialSnapshot: snapshot,
})
- setIsReconnecting(false)
- if (streamGenRef.current === gen) {
+ if (!succeeded && streamGenRef.current === gen) {
try {
finalizeRef.current({ error: true })
} catch {
@@ -906,7 +873,7 @@ export function useChat(
}
reconnect()
}
- }, [applyChatHistorySnapshot, attachToExistingStream, chatHistory, queryClient])
+ }, [applyChatHistorySnapshot, chatHistory, queryClient])
const processSSEStream = useCallback(
async (
@@ -1672,6 +1639,67 @@ export function useChat(
[fetchStreamBatch, attachToExistingStream, finalize]
)
+ const retryReconnect = useCallback(
+ async (opts: {
+ streamId: string
+ assistantId: string
+ gen: number
+ initialSnapshot?: StreamSnapshot | null
+ }): Promise => {
+ const { streamId, assistantId, gen, initialSnapshot } = opts
+
+ for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
+ if (streamGenRef.current !== gen) return true
+ if (abortControllerRef.current?.signal.aborted) return true
+
+ if (attempt > 0) {
+ const delayMs = Math.min(
+ RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1),
+ RECONNECT_MAX_DELAY_MS
+ )
+ logger.warn('Reconnect attempt', {
+ streamId,
+ attempt,
+ maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ delayMs,
+ })
+ setIsReconnecting(true)
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
+ if (streamGenRef.current !== gen) return true
+ if (abortControllerRef.current?.signal.aborted) return true
+ }
+
+ try {
+ await resumeOrFinalize({
+ streamId,
+ assistantId,
+ gen,
+ fromEventId: lastEventIdRef.current,
+ snapshot: attempt === 0 ? initialSnapshot : undefined,
+ signal: abortControllerRef.current?.signal,
+ })
+ return true
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') return true
+ logger.warn('Reconnect attempt failed', {
+ streamId,
+ attempt: attempt + 1,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+
+ logger.error('All reconnect attempts exhausted', {
+ streamId,
+ maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ })
+ setIsReconnecting(false)
+ return false
+ },
+ [resumeOrFinalize]
+ )
+ retryReconnectRef.current = retryReconnect
+
const sendMessage = useCallback(
async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
if (!message.trim() || !workspaceId) return
@@ -1877,49 +1905,12 @@ export function useChat(
const activeStreamId = streamIdRef.current
if (activeStreamId && streamGenRef.current === gen) {
- for (let attempt = 0; attempt < MAX_RECONNECT_ATTEMPTS; attempt++) {
- if (streamGenRef.current !== gen) return
- if (abortControllerRef.current?.signal.aborted) return
-
- const delayMs = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS)
- logger.info('Reconnect attempt after network error', {
- streamId: activeStreamId,
- attempt: attempt + 1,
- maxAttempts: MAX_RECONNECT_ATTEMPTS,
- delayMs,
- error: errorMessage,
- })
-
- setIsReconnecting(true)
- await new Promise((resolve) => setTimeout(resolve, delayMs))
-
- if (streamGenRef.current !== gen) return
- if (abortControllerRef.current?.signal.aborted) return
-
- try {
- await resumeOrFinalize({
- streamId: activeStreamId,
- assistantId,
- gen,
- fromEventId: lastEventIdRef.current,
- signal: abortControllerRef.current?.signal,
- })
- return
- } catch (reconnectErr) {
- if (reconnectErr instanceof Error && reconnectErr.name === 'AbortError') return
- logger.warn('Reconnect attempt failed', {
- streamId: activeStreamId,
- attempt: attempt + 1,
- error: reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr),
- })
- }
- }
-
- logger.error('All reconnect attempts exhausted', {
+ const succeeded = await retryReconnect({
streamId: activeStreamId,
- maxAttempts: MAX_RECONNECT_ATTEMPTS,
+ assistantId,
+ gen,
})
- setIsReconnecting(false)
+ if (succeeded) return
}
setError(errorMessage)
@@ -1935,6 +1926,7 @@ export function useChat(
processSSEStream,
finalize,
resumeOrFinalize,
+ retryReconnect,
preparePendingStreamRecovery,
]
)
From d97e22e395d13507e6738720aff6145925b45313 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 25 Mar 2026 20:53:27 -0700
Subject: [PATCH 19/20] chore(docs): update readme (#3778)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 6738087611d..831361fb3ed 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
-
+
From a9fc1a24a9a7da2195bf3de4017a1392aca08608 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 25 Mar 2026 21:00:44 -0700
Subject: [PATCH 20/20] fix(guard-change): run finalize at right time
---
.../[workspaceId]/home/hooks/use-chat.ts | 15 ++++++++++-----
.../settings/[section]/settings.tsx | 7 +++++--
.../[workspaceId]/settings/navigation.ts | 19 +++++++++++--------
3 files changed, 26 insertions(+), 15 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 4bc03673e9f..b52923705a8 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -740,13 +740,12 @@ export function useChat(
return { aborted: true, error: false }
}
- logger.warn('Failed to attach to existing stream', {
+ logger.error('Failed to attach to existing stream, will throw for outer retry', {
streamId,
latestEventId,
error: err instanceof Error ? err.message : String(err),
})
- setError(err instanceof Error ? err.message : RECONNECT_TAIL_ERROR)
- return { aborted: false, error: true }
+ throw err
} finally {
setIsReconnecting(false)
}
@@ -833,11 +832,17 @@ export function useChat(
)
useEffect(() => {
- if (!chatHistory || appliedChatIdRef.current === chatHistory.id) return
+ if (!chatHistory) return
const activeStreamId = chatHistory.activeStreamId
const snapshot = chatHistory.streamSnapshot
- applyChatHistorySnapshot(chatHistory, { preserveActiveStreamingMessage: true })
+ const isNewChat = appliedChatIdRef.current !== chatHistory.id
+
+ if (isNewChat) {
+ applyChatHistorySnapshot(chatHistory, { preserveActiveStreamingMessage: true })
+ } else if (!activeStreamId || sendingRef.current) {
+ return
+ }
if (activeStreamId && !sendingRef.current) {
const gen = ++streamGenRef.current
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
index fd01097fde9..df2f2fcdcd9 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
@@ -20,6 +20,7 @@ import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/nav
import {
allNavigationItems,
isBillingEnabled,
+ isCredentialSetsEnabled,
} from '@/app/workspace/[workspaceId]/settings/navigation'
/**
@@ -164,9 +165,11 @@ export function SettingsPage({ section }: SettingsPageProps) {
const effectiveSection =
!isBillingEnabled && (section === 'subscription' || section === 'team')
? 'general'
- : section === 'admin' && !sessionLoading && !isAdminRole
+ : section === 'credential-sets' && !isCredentialSetsEnabled
? 'general'
- : section
+ : section === 'admin' && !sessionLoading && !isAdminRole
+ ? 'general'
+ : section
const label =
allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
index 1e564ed0598..f97e6768665 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
@@ -72,6 +72,7 @@ const isAccessControlEnabled = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABL
const isInboxEnabled = isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED'))
export const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
+export { isCredentialSetsEnabled }
export const sectionConfig: { key: NavigationSection; title: string }[] = [
{ key: 'account', title: 'Account' },
@@ -141,14 +142,16 @@ export const allNavigationItems: NavigationItem[] = [
selfHostedOverride: isInboxEnabled,
showWhenLocked: true,
},
- {
- id: 'credential-sets',
- label: 'Email Polling',
- icon: Mail,
- section: 'system',
- requiresHosted: true,
- selfHostedOverride: isCredentialSetsEnabled,
- },
+ ...(isCredentialSetsEnabled
+ ? [
+ {
+ id: 'credential-sets' as const,
+ label: 'Email Polling',
+ icon: Mail,
+ section: 'system' as const,
+ },
+ ]
+ : []),
{ id: 'recently-deleted', label: 'Recently Deleted', icon: TrashOutline, section: 'system' },
{
id: 'sso',