diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts
index 1a5f8d643be..e04b0aac04b 100644
--- a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts
+++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts
@@ -292,7 +292,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
const res = await POST(request(SELF_HOSTED_WORKSPACELESS_VALIDATE_BODY))
expect(res.status).toBe(200)
- expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1')
+ expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled()
expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
})
diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts
index e52ba5e5dde..1a159072395 100644
--- a/apps/sim/app/api/copilot/api-keys/validate/route.ts
+++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts
@@ -26,6 +26,7 @@ import {
serializeAccountBillingDecisionHeader,
serializeBillingAttributionHeader,
} from '@/lib/billing/core/attribution'
+import { payerHasChatAccess } from '@/lib/billing/core/chat-access'
import {
isFreeRunPayer,
readMidRunAccountUsageVerdict,
@@ -34,7 +35,7 @@ import {
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprisePlan } from '@/lib/billing/core/subscription'
import { deriveBillingContext } from '@/lib/billing/core/usage-log'
-import { resolveUsageUpgradePayload } from '@/lib/billing/usage-upgrade'
+import { resolveUsageUpgradePayload, type UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -246,11 +247,13 @@ function resolveContinuationBilling(
}
}
+/** The admitted payer's usage standing, and the payer admission reads to decide Chat access. */
async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise<{
isExceeded: boolean
currentUsage: number
limit: number
scope: string
+ payer: UsageUpgradePayer
accountBillingDecision?: AccountBillingDecision
}> {
if (admission.kind === 'attributed' || admission.kind === 'legacy-scoped') {
@@ -262,6 +265,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
currentUsage: enforcedUsage?.currentUsage ?? 0,
limit: enforcedUsage?.limit ?? 0,
scope: usage.scope ?? 'payer',
+ payer: admission.attribution,
}
}
@@ -276,6 +280,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
currentUsage: usage.currentUsage,
limit: usage.limit,
scope: 'account',
+ payer: { billingEntity: billingContext.billingEntity, payerSubscription: subscription },
accountBillingDecision: {
userId: admission.userId,
billingEntity: billingContext.billingEntity,
@@ -291,12 +296,17 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
}
}
- const usage = await checkServerSideUsageLimits(admission.userId)
+ const subscription = await getHighestPrioritySubscription(admission.userId)
+ const usage = await checkServerSideUsageLimits(admission.userId, subscription)
return {
isExceeded: usage.isExceeded,
currentUsage: usage.currentUsage,
limit: usage.limit,
scope: 'account',
+ payer: {
+ billingEntity: deriveBillingContext(admission.userId, subscription).billingEntity,
+ payerSubscription: subscription,
+ },
}
}
@@ -528,6 +538,16 @@ export const POST = withRouteHandler((req: NextRequest) =>
: (usage.accountBillingDecision?.billingEntity ?? { type: 'account', id: userId }),
})
+ // A self-hosted or key-based turn is billed to this hosted payer, whose free plan does
+ // not include Chat. The refusal is the admission 402 every caller already renders as
+ // the upgrade card.
+ if (!(await payerHasChatAccess(usage.payer))) {
+ logger.info('[API VALIDATION] Free plan does not include Chat', { userId })
+ span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.UsageExceeded)
+ span.setAttribute(TraceAttr.HttpStatusCode, 402)
+ return new NextResponse(null, { status: 402 })
+ }
+
if (usage.isExceeded) {
logger.info('[API VALIDATION] Usage exceeded', {
userId,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index 7d55d929cf4..1e78416c8f0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -33,7 +33,12 @@ import { useParams } from 'next/navigation'
import { getOrganizationSettingsHref } from '@/components/settings/navigation'
import { useSettingsNavigationTarget } from '@/components/settings/settings-navigation-provider'
import { useSession } from '@/lib/auth/client'
-import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
+import { CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan'
+import {
+ buildHostedUpgradeUrl,
+ buildUpgradeHref,
+ HOSTED_BILLING_SETTINGS_URL,
+} from '@/lib/billing/upgrade-reasons'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
@@ -3491,7 +3496,10 @@ interface PlanUsageLimitDisplayProps {
data: UsageUpgradeTagData
}
-/** The payer's plan limit: the card links to where the plan or the limit can change. */
+/**
+ * The payer's plan limit, or a free plan that does not include Chat: the card links to where
+ * the plan or the limit can change, and a workspace's plan to its upgrade page.
+ */
function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const { data: session } = useSession()
const hostContext = useOptionalWorkspaceHostContext()
@@ -3500,6 +3508,7 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const { hosted } = useDeploymentShape()
const navigationTarget = useSettingsNavigationTarget()
const opensInNewTab = !hosted || navigationTarget === '_blank'
+ const planRequired = data.reason === CHAT_PLAN_REQUIRED_REASON
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
// Self-hosted plan and limit both live on the hosted account, so local
@@ -3507,7 +3516,9 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const href = hosted
? organizationContext
? getOrganizationSettingsHref(organizationContext.organization.id, 'billing')
- : getSettingsHref({ section: 'billing' })
+ : planRequired && hostContext
+ ? buildUpgradeHref(hostContext.workspace.id)
+ : getSettingsHref({ section: 'billing' })
: data.action === 'upgrade_plan'
? buildHostedUpgradeUrl()
: HOSTED_BILLING_SETTINGS_URL
@@ -3519,11 +3530,15 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const usageGate = useWorkspaceUsageGate(
data.action === 'increase_limit' && !canManageBilling ? hostContext?.workspace.id : undefined
)
- const unavailableMessage = organizationContext
- ? 'Contact an organization admin to manage usage limits.'
- : hostContext?.hostOrganizationId
- ? 'Contact an organization admin to manage this workspace’s usage limits.'
- : 'Only the workspace owner can manage this workspace’s usage limits.'
+ const action = planRequired
+ ? 'upgrade this workspace’s plan'
+ : 'manage this workspace’s usage limits'
+ const unavailableMessage =
+ organizationContext && !planRequired
+ ? 'Contact an organization admin to manage usage limits.'
+ : organizationContext || hostContext?.hostOrganizationId
+ ? `Contact an organization admin to ${action}.`
+ : `Only the workspace owner can ${action}.`
return (
- Usage Limit Reached
+ {planRequired ? 'Paid Plan Required' : 'Usage Limit Reached'}
}
>
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 34d9d043b6a..3595beb2823 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
@@ -35,6 +35,7 @@ import { InputToolbar } from '@/app/workspace/[workspaceId]/home/components/user
import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history'
import { handleMothershipAddContextEvent } from '@/app/workspace/[workspaceId]/home/components/user-input/mothership-context-event'
+import { useChatPlanGate } from '@/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate'
import type {
ChatMessage,
FileAttachmentForApi,
@@ -507,6 +508,9 @@ const UserInputImpl = forwardRef(function UserI
textareaRef.current?.focus()
}
+ /** Refuses a send the workspace plan excludes here, before `onSubmit`, so the draft survives. */
+ const stopSendWithoutChatPlan = useChatPlanGate()
+
/** Empties the text, chips, attachments, transcript, and the saved draft in one step. */
const clearComposer = useCallback(() => {
history.reset()
@@ -527,6 +531,7 @@ const UserInputImpl = forwardRef(function UserI
}, [resetTranscript, history.reset])
const handleSubmit = useCallback(() => {
+ if (stopSendWithoutChatPlan()) return
const currentFiles = filesRef.current
const currentEditor = editorRef.current
@@ -552,7 +557,7 @@ const UserInputImpl = forwardRef(function UserI
)
if (accepted === false) return
clearComposer()
- }, [onSubmit, clearComposer])
+ }, [stopSendWithoutChatPlan, onSubmit, clearComposer])
/**
* Enter policy for the editor: mirror canSubmit's uploading guard (Enter
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts
new file mode 100644
index 00000000000..93c444584e3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts
@@ -0,0 +1,43 @@
+'use client'
+
+import { useCallback } from 'react'
+import { toast } from '@sim/emcn'
+import { useRouter } from 'next/navigation'
+import { useSession } from '@/lib/auth/client'
+import { CHAT_PLAN_REQUIRED_MESSAGE } from '@/lib/billing/chat-plan'
+import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
+import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
+import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
+
+/**
+ * Stops a send from a workspace whose payer may not use Chat before it starts a turn that run
+ * admission would refuse. A member who can manage the workspace's billing goes to its upgrade
+ * page; anyone else is told who can upgrade. The returned callback reports whether the send was
+ * stopped.
+ *
+ * The verdict is the host context's `features.chat`, which the server resolves with the same
+ * check admission runs. A surface without a workspace host (an organization conversation), or a
+ * context that predates the flag, is not stopped here; admission still answers it with the
+ * upgrade card.
+ */
+export function useChatPlanGate(): () => boolean {
+ const hostContext = useOptionalWorkspaceHostContext()
+ const { data: session } = useSession()
+ const viewerId = session?.user?.id
+ const router = useRouter()
+
+ return useCallback(() => {
+ if (!hostContext || hostContext.features?.chat !== false) return false
+
+ if (canManageWorkspaceBilling(hostContext, viewerId)) {
+ router.push(buildUpgradeHref(hostContext.workspace.id))
+ } else {
+ toast.error(
+ hostContext.hostOrganizationId
+ ? `${CHAT_PLAN_REQUIRED_MESSAGE} Contact an organization admin to upgrade this workspace’s plan.`
+ : `${CHAT_PLAN_REQUIRED_MESSAGE} Ask the workspace owner to upgrade this workspace’s plan.`
+ )
+ }
+ return true
+ }, [hostContext, viewerId, router])
+}
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 5577b79af38..d21ab236c1f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -119,6 +119,7 @@ import {
dispatchStreamEvent,
finalizeResidualToolCalls,
} from '@/app/workspace/[workspaceId]/home/hooks/stream'
+import { useChatPlanGate } from '@/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate'
import { useNativeActiveTabIds } from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources'
import { useMothershipChatOlderMessages } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-chat-older-messages'
import { resolveEffectiveResourceId } from '@/app/workspace/[workspaceId]/home/resource-view-policy'
@@ -779,6 +780,7 @@ export function useChat(
const pathname = usePathname()
const router = useRouter()
const queryClient = useQueryClient()
+ const stopSendWithoutChatPlan = useChatPlanGate()
const [pendingMessages, setPendingMessages] = useState([])
const [completedTurn, setCompletedTurn] = useState(null)
const completedTurnRef = useRef(null)
@@ -4437,6 +4439,7 @@ export function useChat(
options?: SendMessageOptions
) => {
if ((!message.trim() && !fileAttachments?.length) || !scopeKey) return
+ if (stopSendWithoutChatPlan()) return
const queueStore = useMothershipQueueStore.getState()
const activeChatKey = chatKeyRef.current
@@ -4524,6 +4527,7 @@ export function useChat(
},
[
workspaceId,
+ stopSendWithoutChatPlan,
createQueuedMessage,
startSendMessage,
handOffWithdrawnSend,
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts
index 0eb4e368a5e..5f443636eb3 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts
@@ -176,6 +176,10 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [
{
title: 'Features',
rows: [
+ {
+ label: 'Chat with Sim',
+ values: [false, true, true, true],
+ },
{
label: 'Sim Mailer (Inbox)',
values: [false, false, true, true],
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
index 03b87e22618..9b48675f6eb 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
@@ -27,6 +27,7 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
}
export const PRO_PLAN_FEATURES: readonly string[] = [
+ 'Chat with Sim',
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Higher rate limits',
@@ -35,6 +36,7 @@ export const PRO_PLAN_FEATURES: readonly string[] = [
]
export const MAX_PLAN_FEATURES: readonly string[] = [
+ 'Chat with Sim',
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Sim Mailer, KB Live Sync & Sandboxes',
diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts
index bbfc2b2e924..71bb7ae8efb 100644
--- a/apps/sim/lib/api/contracts/workspaces.ts
+++ b/apps/sim/lib/api/contracts/workspaces.ts
@@ -288,6 +288,11 @@ export const workspaceHostContextSchema = z.object({
knowledgeMemberAccess: z.boolean().optional(),
/** Optional for rolling compatibility with app versions that predate administrator mode. */
knowledgeSourceMirroredAccess: z.boolean().optional(),
+ /**
+ * Whether the workspace payer may use Chat. Optional for rolling compatibility; absent
+ * leaves the composer open, and run admission still decides.
+ */
+ chat: z.boolean().optional(),
})
.optional(),
/** Optional for rolling compatibility with app versions that predate deployment projection. */
diff --git a/apps/sim/lib/billing/chat-plan.ts b/apps/sim/lib/billing/chat-plan.ts
new file mode 100644
index 00000000000..a1153b6c037
--- /dev/null
+++ b/apps/sim/lib/billing/chat-plan.ts
@@ -0,0 +1,5 @@
+/** The upgrade card's `reason` for a payer whose plan does not include Chat. */
+export const CHAT_PLAN_REQUIRED_REASON = 'plan_required' as const
+
+/** Lead sentence of every refusal that withholds Chat from a plan. */
+export const CHAT_PLAN_REQUIRED_MESSAGE = 'Chat is included on paid plans.'
diff --git a/apps/sim/lib/billing/core/chat-access.ts b/apps/sim/lib/billing/core/chat-access.ts
new file mode 100644
index 00000000000..72645f0afd8
--- /dev/null
+++ b/apps/sim/lib/billing/core/chat-access.ts
@@ -0,0 +1,40 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
+import { isFree } from '@/lib/billing/plan-helpers'
+import type { UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
+import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
+
+const logger = createLogger('ChatAccess')
+
+/**
+ * Whether a payer may use Chat (talking to Sim). Every turn spends hosted model credits, so a
+ * hosted deployment that bills withholds Chat from a payer on the free plan — unless that payer
+ * holds prepaid credits, which an admin grants to customers billed outside a subscription.
+ *
+ * The plan is the payer's entitled subscription (active or past due), so a canceled
+ * plan reads as free; a blocked payer is refused by the usage check every caller runs next.
+ *
+ * This is the single verdict behind every Chat plan gate: run admission, chat titles, scheduled
+ * job dispatch, Copilot key admission, and the workspace host context the composer reads, so the
+ * composer never offers a send that admission will refuse. A balance that cannot be read admits
+ * the payer: a database blip must not lock a paying customer out, and a free payer stays bounded
+ * by its usage limit.
+ */
+export async function payerHasChatAccess(payer: UsageUpgradePayer): Promise {
+ if (!isHosted || !isBillingEnabled) return true
+ if (!isFree(payer.payerSubscription?.plan)) return true
+ try {
+ const balance = await getCreditBalanceForEntity(
+ payer.billingEntity.type,
+ payer.billingEntity.id
+ )
+ return balance > 0
+ } catch (error) {
+ logger.warn('Credit balance unavailable; admitting the payer to Chat', {
+ billingEntity: payer.billingEntity,
+ error: getErrorMessage(error),
+ })
+ return true
+ }
+}
diff --git a/apps/sim/lib/billing/usage-upgrade.ts b/apps/sim/lib/billing/usage-upgrade.ts
index 37fe1e6dbae..10f129f5927 100644
--- a/apps/sim/lib/billing/usage-upgrade.ts
+++ b/apps/sim/lib/billing/usage-upgrade.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { UsageUpgradePayload } from '@/lib/api/contracts/subscription'
+import { CHAT_PLAN_REQUIRED_MESSAGE, CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan'
import type { AttributedUsageLimitsResult } from '@/lib/billing/core/attribution'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import type { BillingEntity } from '@/lib/billing/core/usage-log'
@@ -68,7 +69,21 @@ export async function resolveUsageUpgradePayload(
return { reason: 'usage_limit', action: 'increase_limit', message }
}
+/**
+ * The card for a payer whose plan does not include Chat. Sim writes it at run admission, before
+ * any worker run exists, so it never crosses the worker billing protocol: its reason widens only
+ * the tag Sim renders, not the worker-shared card.
+ */
+export const CHAT_PLAN_REQUIRED_UPGRADE = {
+ reason: CHAT_PLAN_REQUIRED_REASON,
+ action: 'upgrade_plan',
+ message: `${CHAT_PLAN_REQUIRED_MESSAGE} Upgrade your plan to talk to Sim.`,
+} as const
+
+/** Every card body the usage tag carries: the worker-shared card or Sim's Chat plan card. */
+export type UsageUpgradeTagPayload = UsageUpgradePayload | typeof CHAT_PLAN_REQUIRED_UPGRADE
+
/** The assistant text that renders {@link payload} as the usage card. */
-export function formatUsageUpgradeTag(payload: UsageUpgradePayload): string {
+export function formatUsageUpgradeTag(payload: UsageUpgradeTagPayload): string {
return `${JSON.stringify(payload)}`
}
diff --git a/apps/sim/lib/mothership/jobs/dispatch.ts b/apps/sim/lib/mothership/jobs/dispatch.ts
index 90c37bf4448..91c02e34a6f 100644
--- a/apps/sim/lib/mothership/jobs/dispatch.ts
+++ b/apps/sim/lib/mothership/jobs/dispatch.ts
@@ -5,6 +5,8 @@ import {
checkAttributedUsageLimits,
resolveBillingAttribution,
} from '@/lib/billing/core/attribution'
+import { payerHasChatAccess } from '@/lib/billing/core/chat-access'
+import { CHAT_PLAN_REQUIRED_UPGRADE } from '@/lib/billing/usage-upgrade'
import { isHosted } from '@/lib/core/config/env-flags'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
@@ -104,6 +106,11 @@ async function dispatchQueuedOccurrence(executionId: string): Promise ({
vi.mock('@/lib/mothership/request/tools/billing', () => ({
handleBillingLimitResponse: vi.fn(),
+ handleChatPlanRequiredResponse: vi.fn(),
}))
const mockRequestExplicitStreamAbort = vi.hoisted(() => vi.fn())
@@ -2310,6 +2311,17 @@ describe('runCopilotLifecycle', () => {
expect(capturedExecContext?.userPermission).toBe('read')
})
+ /** A paid organization payer, which the free-plan Chat gate admits. */
+ const paidOrganizationPayer = {
+ id: 'sub-org-1',
+ referenceId: 'org-1',
+ plan: 'team',
+ status: 'active',
+ seats: 2,
+ periodStart: null,
+ periodEnd: null,
+ }
+
it.each([
{ workspaceId: 'ws-1', organizationId: undefined },
{ workspaceId: undefined, organizationId: 'org-1' },
@@ -2324,7 +2336,7 @@ describe('runCopilotLifecycle', () => {
start: '2026-07-01T00:00:00.000Z',
end: '2026-08-01T00:00:00.000Z',
},
- payerSubscription: null,
+ payerSubscription: paidOrganizationPayer,
}
setEnvFlags({ isHosted: true, isBillingEnabled: true })
mockEnv.COPILOT_API_KEY = 'sim-agent-key'
@@ -2464,7 +2476,7 @@ describe('runCopilotLifecycle', () => {
start: '2026-07-01T00:00:00.000Z',
end: '2026-08-01T00:00:00.000Z',
},
- payerSubscription: null,
+ payerSubscription: paidOrganizationPayer,
}
setEnvFlags({ isHosted: true, isBillingEnabled: true })
mockCheckAttributedUsageLimits.mockResolvedValue({
@@ -2517,7 +2529,7 @@ describe('runCopilotLifecycle', () => {
billedAccountUserId: 'user-1',
billingEntity: { type: 'organization' as const, id: 'org-1' },
billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2099-01-01T00:00:00.000Z' },
- payerSubscription: null,
+ payerSubscription: paidOrganizationPayer,
}
setEnvFlags({ isHosted: true, isBillingEnabled: true })
resetUsageGateCache()
diff --git a/apps/sim/lib/mothership/request/lifecycle/run.ts b/apps/sim/lib/mothership/request/lifecycle/run.ts
index 52db59650a2..135b1bc3f0c 100644
--- a/apps/sim/lib/mothership/request/lifecycle/run.ts
+++ b/apps/sim/lib/mothership/request/lifecycle/run.ts
@@ -14,6 +14,7 @@ import {
checkAttributedUsageLimits,
createAttributedBillingRequestEnvelope,
} from '@/lib/billing/core/attribution'
+import { payerHasChatAccess } from '@/lib/billing/core/chat-access'
import { env } from '@/lib/core/config/env'
import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags'
import { withFileOwnerContext } from '@/lib/mothership/application/file-owner-context'
@@ -71,7 +72,10 @@ import {
requireToolCallStateResult,
setTerminalToolCallState,
} from '@/lib/mothership/request/tool-call-state'
-import { handleBillingLimitResponse } from '@/lib/mothership/request/tools/billing'
+import {
+ handleBillingLimitResponse,
+ handleChatPlanRequiredResponse,
+} from '@/lib/mothership/request/tools/billing'
import {
executeToolAndReport,
failPendingToolCall,
@@ -572,22 +576,30 @@ export async function runCopilotLifecycle(
let onCompleteStarted = false
try {
- // Hosted admission (usage limits) belongs HERE, at dispatch, with the attribution
+ // Hosted admission (plan and usage limits) belongs HERE, at dispatch, with the attribution
// already in hand. The old path outsourced it to the Go backend's api-keys/validate
// callback — a call the TS worker rightly never makes, so without this gate the
// limit check simply never runs on the new path. On exceeded, the same synthetic
// 402 UX as the mid-stream path renders the upgrade prompt, the backend is never
// dispatched, and the shared verdict assembly below runs exactly as after a
// mid-stream billing break.
- const admission =
+ const admittedAttribution =
isHosted && !isContinuation && execContext.billingAttribution
- ? await checkAttributedUsageLimits(
- assertBillingAttributionSnapshot(execContext.billingAttribution)
- )
+ ? assertBillingAttributionSnapshot(execContext.billingAttribution)
+ : undefined
+ // The free plan does not include Chat, on any surface that starts a run: such a payer's
+ // turn ends here with the upgrade card, before usage is read or the backend dispatched.
+ const chatPlanRequired =
+ admittedAttribution !== undefined && !(await payerHasChatAccess(admittedAttribution))
+ const admission =
+ admittedAttribution && !chatPlanRequired
+ ? await checkAttributedUsageLimits(admittedAttribution)
: { isExceeded: false as const }
if (options.recovery && context.completionStatus) {
// The worker terminal was already delivered before the relay died.
// Rebuild persistence from that receipt without charging its usage twice.
+ } else if (chatPlanRequired) {
+ await handleChatPlanRequiredResponse(context, execContext, lifecycleOptions)
} else if (admission.isExceeded) {
await handleBillingLimitResponse(
execContext.userId,
diff --git a/apps/sim/lib/mothership/request/lifecycle/start.ts b/apps/sim/lib/mothership/request/lifecycle/start.ts
index 62f87601a6b..d499c7b136f 100644
--- a/apps/sim/lib/mothership/request/lifecycle/start.ts
+++ b/apps/sim/lib/mothership/request/lifecycle/start.ts
@@ -13,6 +13,7 @@ import {
resolveBillingAttribution,
resolveOrganizationBillingAttribution,
} from '@/lib/billing/core/attribution'
+import { payerHasChatAccess } from '@/lib/billing/core/chat-access'
import { isHosted } from '@/lib/core/config/env-flags'
import { createRunSegment, recordRunBillingAdmission } from '@/lib/mothership/async-runs/repository'
import { buildChatTitleContext } from '@/lib/mothership/chat/title-context'
@@ -771,6 +772,8 @@ export async function requestChatTitle(params: {
throw new Error('Title billing attribution does not match its actor and workspace')
}
+ // A title is a Chat model call; a payer whose plan excludes Chat gets none.
+ if (!(await payerHasChatAccess(attribution))) return null
const admission = await checkAttributedUsageLimits(attribution)
if (admission.isExceeded) return null
diff --git a/apps/sim/lib/mothership/request/tools/billing.ts b/apps/sim/lib/mothership/request/tools/billing.ts
index 295bf69ddcc..93fd55d4b9b 100644
--- a/apps/sim/lib/mothership/request/tools/billing.ts
+++ b/apps/sim/lib/mothership/request/tools/billing.ts
@@ -1,5 +1,10 @@
import { createLogger } from '@sim/logger'
-import { formatUsageUpgradeTag, resolveUsageUpgradePayload } from '@/lib/billing/usage-upgrade'
+import {
+ CHAT_PLAN_REQUIRED_UPGRADE,
+ formatUsageUpgradeTag,
+ resolveUsageUpgradePayload,
+ type UsageUpgradeTagPayload,
+} from '@/lib/billing/usage-upgrade'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
@@ -31,6 +36,24 @@ export async function handleBillingLimitResponse(
scope?: 'actor' | 'payer' | 'member'
): Promise {
const payload = await resolveUsageUpgradePayload(userId, execContext.billingAttribution, scope)
+ await endTurnWithUpgradeCard(payload, context, execContext, options)
+}
+
+/** Ends a free payer's turn at admission with the card offering a plan that includes Chat. */
+export async function handleChatPlanRequiredResponse(
+ context: StreamingContext,
+ execContext: ExecutionContext,
+ options: OrchestratorOptions
+): Promise {
+ await endTurnWithUpgradeCard(CHAT_PLAN_REQUIRED_UPGRADE, context, execContext, options)
+}
+
+async function endTurnWithUpgradeCard(
+ payload: UsageUpgradeTagPayload,
+ context: StreamingContext,
+ execContext: ExecutionContext,
+ options: OrchestratorOptions
+): Promise {
const syntheticContent = formatUsageUpgradeTag(payload)
// The card is this turn's terminal even when the refused leg follows one that already ended.
context.streamComplete = false
diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts
index 48314ac75d2..3f73b965fcf 100644
--- a/apps/sim/lib/workspaces/host-context.test.ts
+++ b/apps/sim/lib/workspaces/host-context.test.ts
@@ -104,6 +104,7 @@ describe('getWorkspaceHostContextForViewer', () => {
organizationSearch,
knowledgeMemberAccess: workspaceKnowledge,
knowledgeSourceMirroredAccess: workspaceKnowledge,
+ chat: true,
})
expect(mockIsKnowledgeMemberAccessAvailable).toHaveBeenCalledExactlyOnceWith({
organizationId: 'org-host',
diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts
index df82e3d4829..a91ba7c8edf 100644
--- a/apps/sim/lib/workspaces/host-context.ts
+++ b/apps/sim/lib/workspaces/host-context.ts
@@ -1,5 +1,6 @@
import { cache } from 'react'
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
+import { payerHasChatAccess } from '@/lib/billing/core/chat-access'
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape'
import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability'
@@ -33,7 +34,7 @@ async function resolveWorkspaceHostContextForViewer(
? getOrganizationSettingsAccess(hostOrganizationId, userId)
: Promise.resolve({ role: null, isMember: false, isAdmin: false }),
])
- const [credentialGroupsAvailable, knowledgeAccess, organizationSearch] = await Promise.all([
+ const [credentialGroupsAvailable, knowledgeAccess, organizationSearch, chat] = await Promise.all([
hostOrganizationId
? isScopedCredentialGroupsAvailable({
kind: 'organization',
@@ -44,6 +45,12 @@ async function resolveWorkspaceHostContextForViewer(
hostOrganizationId && hostOrganizationAccess.isMember
? isKnowledgeMemberAccessAvailable({ organizationId: hostOrganizationId })
: Promise.resolve(false),
+ payerHasChatAccess({
+ billingEntity: ownerBilling.organizationId
+ ? { type: 'organization', id: ownerBilling.organizationId }
+ : { type: 'user', id: access.workspace.billedAccountUserId },
+ payerSubscription: { plan: ownerBilling.plan },
+ }),
])
return {
@@ -67,6 +74,7 @@ async function resolveWorkspaceHostContextForViewer(
organizationSearch,
knowledgeMemberAccess: knowledgeAccess.memberScoped,
knowledgeSourceMirroredAccess: knowledgeAccess.sourceMirrored,
+ chat,
},
deployment: resolveDeploymentShape(),
}