# Agent System Source: https://docs.second.so/agent-system How Second runs AI agents — the worker, bridge layer, and provider abstraction. Second delegates AI agent work to a standalone worker process. The worker runs agent sessions through runtime adapters for Claude Code, Codex CLI, and OpenCode, streams normalized events back to Next.js, and Next.js translates them into the Vercel AI SDK's UIMessageStream protocol for the browser. ## Design principles **One agent contract, multiple runtimes.** There is no separate "general agent" vs "coding agent." Every runtime receives the same Second system prompt, workspace, tool contract, approval-stop rules, and app-agent governance. What differs between runtimes is launch/config/session behavior and the native model parameter surface. **The worker is stateless infrastructure.** It holds in-memory sessions with a 15-minute TTL, but all durable state lives in MongoDB. When a session expires, the next message restores it from the database. **Two hops, two protocols.** The worker streams normalized runtime events. Next.js translates them to the AI SDK UIMessageStream protocol. The browser sees standard `useChat` messages, so UI components do not need to know whether Claude, Codex CLI, or OpenCode produced the turn. ## Components ### Worker (`apps/worker/`) A standalone Hono HTTP server that manages agent sessions. See [Worker](/worker) for details. * Starts and continues Claude Code, Codex CLI, and OpenCode sessions * Streams raw SDK events over SSE * Manages session lifecycle with 15-minute TTL * No database access, no AI SDK dependency — just the agent runtime ### Bridge (`apps/web/src/lib/agent/worker-bridge.ts`) Connects to the worker's SSE stream and translates runtime events into AI SDK UIMessageStream chunks that `useChat` understands. ``` Claude SDK event → AI SDK UIMessage chunk ───────────────────────────────── ──────────────────────── content_block_delta (text_delta) → text-start + text-delta content_block_delta (thinking) → reasoning-start + reasoning-delta content_block_start (tool_use) → tool-input-start content_block_delta (input_json) → tool-input-delta content_block_stop → tool-input-available user message (tool_result) → tool-output-available ``` The bridge also handles: * Opening and closing text/reasoning blocks (`text-start`/`text-end`) * Tracking pending tool calls and resolving them when the next turn starts * Capturing the SDK `result` message (cost, token counts, per-model breakdown) — see [Models & Usage](/models-and-usage) * Error propagation from the worker ### System prompt (`apps/web/src/lib/agent/system-prompt.ts`) Builds the system prompt for each run. Includes the workspace name and instructions for the agent. The system prompt is passed to the worker in every request and forwarded to `query({ options: { systemPrompt } })`. The system prompt covers: * **Project structure** — the workspace is a Vite + React + TypeScript project with Tailwind + Shadcn starter files * **Implementation workflow** — edit `src/*`, use React/TSX idioms, and keep changes production-ready * **Planning phase** — the `present_plan` tool must be called before first build (see [Worker — Custom tools](/worker#custom-tools-builder)) * **Agent definition** — the `present_agents` tool presents agents.json for governed approval (see [App Agents](/app-agents)) * **Integration setup** — `list_app_integration_keys`, `integration-setup.json`, and `present_integration_setup` handle this app's live static-secret and OAuth setup checks, permissions, scopes, provider configs, and secrets only when configuration is needed (see [Integrations](/integrations#integration-setupjson)) * **Build phase** — the `done_building` tool runs `npm run typecheck` + `npm run build` in parallel, validates artifact output, and triggers live preview (see [App Preview](/app-preview)) * **Data persistence** — `useCollection`/`useDoc` hooks replace `localStorage` for all app data (see [App Data](/app-data)) * **Agent SDK usage** — `useAgent`/`useAgentList` hooks for triggering agents from app code * **agents.json format** — complete schema rules including mockData requirements, dataCollections, static `{{secrets.NAME}}` injection, and OAuth `integration.auth` metadata * **Agent data access** — `read_app_data` and `update_app_data` tools for agent data writing * **Security policy** — custom tools must not be combined with WebSearch/WebFetch in the same agent The system prompt is generated per-request by `getSystemPrompt(workspaceId, workspaceName)`. Integration metadata is not injected into the prompt; the builder calls `mcp__second__list_app_integration_keys` when it needs this app's live configured/requested state. That tool returns metadata only, never secret values, and another app's credential never satisfies this app. ### Approval gates The first build plan and any `agents.json` proposal are approval stops. The builder calls `mcp__second__present_plan` or `mcp__second__present_agents`, the tool returns a card payload, and the runtime adapter stops the active turn. The chat UI blocks normal input until the card is approved or changes are requested. Plan approval sends a follow-up user message so the builder continues in a new turn. Agents card approval first records the approved `agents.json` hash and payload when the actor is an admin or owner, then sends the follow-up user message. Requesting changes sends the feedback as the next user message so the builder can revise the plan or agent configuration and present it again. ### Runtime settings (`apps/web/src/lib/agent/runtime-registry.ts`) Per-message configuration is runtime-specific: * Claude Code exposes effort and thinking controls. * Codex CLI exposes reasoning effort and sandbox controls. * OpenCode currently exposes model selection only. Apps store `runtimeId`, `runtimeModel`, and `runtimeParams`. The runtime registry drives the model picker, defaults, validation, and parameter controls. ### Persistence (`apps/web/src/lib/db/repositories/`) **Builder agent runs** are stored as `AgentRunDocument` in the `agent_runs` collection: ```typescript theme={null} { _id: string; // run ID appId: string; workspaceId: string; messages: UIMessage[]; // full AI SDK message array sessionState: ProviderSessionState | null; // runtime-specific resume state activeStreamId: string | null; // for resumable streams status: "pending" | "streaming" | "completed" | "failed"; usage: RunUsage | null; // accumulated cost and token data createdAt: Date; updatedAt: Date; } ``` Runs are created as `pending`, then atomically claimed as `streaming` by the first chat POST that starts the worker query. Duplicate POSTs for the same active run do not start another worker session. Final messages are saved via `onFinish` after the agent completes a response. Provider-aware `sessionState` is saved after each turn for cross-container resume. The `activeStreamId` is set during streaming and cleared on completion, enabling resumable streams via Redis. The chat route also records a short-lived Redis replay buffer of UI stream chunks so another tab or user can catch up by cursor even if the original resumable stream cannot be resumed. The `usage` field tracks cost and token counts per-model, accumulated from the SDK's `result` messages. See [Models & Usage](/models-and-usage) for the full schema and how to query usage for billing. **App agent runs** are stored separately as `AppAgentRunDocument` in the `app_agent_runs` collection. See [App Agents — Agent run lifecycle](/app-agents#agent-run-lifecycle) for the schema and flow. ## Runtime architecture Runtime support is now implemented through a shared registry and worker adapter layer rather than a future provider sketch. ### Registry and UI `apps/web/src/lib/agent/runtime-registry.ts` is the source of truth for runtime IDs, model lists, defaults, parameter controls, and validation. It defines the persisted settings shape: ```typescript theme={null} type AgentRuntimeSettings = { runtimeId: "claude-code" | "codex-cli" | "opencode"; model: string; params: Record; }; ``` `ModelSelector` groups models by runtime, and `RuntimeParameterSelectors` renders only the controls exposed by the selected runtime: * Claude Code: effort and thinking. * Codex CLI: reasoning effort and sandbox mode. * OpenCode: model selection only for now. The app composer, chat composer, app creation route, settings route, and chat route all send or persist `runtimeId`, `runtimeModel`, and `runtimeParams`. This repo is in active development, so app documents without these fields are treated as old development data rather than a permanent compatibility format. ### Worker dispatch The web bridge sends normalized runtime settings to `POST /sessions/:appId/messages`. `SessionManager` calls `runRuntimeAgent`, which dispatches to: * `apps/worker/src/runtimes/claude.ts` * `apps/worker/src/runtimes/codex-cli.ts` * `apps/worker/src/runtimes/opencode.ts` Claude continues to use the Claude Agent SDK. Codex CLI uses the Codex app-server protocol over stdio so text deltas stream as they are produced. OpenCode remains a command-backed runtime launched in non-interactive JSON mode. Both are normalized to the same worker message shape so the Next.js bridge can keep emitting the same AI SDK message parts. ### Tool exposure Second tools are implemented once in `runner.ts` as provider-neutral handlers. They are exposed in two forms: * Claude receives Claude SDK `tool(...)` definitions through in-process MCP servers. * Codex CLI and OpenCode receive remote MCP server entries that point to the worker's scoped MCP broker. The scoped broker uses one short-lived bearer token per runtime turn. That token grants only the tools allowed for the app/run context and is not `INTERNAL_API_TOKEN`. The worker keeps MongoDB, Redis, WorkOS, internal route tokens, cookies, headers, integration secrets, prompts, and source snapshots out of CLI runtime environment variables and runtime config files. ### Approval Stops `present_plan` and `present_agents` remain hard approval stops for every runtime. Claude closes the active SDK query after the matching tool result. Command-backed runtimes terminate the active process after the matching MCP tool result. The next user approval or change request resumes through the normal chat path. The frontend detects the latest completed `mcp__second__present_plan` or `mcp__second__present_agents` dynamic tool part and blocks normal chat input until the user approves or requests changes. ### Session state Runs store provider-aware session state behind one field: ```typescript theme={null} type ProviderSessionState = { runtimeId: "claude-code" | "codex-cli" | "opencode"; sessionId?: string | null; data?: string | null; format?: string; metadata?: Record; }; ``` Claude may persist JSONL data so a different worker can restore the resume file before calling the SDK. Codex CLI and OpenCode store their native session IDs when the JSON stream exposes them. ### Adding or changing a runtime To add another runtime: 1. Add its models, defaults, parameters, and validation rules to `runtime-registry.ts`. 2. Add a worker adapter under `apps/worker/src/runtimes/` that emits normalized SDK-style events. 3. Use the scoped MCP broker for Second tools instead of passing internal API tokens to the runtime process. 4. Add detection hints in the web and worker `/detect-provider` routes without returning secret values. 5. Add fixture coverage for its JSON events and verify approval-stop behavior. Everything downstream of the bridge stays provider-neutral: AI SDK `UIMessage` persistence, Redis stream replay, chat rendering, plan/agent cards, terminal cards, app data cards, custom tool cards, and usage accumulation. # App Agents Source: https://docs.second.so/app-agents How apps trigger approved AI agents with scoped tools, streaming, and background execution. Apps built on Second can trigger AI agents defined in an `agents.json` file. Each agent has its own system prompt, scoped tools (built-in or custom HTTP), and optional write access to the app's data. The same governed file can also define top-level `appTools`: custom HTTP actions callable directly from app code through the SDK for the narrow deterministic backend-function exception, such as bounded bulk fetches followed by app-side post-processing. Live runtime uses the approved configuration for that app version, so draft changes cannot quietly expand what an agent or app action can call. ## How it works ``` App iframe (useAgent hook) → postMessage("second:agent:trigger") → AppAgentBridge (parent window) → POST /api/.../agent-runs (create run) → POST /api/.../agent-runs/{runId}/stream?startOnly=1 (start agent without opening a browser SSE stream) → Worker POST /sessions/{appId}__agent__{runId}/agent-run → AgentRunManager runs agent in background → Worker calls tools, writes data → Worker POSTs /api/internal/agent-run-complete when done ``` The app triggers an agent via the SDK. The platform creates a run record and starts the agent on the worker with a short server request. Opening the Agent Runs drawer later attaches an SSE viewer for that specific run, but normal app-agent execution does not reserve a browser connection for every running agent. The worker owns the agent lifecycle — closing the browser doesn't stop it. App-agent routes are scoped by the full `{workspaceId, appId, runId}` tuple. A run from one app cannot be loaded or streamed through another app route, even inside the same workspace. App-callable integration actions use the same iframe bridge shape, but they do not create an app-agent run: ``` App iframe (callIntegrationTool) → postMessage("second:integration:execute") → AppIntegrationBridge (parent window) → POST /api/.../app-tools/{toolName}/execute → Verify approved agents.json appTools policy → Resolve app-scoped integration grant → Inject static secrets or current viewer OAuth token server-side → Execute bounded HTTP request → Return response to app code ``` Use agents for most integration-backed workflows. Use app actions only for the narrow deterministic backend-function exception: the app can fetch bounded provider batches and then page, group, filter, or aggregate the response itself without AI reasoning, such as fetching PostHog event batches and grouping them by user ID. This avoids consuming an agent's limited context window with huge tool responses when the task is just API pagination plus local computation. Use agents when the task needs reasoning, generation, autonomous decisions, or natural-language workflows. ## agents.json Agents are defined in a JSON file in the app workspace, persisted alongside source files in MongoDB. The builder agent creates this file during the build flow and presents it via the `present_agents` tool for approval. ```json theme={null} { "appTools": [ { "type": "custom", "name": "posthog_events_page", "displayName": "Fetch PostHog events page", "description": "Fetches one bounded page of PostHog events for app-side grouping.", "enabled": true, "integration": { "name": "PostHog", "domain": "posthog.com", "keySlug": "default" }, "endpoint": { "method": "GET", "url": "https://app.posthog.com/api/projects/{{projectId}}/events/", "headers": { "Authorization": "Bearer {{secrets.POSTHOG_PERSONAL_API_KEY}}" }, "queryParams": { "after": "{{after}}", "before": "{{before}}", "limit": "{{limit}}" } }, "mockData": [ { "results": [{ "distinct_id": "user_123", "event": "$pageview" }], "next": null }, { "results": [{ "distinct_id": "user_456", "event": "signup" }], "next": null }, { "results": [], "next": null } ] } ], "agents": [ { "id": "lead-enricher", "name": "Lead Enricher", "description": "Searches the web to find current information about leads", "systemPrompt": "You are a lead enrichment specialist...", "dataCollections": ["leads"], "tools": [ { "type": "builtin", "name": "WebSearch", "enabled": true, "recommended": true }, { "type": "custom", "name": "hubspot_fetch_contacts", "displayName": "Fetch Contacts", "description": "Search and retrieve contacts from HubSpot CRM", "enabled": true, "recommended": true, "integration": { "name": "HubSpot", "domain": "hubapi.com", "setupSearchQuery": "How to get HubSpot API key" }, "endpoint": { "method": "GET", "url": "https://api.hubapi.com/crm/v3/objects/contacts", "headers": { "Authorization": "Bearer {{secrets.HUBSPOT_PRIVATE_APP_TOKEN}}" }, "queryParams": { "query": "{{query}}", "limit": "10" } }, "responseSchema": { "type": "object", "description": "HubSpot contacts response" }, "mockData": [ { "id": "101", "properties": { "firstname": "Sarah", "lastname": "Chen" } }, { "id": "102", "properties": { "firstname": "Marcus", "lastname": "Johnson" } }, { "id": "103", "properties": { "firstname": "Priya", "lastname": "Patel" } } ] } ] } ] } ``` `agents` may be empty when an app only needs app actions. `appTools` use the same custom HTTP shape and integration rules as `agents[].tools`, but the caller is app code rather than an AI agent. ### Key fields | Field | Purpose | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Unique identifier the SDK uses to trigger the agent | | `tools[].type` | `"builtin"` (WebSearch/WebFetch) or `"custom"` (HTTP request via [tool-execute](/integrations#tool-execution)) | | `tools[].displayName` | Optional human-readable action label for tool cards (for example `"Company Lookup"` while `name` remains `clearbit_company_lookup`) | | `tools[].enabled` | Toggle — user can disable from the agents page | | `tools[].recommended` | UI label — "Highly Recommended" badge. Does not enforce anything | | `tools[].integration` | Links to an app-scoped [integration](/integrations) grant, matched by `domain` and `keySlug` (`"default"` when omitted) | | `tools[].endpoint` | HTTP request spec with static named secret placeholders such as `{{secrets.SLACK_BOT_TOKEN}}`, normal tool-input placeholders for OAuth tools, or public no-auth requests | | `tools[].integration.auth` | Optional auth metadata. Missing means either a static-secret tool when the endpoint uses `{{secrets.NAME}}`, or a public unauthenticated tool when the official API requires no credentials; `type: "oauth2"` means the broker resolves the triggering user's connected account | | `tools[].mockData` | Sample responses used when the integration is not configured. Must contain 3+ entries for variety | | `dataCollections` | Collections this agent can read/write via `update_app_data` and `read_app_data` tools. See [App Data](/app-data#agent-data-access) | | `appTools[]` | Optional top-level custom HTTP actions callable from generated app code with `callIntegrationTool` | Endpoint specs can also use placeholders from the tool input, such as `{{symbol}}`, `{{query}}`, or `{{company.ticker}}`, in the URL, headers, query params, and body. The agent must pass a JSON string with those fields when calling the custom tool. Missing placeholders fail clearly instead of calling broad static endpoints. `present_agents` validates every custom tool and app action before approval. Custom tools must include `integration.name`, `integration.domain`, `endpoint.method`, and `endpoint.url`. Static tools must include a named secret placeholder such as `{{secrets.SLACK_BOT_TOKEN}}` where the saved integration secret is injected. OAuth tools must include `integration.auth.type = "oauth2"`, `providerKey`, `identity: "triggering_user"`, authorization URL, token URL, and exact scopes; they must not include `{{oauth.access_token}}`, `{{access_token}}`, `{{token}}`, `{{secrets.*}}`, or an explicit `Authorization` header. Public unauthenticated tools may omit secrets and auth metadata when the official API requires no API key, OAuth client, or token. Custom tools that need setup should include `integration.keySlug` and use the same slug in `integration-setup.json`; Second normalizes missing slugs to `"default"`. If a model omits the endpoint or uses unsafe placeholders, the agents card is marked as needing changes and cannot be approved until the builder fixes `agents.json` and calls `present_agents` again. If an agent passes tool input to a custom tool whose endpoint does not reference any input placeholders, execution fails rather than calling a static bulk endpoint. This protects integrations from returning broad datasets when the intended request was a lookup. `report_tool_call_failed` is a reserved platform tool name in the `app_tools` namespace. Generated custom tools cannot use it. The worker exposes `mcp__app_tools__report_tool_call_failed` to app agents so a blocked custom-tool failure can be sent back to the builder agent for repair. ## Automatic tool failure recovery When a custom HTTP tool fails with a real execution error, the worker keeps a bounded, redacted record of the failed call for that app-agent run. The record includes the custom tool name, parsed tool input, endpoint and integration metadata from the approved agent configuration, the internal `tool-execute` status, and the structured error/response details. It does not include injected secret values, OAuth tokens, cookies, or full unbounded responses. If the failed tool blocks the requested app-agent task, the app agent should finish any unaffected work and then call `mcp__app_tools__report_tool_call_failed`. That tool posts the report to `/api/internal/tool-failure-report`, which verifies the app-agent run by `{ workspaceId, appId, runId }`, creates a builder repair run for the same app, and records an audit event with compact metadata and hashes. The builder repair run starts with a platform-generated prompt that tells the builder to inspect `agents.json`, app code, integration setup, and the app-agent prompt, then re-present governed agents or setup instructions when they change. Workspace realtime only carries compact builder-run hints such as run id, status, and `runReason: "app_tool_failure"`. The sidebar derives its "Call failed - builder fixing it" badge from the latest authorized builder-run projection and clears it when that run completes or fails. ## Agent config approval `agents.json` is draft source, but it is also runtime policy. Second treats it as a governed artifact: 1. `present_agents` reads and validates the file on disk. 2. The Agents card shows the exact parsed payload. 3. A workspace admin or owner approves that payload. 4. The platform stores a versioned canonical JSON hash, the normalized approved payload, the approver, and the approval time. 5. Draft app-agent runtime can start from the draft file, but live custom tools, app-callable actions, and app-data tools are usable only while the current `agents.json` hash matches that approval. The approval hash is semantic, not a raw byte-for-byte file hash. The current `v1` approval schema normalizes harmless empty optional arrays before hashing: top-level `appTools: []`, agent-level `tools: []`, and agent-level `dataCollections: []` are treated the same as omitted fields. Stored hashes are prefixed with the approval schema version, for example `v1:`. Future `agents.json` schema changes should add a new approval schema version instead of changing old normalization behavior, so already-approved configs do not become stale unless their effective runtime policy changed. Creating `agents.json` or showing the Agents card does not send the app to review. It only pauses the builder until an admin/owner approves or someone requests changes. Review is created later from the publish dialog. If the builder, the user, a file edit, or a shell command changes `agents.json` after approval, the draft approval becomes stale. Draft agent runs may still start so the in-progress app can be tested, but custom HTTP tools and agent data tools are blocked until an admin or owner approves the new payload. When a review is approved or an admin/owner publishes directly, the approved payload is promoted with the published source snapshot. This prevents a draft from quietly adding a new integration domain, endpoint, secret placeholder, app action, permission, or data collection after IT has reviewed a different config. ## integration-setup.json When custom tools or app actions require an external service, the builder may also create `integration-setup.json` at the app workspace root. This file is separate from agents.json. agents.json defines what the agent or app code can call; `integration-setup.json` explains what a human needs to configure in the provider. The builder creates this file only when setup is needed: * this app has no connected integration grant for that domain/key slug * the app grant exists, but this app requires new permission groups, exact permissions/scopes, or named secrets that are not marked configured ```json theme={null} { "integrations": [ { "name": "Slack", "domain": "slack.com", "keySlug": "default", "keyName": "Slack post key for this app", "capabilityLabel": "Slack post", "why": "This app sends Slack messages.", "permissionGroups": [ { "name": "Send messages", "description": "Allows the app to post messages into selected Slack channels.", "permissions": ["chat:write"] } ], "secrets": [ { "name": "SLACK_BOT_TOKEN", "label": "Slack bot token", "description": "Paste the Bot User OAuth Token that starts with xoxb-.", "required": true } ], "setupInstructions": { "overview": "Create or update a Slack app, grant the bot scope, install it to the workspace, and paste the bot token in Second.", "steps": [ { "title": "Open Slack apps", "description": "Go to [Slack | API apps](https://api.slack.com/apps) and create a new app or open the existing app you want Second to use.", "url": "https://api.slack.com/apps" } ], "links": [ { "label": "Slack API apps", "url": "https://api.slack.com/apps" } ] } } ] } ``` Before writing the file, the builder calls `list_app_integration_keys` to check the current app's grant state without receiving secret values. Another app's credential does not satisfy this app. After the runtime policy is approved, the builder writes `integration-setup.json` and calls `present_integration_setup` before app implementation continues. The chat UI shows a compact "Instructions on how to set up ..." card, and the worker syncs the setup metadata into the integrations settings page immediately. If requirements change later, the builder updates `integration-setup.json` with the complete current requirements and calls `present_integration_setup` again, which replaces this app's grant set and re-syncs the integrations page. If the file is missing or invalid JSON, the platform does not register the integration. The file should use simple human language and verified links, not developer-only notes. ### Security policy An agent with access to organization tools (HubSpot, Slack, etc.) must **not** also have internet access (WebSearch/WebFetch). These should be separate agents in a multi-agent setup. The builder agent's system prompt enforces this separation. ## App agent SDK The SDK is included in the workspace template at `src/lib/second-sdk.ts`. It communicates with the platform via `postMessage`. ### `useAgent(agentId)` Triggers an agent and watches its status live. ```typescript theme={null} import { useAgent } from '@/lib/second-sdk'; function EnrichButton({ leadId }: { leadId: string }) { const { trigger, status, isRunning } = useAgent('lead-enricher'); return ( ); } ``` | Return value | Type | Description | | ----------------- | ------------------------------------------------ | ----------------------------------- | | `trigger(prompt)` | `(prompt: string) => void` | Start the agent with a user prompt | | `status` | `'idle' \| 'running' \| 'completed' \| 'failed'` | Current run status | | `isRunning` | `boolean` | `true` while the agent is executing | | `error` | `string \| null` | Error message if the run failed | | `runId` | `string \| null` | Current run ID | ### `useAgentList()` Returns all agents defined in `agents.json`. ```typescript theme={null} const { agents } = useAgentList(); // agents: Array<{ id: string; name: string; description: string }> ``` ### `callIntegrationTool(toolName, input)` Calls a top-level `appTools[]` action from app code and returns the provider response to the iframe without exposing secrets or OAuth tokens. ```typescript theme={null} import { callIntegrationTool } from '@/lib/second-sdk'; type EventsPage = { results: Array<{ distinct_id?: string; event?: string }>; next?: string | null; }; const result = await callIntegrationTool< { projectId: string; after?: string; before?: string; limit: number }, EventsPage >('posthog_events_page', { projectId: '123', after: '2026-05-01', before: '2026-05-20', limit: 100, }); if (!result.success) { throw new Error(result.error ?? 'PostHog request failed'); } ``` The route resolves the approved app action server-side from `agents.json`; the iframe sends only `toolName` and `input`. Static secrets are read from the app-scoped integration grant. OAuth app actions use the current app viewer as the `triggering_user` identity. After a successful `callIntegrationTool` call, if the processed result should survive refresh, be shared across users, or be reused by the app/agents later, save the compact processed result with `useCollection`/`useDoc` `insert` or `update`. Do not persist huge raw provider responses unless the app truly needs them. Make sure to structure the request and post-process the response in code so it can be beautifully saved in the app, not as huge raw chunks. Live failures return actionable diagnostics, not only a boolean failure. The result can include `error`, `statusCode`, `errorCode`, `errorCategory`, `resolution`, `retryable`, `canRequestBuilderRepair`, and bounded redacted `details`. Generated apps should render `error` plus `resolution` directly. Credential and permission failures should point the user back to integration setup; repairable endpoint/input/spec failures can offer an "Ask builder to fix" action that calls `reportIntegrationToolFailure`. ### `reportIntegrationToolFailure(toolName, input, result, description)` Reports a blocking, repairable app-callable backend function failure from the iframe to the normal builder run. The browser route re-authenticates the current viewer, requires editable draft access, resolves the approved tool spec server-side, redacts and bounds the failure payload, then schedules a builder recovery run. Do not use this for wrong or expired API keys, missing scopes, or provider access problems that only the integration owner can fix. Use it when app code, typed wrappers, `agents.json`, or setup instructions likely need repair. ### PostMessage protocol ``` // App → Platform second:agent:trigger { agentId, prompt } second:agents:list-request {} second:integration:execute { toolName, input } second:integration:report-failure { toolName, input, result, description, attemptedTask? } // Platform → App second:agent:update { agentId, runId, status, result?, error? } second:agents:list-response { agents: [...] } second:integration:execute-response { success, data?, mock, mockReason?, statusCode?, error?, errorCode?, errorCategory?, resolution?, retryable?, canRequestBuilderRepair?, details? } second:integration:report-failure-response { ok, status?, builderRunId?, error? } ``` ## present\_agents tool The builder agent calls `present_agents` after writing `agents.json`. This renders an interactive card in the chat showing each agent and each top-level app action with its tools, integration requirements, and recommended labels. It is an approval stop: the tool returns the card payload, the runtime adapter stops the active turn, and the chat composer stays blocked until an admin/owner approves or someone requests changes from the agents card. The tool is registered as `mcp__second__present_agents` in the worker's `second` MCP server alongside `present_plan`, `list_app_integration_keys`, `present_integration_setup`, and `done_building`. ```typescript theme={null} const presentAgents = tool( "present_agents", "Present the agent configuration to the user for approval...", {}, async () => { const agentsConfig = JSON.parse(readFileSync("agents.json", "utf-8")); // Validate agentsConfig.agents and return the card payload. }, ); ``` `present_agents` validates the `agents.json` file on disk as the source of truth, including custom integration tool and app action shape, and returns a fix-it message if the file is missing, invalid JSON, empty, or uses invalid custom-tool placeholders. A file with no agents is valid when it has a non-empty top-level `appTools` array. When an admin or owner approves, `AppChat` first records the governed approval for the normalized Agents card payload, then sends the follow-up user message that continues the build. On requested changes, it sends the feedback as the next user message and the builder must update `agents.json` and call `present_agents` again. ## Agent run lifecycle ### Database App agent runs are stored in the `app_agent_runs` collection, separate from builder agent runs (`agent_runs`). ```typescript theme={null} type AppAgentRunDocument = { _id: string; appId: string; workspaceId: string; triggeredByUserId: string; triggeredByUserEmail: string; triggeredByUserName: string; sourceVersion?: "draft" | "published"; agentId: string; agentName: string; prompt: string; status: "pending" | "running" | "streaming" | "completed" | "failed"; result: unknown | null; messages: unknown[]; sessionId: string | null; activeStreamId: string | null; usage: RunUsage | null; createdAt: Date; updatedAt: Date; }; ``` ### Flow 1. **App triggers agent** — SDK sends `second:agent:trigger` via postMessage. 2. **Bridge creates run** — `AppAgentBridge` calls `POST /api/.../agent-runs` → creates `AppAgentRunDocument` with `status: "pending"` and the server-resolved triggering user. 3. **Bridge starts run** — Calls `POST /api/.../agent-runs/{runId}/stream?startOnly=1`. 4. **Stream route starts worker** — Calls `POST {WORKER_URL}/sessions/{appId}__agent__{runId}/agent-run` (fire-and-forget). Worker returns `{ status: "started" }` immediately. 5. **Worker runs agent** — `AgentRunManager` spawns the agent in the background. Buffers messages via `EventEmitter`. When a custom tool runs, the worker sends `runId` to `/api/internal/tool-execute`; it does not send a user ID for OAuth. 6. **Optional drawer viewer reads events** — If someone opens the run details drawer, the stream route connects to `GET {WORKER_URL}/sessions/{appId}__agent__{runId}/agent-run/{runId}/events` and translates SDK events to UIMessageStream via the bridge, same as builder agent streaming. 7. **Agent finishes** — Worker calls `POST /api/internal/agent-run-complete` with status, result, usage, and the SDK message transcript. The web route converts that transcript to UI messages and updates the run document. 8. **Bridge receives completion** — The bridge listens for compact workspace run events and posts `second:agent:update` to the iframe. A low-frequency summary poll is kept only as a missed-event fallback. ### Streaming in the agents drawer App agent execution and viewing are separate. Triggering an app agent starts it with `startOnly=1`, which returns quickly and leaves the worker-owned run active in `AgentRunManager`. The browser opens an app-agent SSE stream only when the user explicitly opens a run in the Agent Runs drawer, so several app agents can run without consuming several long-lived browser connections. App agent viewer streams use the same UI message format as the builder agent. When a viewer stream exists, the route records UI chunks in the Redis replay buffer used by builder runs. Reopening the agents drawer first tries the active resumable stream; if that handle is stale, it can replay a complete captured buffer, or rebuild the transcript from the worker events endpoint while the worker still has the run in memory. Replay buffers are only used when they start at the beginning of the run, so the UI is not asked to process a `tool-input-delta` without the matching `tool-input-start`. Closing the run viewer aborts the browser fetch and the web route forwards that disconnect signal to the worker events fetch. This releases the viewer connection without stopping the background app-agent run, and clears the transient active stream id if that viewer owned it. The drawer and iframe status bridge use projected run summaries for list and status polling. Full `UIMessage[]` transcripts stay behind the explicit run viewer path, keeping navigation and the Agent Runs dropdown off the hot transcript path. ## Async execution (AgentRunManager) The worker runs app agents via `AgentRunManager` (`apps/worker/src/agent-run-manager.ts`), an in-memory event-driven system that decouples agent execution from the SSE viewer. Each app-agent run is keyed by `runId`, and the web server uses a per-run worker session key so multiple app agents can run at the same time without sharing one SDK transport. ### Design * **Fire-and-forget**: `start(config)` spawns the agent in the background and returns immediately. * **Event buffering**: All SDK messages are buffered in memory. Late-joining SSE viewers catch up on buffered messages, then receive live events. * **EventEmitter**: Each run has its own `EventEmitter`. Viewers subscribe via `events(runId)` — an async generator that yields buffered messages first, then live messages. * **Callback**: When the agent finishes, the manager POSTs to `callbackUrl` (`/api/internal/agent-run-complete`) with the final status, result, and usage. * **Cleanup**: Completed runs are kept for 30 minutes (for late-joining viewers), then evicted. Max 100 concurrent runs with LRU eviction of completed/failed runs. ### Why this exists The original plan described a simple fire-and-forget with a callback. In practice, the SSE viewer needs to both catch up on past messages AND receive live events from a background agent. The `AgentRunManager` with its event buffering and `EventEmitter` solves this — it's the bridge between the background agent and any number of SSE viewers. ## Worker endpoints ### `POST /sessions/:appId/agent-run` Start a background agent run. Returns immediately. **Request body:** ```json theme={null} { "runId": "abc-123", "prompt": "Enrich lead Sarah Chen", "systemPrompt": "You are a lead enrichment specialist...", "agentConfig": { "id": "lead-enricher", "tools": [...], "dataCollections": ["leads"] }, "allowedTools": ["WebSearch", "WebFetch", "mcp__app_data__update_app_data"], "workspaceId": "ws-1", "appId": "app-1", "callbackUrl": "http://web:3000/api/internal/agent-run-complete", "sourceFiles": { "src/App.tsx": "..." } } ``` **Response:** `{ "status": "started", "runId": "abc-123" }` ### `GET /sessions/:appId/agent-run/:runId/events` SSE stream of raw SDK messages from a running (or recently completed) agent. Yields buffered messages first, then live events. ## API routes ### App agent runs | Method | Path | Purpose | | ------ | ---------------------------------------------------------- | ---------------------------------- | | `POST` | `/api/workspaces/[wId]/apps/[aId]/agent-runs` | Create a new run (status: pending) | | `GET` | `/api/workspaces/[wId]/apps/[aId]/agent-runs/[rId]` | Get run status and result | | `POST` | `/api/workspaces/[wId]/apps/[aId]/agent-runs/[rId]/stream` | Start agent + return SSE stream | | `GET` | `/api/workspaces/[wId]/apps/[aId]/agent-runs/[rId]/stream` | Resume disconnected stream | ### Agents config | Method | Path | Purpose | | ------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `GET` | `/api/workspaces/[wId]/apps/[aId]/agents` | Get draft agents.json for app creators/collaborators or published agents.json for viewers | | `PATCH` | `/api/workspaces/[wId]/apps/[aId]/agents` | Admin/owner/app creator: update draft agents.json in the draft source snapshot | | `POST` | `/api/workspaces/[wId]/apps/[aId]/agents/approval` | Admin/owner: approve the exact draft agents.json payload | ### App integration actions | Method | Path | Purpose | | ------ | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `POST` | `/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute` | Browser-authenticated app action execution through the parent bridge | | `POST` | `/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/report-failure` | Browser-authenticated draft-only builder repair report for app-callable backend functions | ### Internal (worker → web) | Method | Path | Purpose | | ------ | ---------------------------------------- | ------------------------------------------------------------------------ | | `POST` | `/api/internal/agent-run-complete` | Worker callback when agent finishes | | `POST` | `/api/internal/tool-execute` | Execute custom HTTP tool (see [Integrations](/integrations)) | | `POST` | `/api/internal/tool-failure-report` | Create a builder repair run from a blocked app-agent custom-tool failure | | `POST` | `/api/internal/integration-requirements` | Sync builder-requested integration setup metadata | | `POST` | `/api/internal/workspace-integrations` | Return live integration metadata to the builder without secret values | Internal endpoints bypass the browser auth proxy and authenticate via `INTERNAL_API_TOKEN`. Local development can omit the token; production requires it on both web and worker. See [Guard and Tenancy — Internal API bypass](/guard-and-tenancy#internal-api-bypass). ## Custom tool execution Custom tools and app actions (type `"custom"` in agents.json) are HTTP requests to external APIs. The agent and iframe never see API secrets, OAuth client secrets, refresh tokens, or access tokens. Agent tool execution is proxied through the web server's `/api/internal/tool-execute` endpoint, which verifies that the requested tool is present in the approved `agents.json` payload for the calling agent. App action execution uses `/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute`, which resolves the canonical approved top-level `appTools[]` item server-side. For static tools, the shared executor injects named secrets and non-secret tool input placeholders at call time. For OAuth agent tools, it loads the app-agent run by `{ workspaceId, appId, runId }`, resolves `triggeredByUserId` from that server-created row, checks the user's connected account and scopes, refreshes the access token on demand if needed, injects `Authorization: Bearer ` server-side, and calls the provider API. For OAuth app actions, it uses the current authenticated app viewer as the OAuth user. See [Integrations](/integrations) for the full flow. When an integration is not configured, the tool returns a random entry from `mockData` so development can continue without real API credentials. This includes OAuth missing-account, revoked-account, missing-scope, or provider-config failures. When a custom tool fails after execution begins, the app agent may call `mcp__app_tools__report_tool_call_failed`. Generated app code may call `reportIntegrationToolFailure` for repairable app-callable backend function failures while testing the editable draft. Both report paths create a normal builder run, so repair uses the same chat streaming, source persistence, `present_agents`, `present_integration_setup`, and `done_building` controls as any other build change. ## Key files | File | Role | | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `apps/worker/src/runner.ts` | `list_app_integration_keys`, `present_agents`, `present_integration_setup`, `buildCustomToolsMcpServer`, `buildAppDataMcpServer` | | `apps/worker/src/agent-run-manager.ts` | Background agent execution with event buffering | | `apps/worker/src/index.ts` | `/sessions/:appId/agent-run` and events endpoints | | `apps/web/src/components/app-agent-bridge.tsx` | postMessage bridge — triggers agents, listens for status events | | `apps/web/src/components/app-integration-bridge.tsx` | postMessage bridge — executes app-callable integration actions | | `apps/web/src/app/api/.../agent-runs/route.ts` | Create run | | `apps/web/src/app/api/.../agent-runs/[rId]/stream/route.ts` | Start agent + SSE stream | | `apps/web/src/app/api/internal/agent-run-complete/route.ts` | Worker completion callback | | `apps/web/src/app/api/internal/tool-execute/route.ts` | Internal app-agent custom tool approval enforcement | | `apps/web/src/app/api/.../app-tools/[toolName]/execute/route.ts` | Browser-authenticated app integration action execution | | `apps/web/src/app/api/.../app-tools/[toolName]/report-failure/route.ts` | Draft-only builder recovery reports from generated app code | | `apps/web/src/lib/integrations/execute-http-action.ts` | Shared HTTP action executor with secret/OAuth injection, domain/IP guards, size limits, and mock fallback | | `apps/web/src/app/api/internal/tool-failure-report/route.ts` | App-agent tool failure recovery bridge to the builder | | `apps/web/src/app/api/internal/integration-requirements/route.ts` | Integration requirement sync from builder tools | | `apps/worker/src/workspace-template.ts` | SDK with `useAgent`, `useAgentList`, `callIntegrationTool`, and `useIntegrationTool` | # App Data Source: https://docs.second.so/app-data How apps persist scoped data in MongoDB with live updates and governed agent access. Apps built on Second persist data in MongoDB via a simple SDK. The SDK provides `useCollection` and `useDoc` hooks that work like Firestore's `onSnapshot` — data updates automatically when changed, whether from the app UI or from an approved agent running in the background. ## How it works ``` App iframe (useCollection / useDoc) → postMessage("second:data:insert", { collection: "leads", data: {...} }) → AppDataBridge (parent window) → POST /api/.../data → MongoDB insert ↓ Change Stream fires ↓ SSE endpoint pushes event ↓ AppDataBridge receives SSE ↓ postMessage("second:data:change") → iframe ↓ useCollection hook updates state → re-render ``` Writes go through REST. Live updates come back through MongoDB Change Streams → SSE → postMessage. The SDK also applies **optimistic updates** — the app that initiated the write sees it instantly without waiting for the Change Stream round-trip. Draft and published apps use separate data scopes. The published app reads and writes the app's normal data scope. Draft preview and draft app-agent runs use an internal draft scope, so builders can test data changes without mutating the data used by the published app. ## Data SDK The SDK is included in the workspace template at `src/lib/second-sdk.ts` alongside the agent hooks. ### `useCollection(collectionName)` List all documents in a collection with live updates. ```typescript theme={null} import { useCollection } from '@/lib/second-sdk'; function LeadList() { const { data: leads, loading, insert, update, remove } = useCollection('leads'); return (
{leads.map(lead => )}
); } ``` | Return value | Type | Description | | --------------------- | --------------------------------------- | --------------------------------------------- | | `data` | `Doc[]` | All documents in the collection, updated live | | `loading` | `boolean` | `true` during initial fetch | | `insert(data)` | `(data: object) => void` | Insert a new document | | `update(docId, data)` | `(docId: string, data: object) => void` | Partial update (merges into `data` field) | | `remove(docId)` | `(docId: string) => void` | Delete a document | ### `useDoc(collectionName, docId)` Single document with live updates. ```typescript theme={null} import { useDoc } from '@/lib/second-sdk'; function LeadDetail({ id }: { id: string }) { const { data: lead, loading, update, remove } = useDoc('leads', id); return
{lead?.name}
; } ``` | Return value | Type | Description | | -------------- | ------------------------ | --------------------------- | | `data` | `Doc \| null` | The document, updated live | | `loading` | `boolean` | `true` during initial fetch | | `update(data)` | `(data: object) => void` | Partial update | | `remove()` | `() => void` | Delete the document | ### Optimistic updates The plan originally relied entirely on Change Streams for reactivity (write → MongoDB → Change Stream → SSE → re-render). This round-trip would feel sluggish. The SDK applies optimistic local state updates in `insert`, `update`, and `remove` — the app that initiated the write sees it instantly. The Change Stream event arrives shortly after and reconciles state for all other connected clients. ## Database ### `app_data` collection All app data lives in one MongoDB collection, partitioned by `workspaceId` + `appId` + `collection`. ```typescript theme={null} type AppDataDocument = { _id: string; workspaceId: string; appId: string; collection: string; // "leads", "contacts", etc. data: Record; // The actual fields createdAt: Date; updatedAt: Date; }; ``` ### Indexes | Index | Purpose | | ------------------------------------------------------------ | --------------------- | | `{ workspaceId: 1, appId: 1, collection: 1, updatedAt: -1 }` | Primary query pattern | | `{ workspaceId: 1, appId: 1, collection: 1, _id: 1 }` | Single doc lookups | ### Data isolation All queries include `workspaceId` + a scoped `appId` — an app can never access another app's data, and a workspace can never access another workspace's data. Published runtime uses the app's normal ID. Draft runtime uses an internal draft ID for the same app. ### Schemaless Apps don't need to define schemas. They just write objects. The builder agent knows the data shape because it wrote the code. No migrations, no schema files. ## REST API ### Collection-level | Method | Path | Purpose | | ------ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `GET` | `/api/workspaces/[wId]/apps/[aId]/data?collection=leads` | List documents in a collection. `version=draft` uses the draft data scope for collaborators | | `POST` | `/api/workspaces/[wId]/apps/[aId]/data` | Insert document `{ collection, data }`. `version=draft` uses the draft data scope for collaborators | ### Document-level | Method | Path | Purpose | | -------- | ---------------------------------------------------------------- | -------------------------------------- | | `GET` | `/api/workspaces/[wId]/apps/[aId]/data/[docId]?collection=leads` | Get single document | | `PATCH` | `/api/workspaces/[wId]/apps/[aId]/data/[docId]` | Update document `{ collection, data }` | | `DELETE` | `/api/workspaces/[wId]/apps/[aId]/data/[docId]?collection=leads` | Delete document | All routes use `requireWorkspaceContext` for auth. Draft data access additionally requires creator, collaborator, admin, or owner access to the app. ## Live updates (Change Streams + SSE) When data changes in MongoDB (from any source — app UI, agent, direct API), all connected clients see the update in real time. ### Architecture ``` MongoDB Change Stream (filtered by workspaceId + appId) ↓ SSE endpoint: GET /api/workspaces/[wId]/apps/[aId]/data/stream ↓ (EventSource in browser) AppDataBridge (parent window) ↓ (postMessage to iframe) useCollection / useDoc hooks ↓ (React state update → re-render) ``` ### SSE event format ``` data: {"type":"insert","collection":"leads","doc":{"_id":"...","name":"Sarah",...}} data: {"type":"update","collection":"leads","docId":"...","doc":{"_id":"...","name":"Sarah Updated",...}} data: {"type":"delete","collection":"leads","docId":"..."} ``` The SSE endpoint sends 30-second heartbeats to keep the connection alive. In the browser, `AppDataBridge` shares this EventSource across tabs for the same app/version with `BroadcastChannel` and Web Locks. This keeps live data reactive without opening one persistent MongoDB Change Stream connection per tab and exhausting the browser's per-origin connection budget during long builder streams. `AppDataBridge` also buffers live changes before forwarding them into the iframe. Bursty agent writes are delivered in small chunks so app data can keep up without monopolizing the browser renderer. The platform's Data Explorer only subscribes to those parent-state updates while the explorer is open; otherwise the iframe receives the live changes directly and the workspace shell does not re-render for every inserted document. ### Change event handling in SDK hooks When a `second:data:change` message arrives from the parent: * **insert** → add document to local array * **update** → merge changes into matching document * **delete** → remove document from local array No refetch needed — the hooks update in-place from the change event. ### Replica set requirement MongoDB Change Streams require a **replica set**. In local development, `docker-compose.yml` starts MongoDB with `--replSet rs0` and a healthcheck that auto-initiates the replica set. In production (e.g., MongoDB Atlas), replica sets are the default — no extra configuration needed. ## PostMessage protocol ``` // Data operations (iframe → parent) second:data:list { collection, requestId } second:data:doc { collection, docId, requestId } second:data:insert { collection, data, requestId } second:data:update { collection, docId, data, requestId } second:data:delete { collection, docId, requestId } // Data responses (parent → iframe) second:data:list-response { collection, docs, requestId } second:data:doc-response { collection, doc, requestId } second:data:insert-response { collection, doc, requestId } second:data:update-response { collection, docId, doc, requestId } second:data:delete-response { collection, docId, requestId } // Live change events (parent → iframe, from Change Stream SSE) second:data:change { collection, operation: 'insert'|'update'|'delete', doc?, docId? } ``` Each request includes a `requestId` for request/response matching. ## Agent data access Agents can read and write to an app's data collections when they have `dataCollections` defined in their agents.json config. Two MCP tools are registered: ### `update_app_data` Write data to the app's database. Supports `insert`, `update`, `upsert`, and `delete` operations. ``` Agent calls update_app_data → Worker MCP tool handler validates collection access → POST /api/internal/app-data-write → MongoDB write → Change Stream fires → SSE → app sees update live ``` The `upsert` operation was added because agents often don't know if a record already exists. The `filter` must include `_id` for update and delete operations. ### `read_app_data` Read data from the app's database. List all docs in a collection, or fetch a single doc by ID. ``` Agent calls read_app_data → Worker MCP tool handler validates collection access → POST /api/internal/app-data-read → MongoDB query → returns docs ``` This tool was added during implementation because agents need to read data too (e.g., "summarize all my todos"). The original plan only included write access. ### Collection access control The `dataCollections` field in agents.json limits which collections an agent can access. The worker validates this before calling internal endpoints, and the web internal endpoints validate it again against the approved `agents.json` payload for the calling agent. An agent without `dataCollections` gets neither tool. Draft app-agent data tools use the draft data scope and require the current draft versioned canonical `agents.json` hash to match an admin/owner approval. Published app-agent data tools use the published data scope and the approved payload promoted with the published snapshot. ### Internal endpoints | Method | Path | Purpose | | ------ | ------------------------------ | ------------------------------------ | | `POST` | `/api/internal/app-data-write` | Agent writes data to app collection | | `POST` | `/api/internal/app-data-read` | Agent reads data from app collection | Both endpoints bypass the browser auth proxy and authenticate via `INTERNAL_API_TOKEN`. They still require explicit `workspaceId`, `appId`, source version, agent ID, and `collection` values and execute database queries scoped by those fields. See [Guard and Tenancy — Internal API bypass](/guard-and-tenancy#internal-api-bypass). ## Agent run status from the app The `useAgent` hook exposes live run status (`idle` → `running` → `completed`). `AppAgentBridge` starts the run, listens for compact workspace run events, and posts `second:agent:update` messages to the iframe. A low-frequency watchdog poll remains as a missed-event fallback, but the bridge does not keep one high-frequency polling loop per active app agent. The original plan proposed watching the `app_agent_runs` collection directly from the browser. The implementation uses existing workspace realtime events instead, so app-agent status shares the same compact event channel as other workspace chrome. ## Key files | File | Role | | ------------------------------------------------------- | --------------------------------------------------------------------- | | `apps/web/src/lib/db/repositories/app-data.ts` | App data CRUD operations | | `apps/web/src/components/app-data-bridge.tsx` | postMessage bridge + SSE subscription | | `apps/web/src/app/api/.../data/route.ts` | REST API (list/insert) | | `apps/web/src/app/api/.../data/[docId]/route.ts` | REST API (get/update/delete) | | `apps/web/src/app/api/.../data/stream/route.ts` | Change Stream SSE endpoint | | `apps/web/src/app/api/internal/app-data-write/route.ts` | Agent data write | | `apps/web/src/app/api/internal/app-data-read/route.ts` | Agent data read | | `apps/worker/src/runner.ts` | `buildAppDataMcpServer` — `update_app_data` and `read_app_data` tools | | `apps/worker/src/workspace-template.ts` | SDK with `useCollection`, `useDoc` hooks | # App Governance Source: https://docs.second.so/app-governance How drafts, reviews, integrations, agent config, and published runtime state stay under workspace control. Second separates building from publishing. Workspace roles are only `owner`, `admin`, and `member`; creator and collaborator are app-level access categories. People with app-level build access can keep iterating in a draft, while the published app keeps serving the last reviewed snapshot to its teams. The goal is simple: builders can move quickly, but the runtime that team members use is the version an admin or owner intentionally allowed to use real data and integrations. Source control is a separate app source storage layer. Connecting a provider such as GitHub, GitLab, or Bitbucket does not publish or upload apps by itself. Local CLI/desktop installs use explicit app-level Publish to source control. On-prem or managed deployments can enable a workspace-level Store app source in source control policy so successful builds store app source in the configured provider and create auto-versioned `second-app-v` tags. This is separate from Available Apps discovery and from the normal review/publish flow. See [Source Control](/source-control). ## Roles and app access | Actor | Can do | | ----------------------- | ----------------------------------------------------------------------------------------------------------------- | | Workspace owner/admin | Review and publish apps, approve agent config, configure integrations and secrets | | Workspace member viewer | Use published apps for teams they belong to | | App creator | App-level access from `createdByUserId`; build and edit that draft app, request review, use the published app | | App collaborator | App-level access from `collaboratorUserIds`; build and edit that draft app, request review, use the published app | Local `none` auth is optimized for single-user development. External/on-prem deployments use the same code paths, but members create review requests and admins/owners approve before publishing. ## Draft and published snapshots Apps have two source snapshots: | Snapshot | Stored in | Used by | | --------- | -------------------------------------------- | ------------------------------------------------------------------ | | Draft | `app_source_snapshots` (`kind: "draft"`) | App creators, app collaborators, admins, and owners while building | | Published | `app_source_snapshots` (`kind: "published"`) | Published app viewers and builders using the published toggle | Editing a published app creates or updates the draft only. The published app continues to use the published snapshot until a new publish or review approval promotes the draft. The `apps` document stores snapshot IDs, hashes, file counts, and byte sizes so app lists, navigation, review inboxes, and access checks do not load source files. Older apps with embedded `sourceFiles` or `publishedSourceFiles` are still readable as a compatibility fallback. If an app is already in review and someone keeps editing it, the pending review is marked `superseded` and the app returns to `draft`. The UI tells the builder they are now editing an unpublished version and must request review again. ## Review flow 1. Builder finishes a draft. 2. App creator/collaborator selects target teams and requests review. 3. Admin/owner reviews the app, integration requirements, and agent config. 4. Approval promotes the current draft source snapshot into the published source snapshot. 5. Team viewers use the promoted published snapshot. Creating `agents.json`, calling `present_agents`, or approving the Agents card does not create a review request and does not publish the app. Review starts only through the publish dialog. ## Governed agents.json `agents.json` is editable draft code, but it is not trusted runtime configuration until the platform records an approval for the versioned canonical JSON hash. The flow is: ``` Builder writes agents.json → present_agents reads and validates the file → admin/owner approves the Agents card payload → platform stores versioned canonical hash + normalized approved payload + approver metadata → draft agent runtime can use that exact config ``` The canonical hash is intentionally schema-versioned. Hashes are stored as `vN:` so future `agents.json` schema changes can keep previous approval semantics stable. In the current `v1` schema, harmless empty optional arrays such as top-level `appTools: []`, agent-level `tools: []`, and agent-level `dataCollections: []` are normalized away before hashing. A schema change that adds or changes defaulted fields should add a new hash version rather than changing the old version's normalization rules. Any later `agents.json` change clears the draft approval. It does not matter whether the change came from a UI toggle, a file edit, or a worker shell command: the source persistence path compares the versioned canonical hash and marks the approval stale if it changed. Draft app-agent runs can start from the draft file so builders can test the in-progress app. Custom HTTP tools and agent data tools still require the current draft hash to match the stored approval before they can touch live integrations or app data. Publishing or review approval promotes the approved payload with the published snapshot, so published runtime continues to use the reviewed configuration. ## Integration and domain approval Integrations are app-scoped grants under workspace governance. Admins and owners configure static secrets or workspace OAuth provider clients and mark the currently requested permission groups, scopes, and secret names as configured. The agent never receives static secret values, OAuth client secrets, refresh tokens, or access tokens. Custom tools are tied to an integration domain in `agents.json`, for example `slack.com` or `hubapi.com`. At runtime, `/api/internal/tool-execute`: 1. Loads the current app's integration grant by `(workspaceId, appId, domain, keySlug)`. 2. Verifies the tool exists in the approved `agents.json` payload for that agent. 3. For static tools, reads named secrets from Vault or local development storage. 4. For OAuth tools, loads the server-created app-agent run by `(workspaceId, appId, runId)`, resolves the triggering user, checks that user's connected account and scopes, refreshes the access token on demand, and injects the bearer token server-side. 5. Substitutes only named static secret placeholders such as `{{secrets.SLACK_BOT_TOKEN}}`. 6. Validates that the final URL hostname matches the configured domain or one of its subdomains. 7. Rejects private/internal IPs, non-HTTPS production URLs, oversized responses, and broad static calls when input was provided. If an integration is missing or not configured, the tool returns explicit mock data instead of a live API response — this includes OAuth missing-account, revoked-account, missing-scope, or provider-config failures. If a draft changes `agents.json` to request a new domain, endpoint, auth mode, OAuth scope, token URL, secret, permission, or data collection, that draft must be approved again before runtime can use it. ## Draft data isolation Draft and published app data are also separated. Published apps read and write under the app's normal data scope. Draft previews and draft app-agent runs use an internal draft data scope for the same app. This lets builders test changes without mutating the live data used by the published app. All data queries still include `workspaceId` and the scoped app ID. Agent data tools also verify that the requested collection appears in the approved `agents.json` payload for the calling agent. # App Preview Source: https://docs.second.so/app-preview How Second builds, previews, and persists Vite artifacts using done_building. Second's app preview system renders compiled artifacts in a sandboxed iframe. The agent edits a Vite + React + TypeScript workspace, calls `done_building`, the worker runs a real build, and the frontend renders `dist/index.html` plus built assets. No Sandpack. No in-browser bundling. ## TLDR ``` ┌───────────────────────────────────────────────────────────────────────────────┐ │ BUILD + PREVIEW LIFECYCLE │ │ │ │ 1. WRITE Agent edits Vite project files in workspace │ │ (src/*, config files, etc.) │ │ │ │ 2. BUILD Agent calls done_building tool │ │ → Worker awaits background dep warmup (started at scaffold) │ │ → Worker installs deps if still needed │ │ → Worker runs npm run typecheck + npm run build in parallel │ │ → Build output must include dist/index.html │ │ │ │ 3. STREAM END Bridge fetches workspace snapshot from worker │ │ → Chat API persists snapshot to app_source_snapshots │ │ │ │ 4. PREVIEW Frontend requests live files via web API │ │ → web API uses worker files when available │ │ → falls back to Mongo source snapshot after sandbox churn │ │ → renders compiled artifact in iframe │ │ │ │ 5. RESTORE On cold start / recycled worker workspace │ │ source restores from source control for backed apps, │ │ otherwise from Mongo │ └───────────────────────────────────────────────────────────────────────────────┘ ``` ## Storage model | What | Where | Format | | ----------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------- | | Working copy during agent execution | Worker filesystem (`/tmp/second-workspaces/{appId}` by default) | Regular project files | | Durable snapshot | MongoDB `app_source_snapshots` | `Record` (source + `dist/**` text files) | | Source-control authority, optional | Provider repository, such as GitHub, GitLab, or Bitbucket | Sanitized app files + root `second-app.json` | | Snapshot metadata | MongoDB `apps` | Snapshot IDs, hashes, file counts, and byte sizes | | Chat messages and run metadata | MongoDB `agent_runs` | `UIMessage[]` and run fields | | Preview runtime | Browser iframe | `srcDoc` HTML built from current files returned by the web API | `app_source_snapshots` is the durable restore snapshot. Live preview/file explorer reads prefer the worker workspace via `/sessions/:appId/files`, then fall back to the persisted Mongo snapshot when the worker workspace is missing or empty after sandbox churn. The `apps` document keeps compact metadata so app lists, navigation, and access checks do not load source files. Legacy embedded `apps.sourceFiles` snapshots remain readable until the app is saved or migrated. ## Source-control boundary When an app is source-control-backed through app-level publish, workspace source storage, or Available Apps install/update, the configured provider becomes the authority for that app's source. MongoDB still stores a materialized snapshot/cache so app pages can render quickly. Normal preview/page loads do not download from source control and do not compile source. The provider is consulted only from explicit mutation paths such as app publish, workspace source-storage sync, Available Apps install/update, or worker/session restore after the live workspace is gone. This keeps the hot preview path fast while source control can still be the authoritative source store. See [Source Control](/source-control) for the full source storage, app-level publish, auto-versioning, and Available Apps model. ## Workspace template New workspaces are scaffolded with a Vite + React + TS + Tailwind + Shadcn starter. Template includes: * `package.json` (`dev`, `typecheck`, `build`, `preview`) * `index.html` * `vite.config.ts` * `tailwind.config.ts` * `postcss.config.js` * `tsconfig*.json` * `src/main.tsx`, `src/App.tsx`, `src/index.css` * `src/components/ui/button.tsx` * `src/lib/utils.ts` * `src/lib/second-sdk.ts` — the app SDK with `useAgent`, `useAgentList`, `useCollection`, and `useDoc` hooks. See [App Agents](/app-agents) and [App Data](/app-data) * `components.json` If `sourceFiles` are provided during scaffold (restore path), those files are written instead of the template. ## Build step (`done_building`) The `mcp__second__done_building` tool is the build gate. Behavior: 1. Validates required Vite files exist. 2. Awaits the background dependency warmup started at scaffold time (if any). 3. Installs dependencies only when still needed (`node_modules` missing, lock drift, or a declared package is absent from `node_modules`). 4. Runs `npm run typecheck` and `npm run build` in parallel (when both scripts are defined in `package.json`). Scripts are honored as-is, so swapping bundlers or type-checkers "just works". 5. Requires `dist/index.html` to exist. 6. Collects workspace snapshot (including `dist/**`). 7. Returns structured success payload (`status: "complete"`) or explicit failure text. If install/build fails, the agent receives errors (typecheck and build errors reported separately) and must fix/retry. ### Dependency warmup Workspaces start `npm install --include=dev` in the background as soon as they are scaffolded (see `apps/worker/src/dep-warmup.ts`). The explicit dev-dependency include matters in production workers because npm otherwise follows `NODE_ENV=production` and may omit Vite, TypeScript, PostCSS plugins, and type packages required by the generated app build. By the time `done_building` is called, deps are usually already installed, so the build step is dominated by the (parallel) typecheck + bundle rather than a cold install. Warmup is a no-op when `node_modules` already exists (warm worker), and `done_building` performs the same install only if the dependency tree is incomplete or stale. ## Snapshot guardrails Snapshot persistence is deliberately bounded: * Per-file cap: `1MB` * Warning threshold: `8MB` total snapshot size * Hard fail threshold: `12MB` total snapshot size * No silent truncation, no partial snapshot writes If limits are exceeded, `done_building` returns an explicit error and preview persistence does not advance. ## Preview rendering `AppPreview` resolves rendering in this order: 1. Artifact mode (`dist/index.html` + `dist/*` assets) 2. Legacy ArrowJS fallback (`main.js` / `main.ts`) for older snapshots Artifact mode rewrites built local asset references to iframe-safe inlined/data URLs so the compiled app runs directly inside `srcDoc`. Iframe is sandboxed and intentionally omits `allow-same-origin`, so the preview does not regain same-origin privileges with the parent app. Parent-window bridges validate `event.source` against the expected iframe window before handling SDK messages. Workspace owners and admins can tune generated-app runtime capabilities in Settings → Runtime settings. The default policy preserves the normal app experience: scripts run, clipboard writes are available for user-triggered copy actions, and external links can open new tabs. Hardening those toggles removes the corresponding iframe `sandbox` or `allow` capability without changing the tenant boundary or granting same-origin access. ## done\_building success detection Build-complete UI transitions are triggered only when `done_building` returns a successful structured payload (`status: "complete"`). Failed tool outputs no longer flip the workspace into a false "ready" state. If a builder run writes app files or attempts `done_building` but the stream ends outside a valid approval stop before a successful snapshot is available, the run is marked failed instead of completed so the chat cannot sit forever on a stale "creating" tool card. ## Persistence and conditional hydration On each user message: 1. Web checks worker status (`/sessions/:appId/status`). 2. If restore is **not** needed, web sends prompt without `sourceFiles`. 3. If restore **is** needed, web loads the latest source from source control when the app is source-control-backed; otherwise it loads the latest draft source snapshot from Mongo. Restored files are saved back into Mongo as a fast cache. 4. Worker scaffolds from `sourceFiles` only when workspace is empty. This avoids reloading large snapshots on every turn while preserving recovery after worker/session churn. ## Live reads vs durable restore * Live preview/file explorer reads: web API calls worker `GET /sessions/:appId/files` using `WORKER_URL` when live files are available, merging them over the persisted snapshot so live source can update without hiding the last compiled `dist/**` artifact. * Idle/cold fallback: if the worker is unavailable or returns an empty workspace, the web API returns the MongoDB source snapshot so the compiled app and file explorer remain visible after the 15-minute sandbox TTL. * Durable recovery after worker loss: chat restore path loads source-control source for source-control-backed apps, otherwise MongoDB source, and rehydrates the workspace. * Result: UI shows current worker filesystem when available; Mongo snapshot/cache keeps the idle preview visible, while source control is used only for mutation-time restore when an app is source-control-backed. ## Key files | File | Role | | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- | | `apps/worker/src/workspace-template.ts` | Vite + TS + Shadcn scaffold | | `apps/worker/src/runner.ts` | `done_building` implementation, snapshot limits, workspace collection | | `apps/worker/src/dep-warmup.ts` | background `npm install` started at scaffold time | | `apps/worker/src/index.ts` | scaffold logic, status/files endpoints, warmup trigger | | `apps/web/src/components/app-preview.tsx` | artifact-first iframe rendering + legacy fallback | | `apps/web/src/components/app-chat.tsx` | successful `done_building` detection in stream UI | | `apps/web/src/components/app-workspace.tsx` | initial preview visibility logic | | `apps/web/src/app/w/[workspaceId]/settings/runtime-settings` | workspace runtime capability settings for generated app iframes | | `apps/web/src/lib/agent/worker-bridge.ts` | bridge stream handling and build-complete gating | | `apps/web/src/app/api/.../chat/route.ts` | conditional hydration + snapshot persistence | | `apps/web/src/lib/db/repositories/app-source-snapshots.ts` | source snapshot storage, hashes, file counts, and size summaries | | `apps/web/src/lib/db/repositories/apps.ts` | app metadata, legacy fallback, and snapshot promotion | | `scripts/migrate-app-source-snapshots.mjs` | optional operational migration from embedded legacy source maps into snapshot documents | # Architecture Source: https://docs.second.so/architecture How Second's governed workspace architecture connects the browser, web app, worker, and database. Second is built around a workspace-first data model and a streaming agent architecture. The browser talks to a Next.js API layer, which delegates agent work to a separate worker process. Access checks, review state, and source/data scoping live in the web layer before any worker output becomes trusted runtime state. ## System overview ``` Browser (useChat) │ ├─ POST /api/.../runs/[runId]/chat → send message, get SSE stream back ├─ GET /api/.../runs/[runId]/chat → load chat history │ ▼ ┌──────────────────────────────────────────────┐ │ Next.js (apps/web) │ │ │ │ API Route: │ │ ├─ validates workspace/app/run ownership │ │ ├─ atomically claims one active run stream │ │ ├─ connects to worker via HTTP │ │ ├─ createUIMessageStream() │ │ │ └─ worker-bridge reads worker SSE │ │ │ and writes UIMessage chunks │ │ ├─ Redis resumable stream → activeStreamId │ │ ├─ Redis replay buffer → cursor attach │ │ └─ onFinish() → MongoDB (persistence) │ └──────────────────┬───────────────────────────┘ │ HTTP (SSE stream) ▼ ┌──────────────────────────────────────────────┐ │ Worker (apps/worker) │ │ │ │ Builder agent: │ │ ├─ POST /sessions/:appId/messages │ │ │ → starts or continues agent session │ │ │ → returns SSE stream of SDK events │ │ ├─ GET /sessions/:appId/status │ │ └─ DELETE /sessions/:appId │ │ │ │ App agents (async): │ │ ├─ POST /sessions/:appId/agent-run │ │ │ → fire-and-forget background execution │ │ └─ GET /sessions/:appId/agent-run/:rId/events │ │ → SSE stream of agent messages │ │ │ │ Claude Agent SDK: │ │ └─ query() with streaming events │ └──────────────────────────────────────────────┘ ``` ## App agent and data flow In addition to the builder agent chat flow, apps can trigger agents and persist data: ``` App iframe (SDK hooks) │ ├─ useAgent() → postMessage → AppAgentBridge → /api/.../agent-runs → Worker ├─ useCollection() → postMessage → AppDataBridge → /api/.../data → MongoDB │ ▼ ┌──────────────────────────────────────────────┐ │ Live updates │ │ │ │ MongoDB Change Stream (app_data collection) │ │ → SSE: GET /api/.../data/stream │ │ → AppDataBridge │ │ → postMessage to iframe │ │ → SDK hooks re-render │ │ │ │ Agent writes: │ │ Worker → POST /api/internal/app-data-write │ │ → MongoDB → Change Stream → app sees it │ └──────────────────────────────────────────────┘ ``` See [App Governance](/app-governance), [Source Control](/source-control), [App Agents](/app-agents), [App Data](/app-data), and [Integrations](/integrations) for details. Draft and published runtime state are intentionally separate. Published app views read and write app data under the published app ID. Draft previews and draft app-agent runs use a draft data scope derived from the same app ID, so a builder can test data changes without mutating the currently published app data. ## Services | Service | Role | Runs in Docker (dev) | Runs on host (dev) | | ----------- | ----------------------------------------------------------------------- | -------------------- | ------------------ | | **Web** | Next.js app — UI, API routes, persistence | | Yes | | **Worker** | Agent runner — executes Claude sessions | | Yes | | **MongoDB** | Data storage — users, workspaces, apps, runs | Yes | | | **Redis** | Resumable stream relay, run replay buffers, and workspace domain events | Yes | | ## Collections | Collection | Purpose | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `users` | Identity records (display name, email) | | `workspaces` | Workspace metadata | | `workspace_memberships` | Links users to workspaces with the `owner`, `admin`, or `member` role | | `workspace_teams` | Workspace-internal teams. Every workspace starts with a default `General` team | | `workspace_team_memberships` | Links users to workspace teams. New workspace members are added to `General` | | `workspace_invitations` | Workspace-scoped invitation records, external invitation IDs, requested role, and default team assignment | | `apps` | Workspace-owned application records, draft/review/published state, app collaborators, and team visibility | | `app_source_snapshots` | Large draft and published source-file snapshots, separated from hot app metadata paths | | `source_control_connections` | Workspace-scoped source-control provider configuration, connection status, owner metadata, and secret references | | `review_requests` | Workspace admin inbox items for app publication approval | | `agent_runs` | Builder agent runs: messages, `pending`/`streaming`/`completed` status, session state, active stream ID | | `integrations` | App-scoped integration grants, setup requirements, static/OAuth auth metadata, app/requester metadata, and integration state. See [Integrations](/integrations) | | `integration_credentials` | Static app-scoped API key/bot-token secret references and configured snapshots | | `oauth_provider_configs` | Workspace/provider OAuth client configs such as a customer-owned Google OAuth app; stores client ID and secret reference, not user tokens | | `connected_accounts` | Per-user OAuth account metadata and token secret references keyed by workspace, user, and provider config | | `app_agent_runs` | App-triggered agent runs (status, result, usage, triggering user). See [App Agents](/app-agents) | | `app_data` | App data documents, partitioned by `workspaceId` + scoped `appId` + `collection`. Published apps use the app ID; drafts use an internal draft scope. See [App Data](/app-data) | | `audit_events` | Append-only workspace audit records for governance, app/build lifecycle, integrations, app-agent/tool outcomes, app data writes, and safe authorization denials. See [Audit Logs](/audit-logs) | Every workspace-owned entity carries a `workspaceId` field and is always queried with it. Nested resources are also loaded with their parent IDs, for example `{ workspaceId, appId, runId }`, so a run from one app cannot be read through another app in the same workspace. ## Request flow (standard routes) ``` Request → Middleware proxy → Route handler → Repository → Response ``` 1. **Middleware proxy** — redirects unonboarded users, rejects unauthorized API calls. 2. **Route handler** — calls `requireWorkspaceContext` to validate actor + workspace membership. 3. **Permission check** — sensitive routes check a named workspace permission such as `integrations:manage` or `members:invite`. 4. **Repository** — runs the database query, scoped by `workspaceId`. Cross-workspace access returns `404` — not `403` — so a caller learns nothing about resources in other workspaces. App routes add one more check after loading the workspace: owners and admins can see every app, app creators (`createdByUserId`) and app collaborators (`collaboratorUserIds`) can see their private drafts/review requests, and published apps are visible only to members of the selected teams. Nested routes repeat the same rule at each parent boundary. App files, chat runs, app-agent runs, data, agents config, and settings all first prove workspace membership, then load the app by `{ workspaceId, appId }`, then load child resources by the full parent scope. ## Workspace realtime and settings reads Workspace chrome uses explicit Redis-backed domain events rather than request-scoped MongoDB change streams. Mutations publish small events such as `app.created`, `app.updated`, `review.updated`, `integration.changed`, `member.changed`, `run.stream_ready`, and `run.completed`. Events contain ids, status, timestamps, and invalidation scopes; they never carry prompts, source files, secrets, headers, cookies, or full database documents. `WorkspaceRealtimeProvider` owns one workspace event subscription around the workspace shell. Sidebar, app chrome, integration callouts, settings pages, and run-status indicators subscribe to that provider instead of each opening their own workspace event stream. Run chat streaming remains separate because it has stricter ordering and replay requirements. Settings pages render a cheap route shell first. Members, teams, invitations, and integrations then load through projected API read models. These API routes still authorize every request with `requireWorkspaceContext`; a short in-process dedupe window only shares duplicate reads for the same workspace, user, role, and membership version. Realtime invalidations are hints, not authorization decisions. ## App publishing and review New apps start as `draft`. A draft is visible only to its app creator, explicit app collaborators, and workspace admins and owners. Creator and collaborator are app-level access categories, not workspace roles. When the builder is ready to publish, the requester selects one or more workspace teams: 1. In local `none` auth mode, publishing has no approval step and only marks the app as published. 2. In external/on-prem mode, members create a pending app review request. 3. Admins and owners see pending requests in the review inbox, inspect the target teams, and review integration requirements. 4. Approval promotes the reviewed draft snapshot to the published snapshot and shares that published version with the selected teams. If any requested integration still needs configuration, approval is blocked until an admin or owner configures it. Owners and admins can self-publish after reviewing the app, but that path still records an approved review request for auditability. If the requester changes the app after creating a pending review — for example by sending another builder message or editing the approved agent configuration — the pending review is automatically closed as superseded and the app moves back to `draft`. Stale review approvals are rejected, and the requester must send the updated app for review again. Published apps keep a separate published source snapshot. Builders and agent-configuration edits mutate the draft source snapshot only. Current snapshots are stored in `app_source_snapshots`; the `apps` document keeps metadata pointers, hashes, file counts, and sizes so navigation and access checks do not load large file maps. Legacy embedded `sourceFiles` and `publishedSourceFiles` are still read as a fallback for old apps until they are saved or migrated. When an app creator or collaborator edits an already published app, the app keeps serving the last published snapshot to team viewers while the builder works on a draft. Publishing locally or approving a review promotes the current draft snapshot into the published snapshot. See [App Governance](/app-governance) for the full role and review flow. Source control is the repository-backed app source storage layer. Connecting a provider such as GitHub, GitLab, or Bitbucket at the workspace level only stores credentials and owner metadata; it does not upload apps by itself. Local CLI/desktop installs use app-level Publish to source control. On-prem or managed deployments can enable a workspace-level Store app source in source control policy, which makes successful builds sync sanitized source and built artifacts to the configured provider and create auto-bumped `second-app-v` tags. Normal app page loads still render a cached built artifact; source restore happens only when a source-control-backed app needs files after the live worker/container session is gone. Available Apps is a separate discovery layer, not the definition of source-control storage. See [Source Control](/source-control). `agents.json` is a protected draft artifact. The builder and file tools may edit it, but live agent runtime permissions are trusted only after the platform records an approval for the versioned canonical JSON hash. The canonicalizer is schema-versioned so harmless representation changes, such as missing optional arrays versus empty optional arrays, do not invalidate approval. Any later effective `agents.json` policy change clears that draft approval. Draft app-agent runs can start from the draft file so builders can test the in-progress app, but custom HTTP tools and agent data tools require the current draft hash to match the stored approval before they can touch live integrations or app data. Publishing and review approval promote both the source snapshot and the approved `agents.json` payload into the published snapshot. Integration domains and OAuth metadata are approved at runtime, not trusted from model output. A custom tool must exist in the approved `agents.json` payload, resolve an app-scoped integration grant by `workspaceId`, `appId`, `domain`, and `keySlug`, and pass the tool-execute domain/protocol/IP guards before any credential is injected. OAuth tools add one more trusted lookup: the web route loads `app_agent_runs` by `workspaceId + appId + runId` and resolves the triggering user from that server-created row before reading a connected account. ## Request flow (agent chat) ``` POST /api/.../runs/[runId]/chat → authenticate + load app by workspaceId/appId → load run by workspaceId/appId/runId → atomically mark run as streaming → createUIMessageStream({ execute, onFinish }) → register Redis resumable stream → capture Redis replay chunks for cursor attach → worker-bridge connects to worker SSE → translates Claude SDK events → AI SDK UIMessageStream → streams to browser via SSE → onFinish persists messages + clears active stream in MongoDB ``` New runs start as `pending`. The first chat POST that claims the run starts the worker request. If a route remount, back/forward navigation, or second tab posts the same pending run while the first stream is initializing, the duplicate POST returns an empty successful stream and does not start another worker session. Completed runs can only be claimed again when the posted message list is longer than the persisted list, so stale browser history requests cannot replace a full conversation with the original first prompt. See [Agent System](/agent-system), [Worker](/worker), and [Streaming](/streaming) for details. ## Indexes Created automatically on startup: | Collection | Index | Notes | | ---------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------- | | `apps` | `{ workspaceId: 1, createdAt: -1 }` | List apps by workspace, newest first | | `apps` | `{ workspaceId: 1, publishStatus: 1, createdAt: -1 }` | List draft/review/published app groups | | `apps` | `{ workspaceId: 1, teamIds: 1, publishStatus: 1 }` | Enforce team-scoped published app lists | | `apps` | `{ workspaceId: 1, collaboratorUserIds: 1 }` | List private app collaboration access | | `app_source_snapshots` | `{ workspaceId: 1, appId: 1, kind: 1 }` | Unique — one draft and one published source snapshot per app | | `app_source_snapshots` | `{ workspaceId: 1, appId: 1, updatedAt: -1 }` | Find recent source snapshot metadata | | `review_requests` | `{ workspaceId: 1, status: 1, updatedAt: -1 }` | Admin review inbox | | `review_requests` | `{ workspaceId: 1, resourceType: 1, resourceId: 1, status: 1 }` | Find pending review for a resource | | `workspaces` | `{ slug: 1 }` | Unique — one URL slug per workspace | | `workspaces` | `{ externalOrganizationProvider: 1, externalOrganizationId: 1 }` | Sparse — map an external auth organization back to a workspace | | `workspace_memberships` | `{ workspaceId: 1, userId: 1 }` | Unique — one membership per user per workspace | | `workspace_memberships` | `{ userId: 1, workspaceId: 1 }` | Fast membership lookup from actor to workspace | | `workspace_memberships` | `{ workspaceId: 1, createdAt: 1 }` | Projected settings member lists | | `workspace_teams` | `{ workspaceId: 1, slug: 1 }` | Unique — one team slug per workspace | | `workspace_teams` | `{ workspaceId: 1, isDefault: 1 }` | Find the default team | | `workspace_teams` | `{ workspaceId: 1, isDefault: -1, name: 1 }` | Projected settings team lists | | `workspace_team_memberships` | `{ workspaceId: 1, teamId: 1, userId: 1 }` | Unique — one team membership per user/team/workspace | | `workspace_team_memberships` | `{ workspaceId: 1, userId: 1 }` | List a user's teams inside a workspace | | `workspace_invitations` | `{ workspaceId: 1, emailNormalized: 1, status: 1 }` | Find duplicate pending invitations | | `workspace_invitations` | `{ externalInvitationId: 1 }` | Sparse — reconcile external invitation status | | `users` | `{ emailNormalized: 1 }` | Unique — prevents duplicate accounts | | `integrations` | `{ workspaceId: 1, appId: 1, domain: 1, keySlug: 1 }` | Unique — one app-scoped integration grant per app/provider/key | | `integrations` | `{ workspaceId: 1, appId: 1, updatedAt: -1 }` | Review, publish, and app-state integration checks | | `integrations` | `{ workspaceId: 1, domain: 1 }` | Provider filtering and diagnostics | | `integration_credentials` | `{ workspaceId: 1, domain: 1, capabilityFingerprint: 1 }` | Credential lookup for compatible app grants | | `oauth_provider_configs` | `{ workspaceId: 1, providerKey: 1 }` | Unique — one OAuth client config per workspace/provider key | | `oauth_provider_configs` | `{ workspaceId: 1, updatedAt: -1 }` | Settings provider config lists | | `connected_accounts` | `{ workspaceId: 1, userId: 1, providerConfigId: 1 }` | Unique — one connected account per user/provider config | | `connected_accounts` | `{ workspaceId: 1, providerKey: 1, userId: 1 }` | Provider/user connection status lookups | | `connected_accounts` | `{ workspaceId: 1, userId: 1, updatedAt: -1 }` | Current-user settings projections | | `app_agent_runs` | `{ appId: 1, createdAt: -1 }` | List runs by app, newest first | | `app_agent_runs` | `{ workspaceId: 1, status: 1 }` | Query runs by workspace and status | | `agent_runs` | `{ workspaceId: 1, appId: 1, createdAt: -1 }` | List builder runs by app, newest first | | `app_data` | `{ workspaceId: 1, appId: 1, collection: 1, updatedAt: -1 }` | Primary query + Change Stream filter | | `app_data` | `{ workspaceId: 1, appId: 1, collection: 1, _id: 1 }` | Single doc lookups | # Authentication Source: https://docs.second.so/authentication Choose between local no-auth mode and an external auth provider. Second supports two authentication modes, controlled by the `SECOND_AUTH_MODE` environment variable. ## `none` mode (local development) Designed for local development and trusted internal networks. Identity is managed entirely within Second. How it works: 1. User fills out `/onboarding/identity` (display name + email). 2. A local signed session cookie is issued. 3. Users without workspace memberships are redirected to `/onboarding/workspace`. 4. Cookie signatures use an auto-generated process secret — no external config needed. Do not expose `none` mode to the public internet. There is no real authentication — anyone who can reach the server can create an identity. Local mode does not send real workspace invitations. The members UI still shows roles and governance state, but invitation actions explain that external authentication is required. For local multi-user testing, seed a second user into an existing workspace with `scripts/local-workspace-member.mjs`; the seeded user is added to the workspace's default `General` team. ## `external` mode (production) For internet-facing deployments. Second delegates authentication to your own provider. Your provider must: * Resolve the authenticated actor on each request * Return a stable user identifier * Map the identity into Second's `users` and membership model * Sync workspace memberships and roles into Second's `workspace_memberships` * Send and reconcile workspace invitations when collaboration is enabled If `external` mode is set but no provider extension is present, the app fails fast on startup — you'll see a clear error rather than silent misbehavior. External providers should map each workspace to the provider's organization or tenant concept. Accepted invitations must upsert the Second user, upsert the workspace membership with a validated role (`owner`, `admin`, or `member`), and ensure the user belongs to the workspace's default `General` team. ## How auth mode affects authorization Authentication mode only changes **where identity comes from**. The authorization flow is the same regardless: 1. Resolve actor (from session cookie or external provider) 2. Verify workspace membership 3. Check any route-level workspace permission 4. Query scoped by `workspaceId` See [Guard and Tenancy](/guard-and-tenancy) for the full enforcement model. # Contributing Source: https://docs.second.so/contributing Contribute to Second: local setup, quality checks, and pull request expectations. Contributions are welcome — whether it's a bug fix, a feature, or a documentation improvement. ## Getting started Follow the [Quickstart developer setup](/quickstart#developer-setup) to get the app running locally. Once `.second-dev.txt` contains a `url=` value you can open in the browser, you're ready to go. ## Before opening a PR Make sure lint and build pass: ```bash theme={null} npm run typecheck npm --prefix apps/web run lint npm --prefix apps/web run build npm --prefix apps/worker run typecheck ``` ## Guidelines * **One concern per PR** — keep changes focused and reviewable. * **Preserve workspace isolation** — if you're touching API routes or repositories, make sure `workspaceId` scoping is maintained. * **Bridge layer changes** — if you modify `worker-bridge.ts`, test with a real agent session to verify tool calls and streaming work end to end. * **No provider-specific details** — don't add private auth provider implementation details to OSS code or docs. * **Update docs** — if your change affects behavior or configuration, update the relevant doc page. ## Reporting bugs When filing an issue, include: * What you expected to happen * What actually happened * Steps to reproduce * Environment details: `SECOND_AUTH_MODE`, browser, and whether you're using `npm run dev` or Docker # Deployed Slowness Playbook Source: https://docs.second.so/deployed-slowness-playbook How to diagnose staging or production slowness with perf traces, Kubernetes events, and request amplification checks. Local speed is not enough proof that a deployed issue is gone. Deployed environments add external auth, real Redis, real Mongo/network latency, Kubernetes resource limits, health probes, and a load balancer. Start from evidence and avoid guessing. Use this playbook when the app feels frozen, navigation stalls, settings pages hang, chat history appears late, stream attach is slow, or users see temporary server errors. ## Before you start Use safe structured timing only while diagnosing: ```bash theme={null} SECOND_PERF_TRACE=1 ``` Perf traces are designed to be content-minimal. They include route names, request IDs, workspace/app/run IDs, elapsed timings, counts, CPU, and memory. They must not include prompts, source files, cookies, tokens, headers, secret values, or integration secret values. If tracing is not already enabled in the deployed environment, ask before changing deployment config. Turn it back off after diagnosis unless the current incident still needs it. ## First cluster read Run read-only checks from a shell where `kubectl` is configured for the target cluster and namespace: ```bash theme={null} kubectl config current-context kubectl get pods -o wide kubectl top pods kubectl get events --sort-by=.lastTimestamp | tail -n 80 ``` Then inspect the active web pod: ```bash theme={null} kubectl describe pod ``` Look for: * restarts; * readiness or liveness probe failures; * `/api/health` timeouts; * OOM kills; * CPU or memory limits; * scheduling failures; * node scale-up events. A health probe failure like `/api/health context deadline exceeded` means the whole web pod was not answering quickly. That is different from one slow Teams query or one slow UI component. ## Capture logs Capture web and worker logs for the incident window: ```bash theme={null} kubectl logs deploy/second --since=30m --timestamps > /tmp/second-web.log kubectl logs deploy/second-worker --since=30m --timestamps > /tmp/second-worker.log ``` If the current deployment has multiple web pods, capture all matching pods or use labels: ```bash theme={null} kubectl logs -l app.kubernetes.io/name=second,app.kubernetes.io/component=web \ --since=30m \ --timestamps \ --all-containers > /tmp/second-web.log ``` Search for application errors first: ```bash theme={null} rg "Error|Unhandled|Exception|ECONN|timeout|second.perf" /tmp/second-web.log rg "Error|Unhandled|Exception|ECONN|timeout" /tmp/second-worker.log ``` If the browser showed a temporary server error but the web logs do not show a route stack trace, compare the timestamp against Kubernetes events. It may have been a load-balancer/backend timeout while the web pod was saturated. ## Parse perf traces Structured perf log lines contain JSON with `"type":"second.perf"`. Group by `requestId`, route, and second. This script gives a quick route-level summary: ```bash theme={null} node - <<'NODE' const fs = require("fs"); const path = "/tmp/second-web.log"; const rows = fs.readFileSync(path, "utf8").split(/\n/).filter(Boolean); const events = []; for (const line of rows) { const i = line.indexOf('{"type":"second.perf"'); if (i === -1) continue; try { events.push(JSON.parse(line.slice(i))); } catch {} } const byReq = new Map(); for (const e of events) { if (!e.requestId) continue; const r = byReq.get(e.requestId) ?? { route: e.route, response: null, events: [], }; r.events.push(e); if (String(e.event).endsWith(".response")) r.response = e; byReq.set(e.requestId, r); } const stats = new Map(); for (const r of byReq.values()) { const s = stats.get(r.route) ?? { requests: 0, responded: 0, over2s: 0, over5s: 0, max: 0, }; s.requests++; if (r.response) { s.responded++; const ms = Number(r.response.totalElapsedMs ?? r.response.sinceStartMs ?? 0); s.max = Math.max(s.max, ms); if (ms > 2000) s.over2s++; if (ms > 5000) s.over5s++; } stats.set(r.route, s); } console.log("perf events", events.length, "requests", byReq.size); for (const [route, s] of [...stats.entries()].sort()) { console.log(route, s); } NODE ``` This script finds request-start bursts: ```bash theme={null} node - <<'NODE' const fs = require("fs"); const rows = fs.readFileSync("/tmp/second-web.log", "utf8").split(/\n/); const buckets = new Map(); for (const line of rows) { const i = line.indexOf('{"type":"second.perf"'); if (i === -1) continue; let e; try { e = JSON.parse(line.slice(i)); } catch { continue; } if (!String(e.event).endsWith("request_start")) continue; const key = `${String(e.at).slice(0, 19)} ${e.route}`; buckets.set(key, (buckets.get(key) ?? 0) + 1); } for (const [key, count] of [...buckets.entries()].sort((a, b) => b[1] - a[1]).slice(0, 25)) { console.log(count, key); } NODE ``` A normal click should not create dozens of identical `Teams`, `Members`, `Invitations`, or `Integrations` GETs. If it does, treat that as request amplification, not user behavior. ## Split slow requests For slow `.response` events, inspect the same `requestId` and split elapsed time into: * `auth.workspace`; * app access checks; * settings read model; * DB subqueries; * stream readiness wait; * resumable stream resume; * replay fallback; * total elapsed. Useful searches: ```bash theme={null} rg '"requestId":""' /tmp/second-web.log rg '"event":"auth.workspace"|"event":"settings.|"event":"run.stream_attach' /tmp/second-web.log ``` If `auth.workspace` is slow across many concurrent requests, suspect external auth, membership lookup pressure, or request amplification. If a DB subquery is slow for tiny result counts, suspect concurrency, missing indexes, network latency, or saturation rather than data size. ## Interpret common patterns ### Request amplification Symptoms: * many identical GETs in the same second; * settings pages repeatedly loading tiny result sets; * web pod health probes timing out; * worker mostly quiet. Likely causes: * realtime invalidation loop; * component remount loop; * repeated `useEffect` fetches; * component-local polling added on top of workspace realtime; * read route publishing mutation events; * browser connection pressure from too many EventSource subscriptions. Start by inspecting: * `apps/web/src/components/workspace-realtime-provider.tsx`; * `apps/web/src/lib/events/workspace-events.ts`; * `apps/web/src/lib/workspace-settings/read-models.ts`; * `apps/web/src/lib/workspace-settings/request-dedupe.ts`; * Members, Teams, Integrations settings clients; * routes that publish `member.changed`, `integration.changed`, or app/run events. ### Read-side mutation loop Treat any write from a GET/read path as suspicious. A read path that repairs membership, ensures a default team, upserts metadata, or publishes invalidation events can create this loop: ``` read → publish event → mounted client refetches → read → publish event ``` Fix by making ensure paths idempotent and publishing only after a real insert, update, delete, or status transition. ### Whole-app stall If `/api/health` probes time out, do not focus only on the UI page that was visible. Check request volume, auth latency, CPU throttling, memory pressure, and event-loop saturation. A later low `kubectl top pods` sample does not disprove a short earlier stall. ### Stream attach delay Start with: * `docs/streaming.mdx`; * `apps/web/src/components/app-chat.tsx`; * `apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/runs/[runId]/chat/stream/route.ts`; * `apps/web/src/lib/streams/run-replay.ts`. Check whether the run was already `streaming`, whether `activeStreamId` existed, whether the attach path waited for readiness, and whether replay or resumable stream was used. ## Capacity checks Kubernetes node autoscaling is not pod autoscaling. Check whether more pods can exist: ```bash theme={null} kubectl get deploy second -o jsonpath='{.spec.replicas}{"\n"}' kubectl get hpa kubectl describe deploy second ``` Managed GKE Autopilot can add nodes for schedulable pod requests, but it will not create more web pods without replicas or an autoscaling policy. A single web pod can still become the bottleneck under request amplification or many active streams. Worker scaling needs separate thought because active SDK sessions and workspace files live in worker memory/filesystem while durable state is saved through web and MongoDB. ## What to record When the issue teaches a durable lesson, update the active plan or docs with: * what the user observed; * the exact time window checked; * pod health and resource state; * request counts by route; * slow request breakdown by request ID; * what was ruled out; * the likely root cause; * the code or infra change; * the exact staging validation that should prove the fix. # Development Source: https://docs.second.so/development Set up the repository, scripts, and environment variables for local development. ## Repository layout ``` apps/ ├── web/ → Next.js application (UI, API routes, persistence) └── worker/ → Agent worker (Claude Agent SDK, session management) docs/ → Documentation (what you're reading now) packages/ ├── cli/ → Tiny npx launcher (@second-inc/cli) │ └── bin/second.js → Resolves and runs the platform payload └── cli-local-darwin-arm64/ → macOS arm64 local payload package ├── bin/second-local.js → Local runtime supervisor └── dist/ → Web, worker, MongoDB, Redis payload (gitignored) ``` ## Install and run ```bash theme={null} npm --prefix apps/web install npm --prefix apps/worker install npm run dev ``` `npm run dev` runs `scripts/dev.sh`. It first starts MongoDB and Redis in Docker, then starts the agent worker on the host and the Next.js dev server with hot reload. If Docker is not running, the script exits before starting the web server so the app does not boot against missing MongoDB/Redis. The dev script is worktree-aware. It automatically derives a stable local dev ID from the current branch/worktree and repository path, uses that ID as the Docker Compose project name, lets Docker assign free loopback host ports for MongoDB and Redis, and chooses free host ports for web and worker. Multiple worktrees can run at the same time without sharing containers, networks, volumes, or host ports. When available, the script runs through [portless](https://github.com/vercel-labs/portless) and exposes the app at a stable `.localhost` URL such as `http://feature.second.localhost:1355`. The default proxy uses local HTTP on port `1355` and disables host-file sync so `npm run dev` does not ask for sudo. If portless is not installed, the script uses `npx portless@0.12.0` in interactive shells. If portless is disabled or unavailable, it falls back to an auto-picked `http://localhost:` URL. Each start writes `.second-dev.txt` in the repository root with the actual URL, ports, and Compose project name. The file is gitignored and safe for local agents and browser automation to read. Prefer its `url=` value over assuming `http://localhost:3000`. The worker runs on the host (not in Docker) so it can use your local Claude authentication. If you've ever run `claude` and logged in, the agent will work with your existing auth — no API key needed. ## Scripts All scripts run from the repository root: | Script | What it does | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `npm run dev` | Starts per-worktree Mongo + Redis in Docker, worker + Next.js on host with hot reload | | `npm run typecheck` | Runs `tsc --noEmit` on both `apps/web` and `apps/worker` | | `npm run start` | Builds and runs all services in Docker (requires `ANTHROPIC_API_KEY`) | | `npm run release` | Runs prebuilt images with Docker Compose | | `npm run build --prefix packages/cli` | Syntax-checks the tiny `@second-inc/cli` launcher | | `npm run build --prefix packages/cli-local-darwin-arm64` | Builds the macOS arm64 payload with web, worker, MongoDB, Redis, and runtime libraries | | `node scripts/local-workspace-member.mjs ...` | Seeds a local no-auth user into an existing workspace with a role | | `node scripts/verify-local-rbac.mjs ...` | Signs in as a local member and verifies integration mutation APIs return `403` | | `node scripts/migrate-app-source-snapshots.mjs ...` | Migrates legacy embedded app source maps into `app_source_snapshots` after backup/approval | ## Local multi-user testing Local no-auth mode does not send real invitations. To test workspace roles without simulating an external provider, seed a second user into an existing workspace: ```bash theme={null} node scripts/local-workspace-member.mjs \ --workspace-id \ --email member@example.test \ --name "Member User" \ --role member ``` The script upserts the user, upserts the workspace membership, ensures the workspace has the default `General` team, and adds the user to that team. Then sign out, open `/onboarding/identity`, and use the seeded email/name. To verify the member cannot mutate integration settings through the API: ```bash theme={null} node scripts/verify-local-rbac.mjs \ --base-url "$(awk -F= '$1 == "url" { print $2 }' .second-dev.txt)" \ --workspace-id \ --email member@example.test \ --name "Member User" ``` The verifier obtains real local session cookies through `/api/onboarding/identity` and expects `403` from integration create, configure, reset, and delete routes. ## Environment variables | Variable | Default | Purpose | | --------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `SECOND_AUTH_MODE` | `none` | `none` for local dev, `external` for production | | `MONGODB_URI` | auto-configured | Full Mongo URI including database name | | `SECOND_PUBLIC_URL` | auto-configured | Canonical app origin, used for redirects | | `WORKER_URL` | auto-configured | Worker HTTP API endpoint | | `REDIS_URL` | auto-configured | Redis connection string | | `ANTHROPIC_API_KEY` | — | Required in Docker mode. Not needed for `npm run dev` | | `INTERNAL_API_TOKEN` | — | Shared token for web↔worker internal API auth. Optional in local dev, required in production | | `SECOND_PERF_TRACE` | `0` | Set to `1` to emit safe structured timing logs for selected hot paths. Do not leave enabled unless diagnosing performance | | `SECOND_POSTHOG_TOKEN` | built-in release default or empty | Optional public PostHog project token for product analytics. This is not a secret | | `SECOND_POSTHOG_HOST` | `https://us.i.posthog.com` | Optional PostHog host override, for example EU projects | | `SECOND_POSTHOG_DISABLED` | `0` | Set to `1` to disable PostHog analytics | | `SECOND_SENTRY_DSN` | built-in release default or empty | Optional public Sentry DSN for error reporting. This is not a secret | | `NEXT_PUBLIC_SENTRY_DSN` | built-in release default or empty | Optional browser Sentry DSN override; set it before building if you need a custom client-side DSN | | `SECOND_SENTRY_DISABLED` | `0` | Set to `1` to disable Sentry error reporting | | `SECOND_ERROR_REPORTING_DISABLED` | `0` | Alias for `SECOND_SENTRY_DISABLED=1` | | `SENTRY_AUTH_TOKEN` | — | Optional CI-only secret for uploading Sentry source maps | | `SECOND_TELEMETRY_DISABLED` | `0` | Set to `1` to disable product analytics and error reporting | | `TOOL_EXECUTE_URL` | auto-configured | Worker's URL for custom tool execution | | `WEB_PORT` | auto-picked, prefers `3000` | Host port for the web app | | `WORKER_PORT` | auto-picked, prefers `3001` | Host port for the worker | | `MONGO_PORT` | Docker-assigned | Host port for MongoDB; set explicitly only when you need a fixed port | | `REDIS_PORT` | Docker-assigned | Host port for Redis; set explicitly only when you need a fixed port | | `SECOND_DEV_ID` | auto-generated | Stable local ID used to isolate each worktree | | `SECOND_DEV_PORTLESS` | `1` | Set to `0` to skip portless and use localhost auto-ports | | `SECOND_DEV_PORTLESS_PROXY_PORT` | `1355` | Local portless proxy port; uses an unprivileged port to avoid sudo | | `SECOND_DEV_PORTLESS_HTTPS` | `0` | Set to `1` to use HTTPS; port `443` may require sudo | | `SECOND_DEV_PORTLESS_SYNC_HOSTS` | `0` | Set to `1` to let portless sync `/etc/hosts`; this may require sudo | | `SECOND_DEV_ALLOWED_ORIGINS` | — | Optional extra hostnames for Next.js dev internal resources, comma or space separated | | `SECOND_DEV_KEEP_INFRA` | `0` | Set to `1` to keep dev Mongo/Redis containers running after the dev server exits | Product analytics are enabled by default in anonymized mode. To disable them for a local run, use `npm run dev -- --disable-telemetry`, `npm run dev -- --no-analytics`, or `SECOND_POSTHOG_DISABLED=1 npm run dev`. See [Product analytics](/product-analytics) for the capture path, anonymous ID behavior, and privacy rules. Client and server error reporting use Sentry by default. The DSN is public and can be overridden with `SECOND_SENTRY_DSN`; set `NEXT_PUBLIC_SENTRY_DSN` before building when you need browser-side events to go to a custom Sentry project. Source-map upload requires `SENTRY_AUTH_TOKEN` in CI. ## How `WORKER_URL` is set `WORKER_URL` is the internal HTTP address the web server uses to call the worker API (`/sessions/*`, `/detect-provider`). ### `npm run dev` (monorepo dev) * Web runs on host. * Worker runs on host. * `WORKER_URL` is set to `http://127.0.0.1:` by the root `dev` script. * `MONGODB_URI` is set to the Docker-assigned loopback MongoDB port with `directConnection=true&replicaSet=rs0`, so the host web process does not follow Mongo's internal `localhost:27017` replica-set advertisement from another worktree. * `WEB_URL` and `TOOL_EXECUTE_URL` are set to the loopback web server URL so the host worker calls the correct worktree even when the browser URL is a portless HTTPS hostname. * Next.js dev resources are allowed from the generated `*.second.localhost` proxy host and from `SECOND_PUBLIC_URL`, so the canonical portless URL can load HMR, fonts, and client code without falling back to the loopback web port. ### `npm run start` / `npm run release` (compose deployment) * Web and worker both run in Docker. * `WORKER_URL` is set to `http://worker:3001` in `docker-compose.yml`. * `worker` is the Docker service DNS name reachable from the `web` container. ### `npx --yes @second-inc/cli` * `npx` installs the tiny `@second-inc/cli` launcher first. * The launcher invokes the matching platform payload package, for example `@second-inc/cli-local-darwin-arm64`. * Web runs as a packaged Next.js standalone Node server on the host. * MongoDB runs as a packaged native `mongod` child process on loopback with `--replSet rs0`. * Redis runs as a packaged native `redis-server` child process on loopback for streaming/replay, pub/sub, OAuth state, and short locks. * Worker runs on the host so it can use the user's local Claude/Codex/OpenCode auth. * The CLI starts a loopback-only host control server for release status and update restart requests. The web process receives only server-side `SECOND_LOCAL_*` / `SECOND_RELEASE_*` environment variables and calls the control server with a local bearer token; those values are not exposed as `NEXT_PUBLIC_*`. * MongoDB and Redis startup run concurrently. The web server still waits until MongoDB has a ready single-node replica set and Redis has answered `PONG`. This is expected and required for local Claude CLI auth, since the worker must run on the host in CLI mode. The launcher package is intentionally tiny. The payload package carries the Next.js standalone output, worker bundle, packaged MongoDB, packaged Redis, and runtime libraries. That keeps the visible `npx` command stable while avoiding a large launcher package that sits behind npm's spinner before our code can print progress. ## Runtime config and Docker builds `readRuntimeConfig()` in `src/lib/config/runtime.ts` validates that `SECOND_AUTH_MODE`, `MONGODB_URI`, and `SECOND_PUBLIC_URL` are set. These variables exist at runtime but not during `docker build`. During `next build`, Next.js prerenders pages and calls `readRuntimeConfig()` — which would crash on missing env vars. To handle this, the function detects the build phase via `process.env.NEXT_PHASE === "phase-production-build"` (set automatically by Next.js) and returns safe defaults. At runtime, full validation applies as normal. This means no placeholder env vars are needed in the Dockerfile, and no pages need `export const dynamic = "force-dynamic"` just to avoid build errors. ## Docker Compose services | Service | Image | Purpose | | -------- | ----------------------------------- | -------------------------------------------------- | | `mongo` | `mongo:8.0` | Database (with `--replSet rs0` for Change Streams) | | `redis` | `redis:7-alpine` | Stream resumption + pub/sub | | `worker` | Built from `apps/worker/Dockerfile` | Agent runner | | `web` | Built from `apps/web/Dockerfile` | Next.js app | In dev mode (`npm run dev`), only Mongo and Redis run in Docker. The worker and web app run on the host for fast iteration and access to local Claude auth. ### MongoDB replica set MongoDB runs with `--replSet rs0` because [Change Streams](/app-data#live-updates-change-streams--sse) require a replica set. The `docker-compose.yml` healthcheck auto-initiates the replica set on first start: ```yaml theme={null} healthcheck: test: ["CMD-SHELL", "mongosh --quiet --eval 'try{rs.status().ok}catch(e){rs.initiate({_id:\"rs0\",members:[{_id:0,host:\"localhost:27017\"}]});rs.status().ok}' | grep 1"] ``` In production (e.g., MongoDB Atlas), replica sets are the default — no extra configuration needed. The packaged CLI does not use Docker and does not require the user to install MongoDB. It starts the packaged `mongod` binary with `--replSet rs0`, binds it to `127.0.0.1`, initiates a single-node replica set using the MongoDB Node driver, and gives the web process a loopback `MONGODB_URI` with `directConnection=true&replicaSet=rs0`. ## How the CLI works `npx --yes @second-inc/cli` starts a tiny launcher, which runs the matching platform payload package. The payload's supervisor starts the full stack locally: ``` ┌─ Host processes owned by npx --yes @second-inc/cli ────┐ │ mongod --replSet rs0 → database │ │ redis-server → stream relay │ │ node server.js → Next.js app │ │ node worker.mjs → agent worker │ └──────────────────────────────────────────────────┘ ↕ loopback-only HTTP/TCP ports ``` The worker runs on the host so it can access the user's local `claude`, `codex`, or `opencode` CLI and authentication. The web process reaches the worker through a loopback `WORKER_URL`. **Script resolution:** the payload supervisor prefers `apps/worker/src/index.ts` when running from the monorepo, so the worker can resolve the Claude Agent SDK and `claude` CLI binary from its own `node_modules`. When the monorepo isn't available, it falls back to the bundled `dist/worker.mjs` shipped in the payload package. **Provider detection** happens at runtime in the web app's onboarding flow, not in the CLI. The CLI just starts infrastructure; the app handles auth. **Release/update control:** the CLI writes a random local control token to `~/.second/secrets/local-control-token`, starts a small host control server, and writes non-secret connection metadata to `~/.second/local-control.json`. The control server exposes unauthenticated `GET /health`, authenticated `GET /release/status`, and authenticated `POST /update/install`. Update status checks use the host user's npm auth, which lets private npm-package rehearsals work without putting npm tokens in the web process or browser. ### Building the CLI for publishing ```bash theme={null} cd packages/cli-local-darwin-arm64 npm publish --access restricted cd ../cli npm publish --access restricted ``` Publish the platform payload before the launcher for the same version. The payload package's `prepack` script runs the build that bundles the worker, builds the Next standalone web server, and prepares packaged MongoDB/Redis runtime files. The launcher package is deliberately tiny and points npm users to the matching payload package at runtime. During private release rehearsal use `--access restricted` and a logged-in npm account with access to the `@second-inc` scope. For public release, publish with public access after the package visibility decision is made. ## Local data **`npm run dev`**: MongoDB data persists in a per-worktree Docker volume under the generated Compose project. The dev script also writes a stable no-auth session secret under `.second-dev/` so local sign-in survives host web-server restarts. The dev script stops and removes the per-worktree Mongo/Redis containers on exit by default, but it does not delete volumes. Set `SECOND_DEV_KEEP_INFRA=1` before starting if you want the containers to remain running after the dev server exits. Wipe the current worktree's dev containers and volume with the `compose_project=` value from `.second-dev.txt`: ```bash theme={null} docker compose -p down --volumes --remove-orphans ``` **`npx --yes @second-inc/cli`**: MongoDB data persists at `~/.second/data/mongo/`, Redis data persists at `~/.second/data/redis/`, generated app workspaces persist at `~/.second/data/workspaces/`, logs are written under `~/.second/logs/`, and local service secrets persist under `~/.second/secrets/` so sign-in, web↔worker auth, and local update auth survive stop/start. MongoDB, Redis, OpenSSL runtime libraries on macOS, the packaged web server, and the worker bundle come from the platform payload package instead of a first-run infrastructure download cache. Wipe local data with: ```bash theme={null} npx --yes @second-inc/cli reset ``` # Enterprise Deployment and Security Source: https://docs.second.so/enterprise How Second fits into customer-owned infrastructure, auth, OAuth apps, secrets, app-agent governance, and audit review. This page centralizes the security and enterprise answers that usually come up before a team deploys Second beyond local development. In short: production Second is designed to run inside infrastructure you control or a dedicated managed environment, use your auth provider, use your OAuth apps, and keep integration credentials on the server side. Agents can request approved tools; they do not receive API keys, OAuth client secrets, refresh tokens, or access tokens. ## Enterprise model Second has two current deployment shapes: | Deployment | Best for | Security boundary | | ------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------ | | Local CLI | Individual evaluation and local app building | Runs on the user's machine with local data and local secrets | | Self-hosted or managed instance | Teams and production use | Runs in customer-owned infrastructure or a dedicated managed environment | Source Control lets Second use a repository provider, such as GitHub, GitLab, Bitbucket, or self-hosted source control, as authoritative app source storage. Local CLI/desktop teams opt in per app from the app top bar. On-prem or managed deployments can enable a workspace-level Store app source in source control policy so successful builds commit app source to the configured provider automatically. Available Apps is a separate discovery/install layer, not the storage layer itself. See [Source Control](/source-control). Production deployments should use `SECOND_AUTH_MODE=external`, keep web and worker internal routes on a private network, and use a production secret store such as WorkOS Vault when configured. Without WorkOS Vault, OAuth secrets require `SECOND_TOKEN_ENCRYPTION_KEY` so the local encrypted adapter can store token references safely. Second is not a hosted OAuth broker. For enterprise deployments, customers bring their own identity provider, OAuth provider apps, and workspace policies. Need help with secure deployment, production rollout, cost management, runtime configuration, or support? Contact [sales@second.so](mailto:sales@second.so). ## Authentication In production, your external auth provider is responsible for: * resolving the authenticated actor on each request * returning a stable user identifier * mapping the user into Second's `users` table * syncing workspace memberships and roles * handling invitations if collaboration is enabled Authorization happens inside Second. Every workspace route proves membership, checks role permissions where needed, and queries data with `workspaceId` filters. A valid user from one workspace cannot read another workspace by guessing IDs. For the full request guard model, see [Guard and Tenancy](/guard-and-tenancy). ## Customer-owned OAuth apps OAuth integrations use provider apps owned by the customer. The builder can describe the OAuth metadata an app needs, but admins configure the real provider client in Second settings. For example, a Slack integration should be set up with your own internal Slack app: 1. Create or open a Slack app in your Slack workspace. 2. Grant only the scopes the generated app needs, such as `chat:write` for a tool that posts messages. 3. Install that Slack app into your workspace. 4. Paste the resulting bot token or OAuth client details into Second. 5. Users connect their own accounts when the integration uses user OAuth. For self-hosted or customer-cloud deployments, Slack API calls go from your Second deployment to Slack. The Slack credential does not need to pass through a shared Second SaaS service. In a dedicated managed deployment, the call path is still limited to that deployment's server-side runtime and its configured secret store. OAuth client secrets, refresh tokens, access tokens, and provider token responses are never sent to the agent runtime. ## agents.json `agents.json` is the app's proposed agent and tool configuration. It can define app agents, custom HTTP tools, data tools, integration domains, OAuth metadata, required scopes, required static secrets, and mock data. `agents.json` is not trusted just because the builder wrote it. The enterprise control point is approval: ```text theme={null} Builder writes agents.json -> present_agents validates and shows the Agents card -> admin or owner approves the exact payload -> Second stores the canonical JSON hash and approver metadata -> runtime can use only that approved payload ``` If `agents.json` changes later, the approval becomes stale. A new domain, endpoint, OAuth scope, token URL, secret name, permission group, or data collection must be reviewed again before live tools can use it. This means the builder can move quickly in draft mode, but the runtime that touches live integrations is pinned to the configuration an admin or owner approved. ## Secrets and tool execution Agents do not get a general "get secret" endpoint. They call named tools. The server-side tool execution path decides whether that tool is approved and which secret values, if any, are injected into the outbound HTTP request. ```mermaid placement="top-right" theme={null} %%{init: {'flowchart': {'defaultRenderer': 'elk'}}}%% flowchart LR A["App agent"] -->|"tool name + normal inputs"| B["Worker tool handler"] B --> C["/api/internal/tool-execute"] C --> D{"Tool in approved agents.json?"} D -- "No" --> E["Reject or return configured mock data"] D -- "Yes" --> F["Resolve app integration grant"] F --> G["Read named secrets server-side"] G --> H["Inject secret into request template"] H --> I["Validate HTTPS, domain, IP range, size, timeout"] I --> J["Call external API"] J --> K["Return bounded response to agent"] G -. "secret value is not sent" .-> A ``` What each layer can see: | Layer | Can see | Cannot see | | ----------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | | Agent runtime | Tool names, tool descriptions, normal tool inputs, tool result payloads | Static API keys, OAuth client secrets, refresh tokens, access tokens, Vault IDs | | Worker | The app/run context, approved tool request, and bounded tool result stream | Raw browser cookies or workspace-wide secret inventory | | Web internal tool route | Approved `agents.json`, app grant, configured secret references, resolved outbound request | Unapproved arbitrary domains or secrets for another app/workspace | | Secret store | Actual secret material or encrypted references | Agent prompt or app source as part of normal lookup | Static-secret tools use named placeholders such as `{{secrets.SLACK_BOT_TOKEN}}`. OAuth tools omit `Authorization` headers; Second resolves the triggering user from the server-created app-agent run, refreshes the access token if needed, and injects the bearer token server-side. ## Slack integration example A Slack notification app might declare a custom tool in `agents.json` that posts to `https://slack.com/api/chat.postMessage` and references `{{secrets.SLACK_BOT_TOKEN}}`. The important enterprise property is **app-scoped credentials**. A Slack token configured for one app grant is not a workspace-wide ambient credential and does not silently power other apps. The secure runtime behavior is: 1. The builder proposes the Slack tool and setup instructions. 2. An admin reviews and approves the agent configuration. 3. An admin creates the internal Slack app and pastes the bot token into Second. 4. The app agent calls `send_slack_message` with a channel and text. 5. `/api/internal/tool-execute` verifies that exact tool and domain were approved for this app. 6. The route injects the bot token into the Slack request server-side. 7. The agent receives only the Slack API response, not the token. If the Slack token is missing, unconfigured, or no longer satisfies the requested grant, **Second does not borrow a token from another app**. Static-secret and OAuth tools return configured mock data when the integration is not set up or the user's account is missing or revoked. ## Workspace and app isolation Enterprise deployments rely on the same isolation model everywhere: * Every app, run, integration, credential, connected account, audit event, and app-data record is scoped by `workspaceId`. * App resources are also bound to `appId`; a same-workspace app cannot read another app's run or data by guessing IDs. * Integration grants are app-scoped. A credential configured for one app does not silently power another app. * OAuth connected accounts are user-scoped. A tool call resolves the triggering user from the server-created app-agent run, not from agent-provided input. * Published app viewers use the last approved published snapshot. Builders can continue editing drafts without changing the live version. ## Auditability Workspace audit logs cover governance and setup actions such as role changes, integration configuration, agent approval, review requests, approvals, and publishing decisions. Audit events are workspace-scoped and redact secret values, provider tokens, prompt payloads, source files, cookies, and headers. See [Audit Logs](/audit-logs) for the event schema and coverage. ## Enterprise checklist Before production rollout: * Use `SECOND_AUTH_MODE=external`. * Connect Second to your identity provider and map workspace roles. * Keep web and worker internal routes reachable only on the private deployment network. * Configure WorkOS Vault, or set `SECOND_TOKEN_ENCRYPTION_KEY` for encrypted local secret references where appropriate. * Create customer-owned OAuth apps for providers such as Slack, Google, and Microsoft. * Register the exact redirect URI shown by Second for each OAuth provider. * Require admin or owner review for `agents.json`, integration grants, scopes, secrets, and published snapshots. * Review audit logs after setup and after any integration or agent change. * For secure rollout help, deployment support, or cost planning, contact [sales@second.so](mailto:sales@second.so). Related pages: * [Self-hosting](/self-hosting) * [Source Control](/source-control) * [Authentication](/authentication) * [App Governance](/app-governance) * [Integrations](/integrations) * [Guard and Tenancy](/guard-and-tenancy) * [Audit Logs](/audit-logs) # Guard and Tenancy Source: https://docs.second.so/guard-and-tenancy How Second enforces workspace isolation, access control, and scoped database queries. This page explains the security model that prevents one workspace from reading or writing another workspace's data, and keeps app access tied to membership, teams, and app-level permissions. ## Why this matters In a multi-tenant app, users can know or guess resource IDs. ID secrecy is not a security boundary. Second treats tenancy as an enforced invariant — every request must prove: 1. **Who** — resolve the actor's identity 2. **Where** — identify which workspace the request targets 3. **Allowed** — verify the actor is a member of that workspace 4. **Permitted** — for sensitive routes, verify a named role permission 5. **Scoped** — execute database queries filtered by `workspaceId` If identity or onboarding fails, the request gets an auth/onboarding error. If a workspace/resource is outside the actor's tenant boundary, the request returns `404`. If the actor is in the workspace but lacks a role permission, the request returns `403`. ## High-level request flow ```mermaid placement="top-right" theme={null} %%{init: {'flowchart': {'defaultRenderer': 'elk'}}}%% flowchart LR A[Request arrives] --> B[Middleware proxy] B -- Not onboarded --> C[Redirect or JSON error] B -- Onboarded --> D[Route handler] D --> E[requireWorkspaceContext] E --> F{User is member of workspace?} F -- No --> G[Return 404 not_found] F -- Yes --> H{Route permission required?} H -- Missing --> I[Return 403 forbidden] H -- OK --> J[Run repository method with workspaceId] J --> K[DB query includes workspaceId filter] K --> L[Return scoped result] ``` ## Enforcement layers There are four layers, from outermost to innermost. ### Layer 1: Middleware proxy (`proxy.ts`) Runs on every matched request (`/`, `/w/*`, `/onboarding/*`, `/api/*`) before any route handler. Its job is onboarding enforcement and early rejection. The proxy calls `resolveOnboardingState` and routes based on the result: | Onboarding state | Page request | API request | | ------------------ | ---------------------------------- | ------------------------ | | `missing-identity` | Redirect → `/onboarding/identity` | `401 identity_required` | | `needs-profile` | Redirect → `/onboarding/identity` | `401 profile_required` | | `needs-workspace` | Redirect → `/onboarding/workspace` | `403 workspace_required` | | `ready` | Pass through | Pass through | For fully onboarded users, the proxy also checks: * **Invalid workspace format** — URL contains a workspace slug that is not URL-safe → API gets `404`, pages pass through to render a not-found page. * **Membership pre-check** — URL targets a specific workspace → verify the user is a member, otherwise `404`. * **Onboarding redirect** — onboarded user visits an onboarding page → redirect to `/w/{firstWorkspaceSlug}`. In `external` auth mode, the proxy skips redirects for page requests (the external provider handles login) but still returns JSON errors for API requests. ### Layer 2: Guard module (`guard.ts`) Shared logic used by both the middleware and route handlers. Three functions, from lightest to strictest: | Function | What it does | Used by | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `resolveOnboardingState` | Resolves actor, checks profile completeness and memberships. Returns a discriminated union. | Middleware proxy | | `requireReadyState` | Calls `resolveOnboardingState`, throws if user isn't fully onboarded. | Routes that need an authenticated user but aren't workspace-scoped | | `requireWorkspaceContext` | Validates workspace selection, verifies membership, returns full `WorkspaceContext` (actor, user, workspaceId, membership). | Workspace API routes | Guard failures throw `RequestGuardError` with a typed `code` (`identity_required`, `profile_required`, `workspace_required`, `not_found`) so error responses stay consistent. ### Layer 3: Route handlers Workspace API routes follow a two-step pattern: ``` const context = requireWorkspaceContext(...) createAppForWorkspace({ workspaceId: context.workspaceId, ... }) ``` The workspace ID for writes always comes from the guard context — never from JSON/form bodies. Sensitive workspace routes add a role permission check after membership is proved. For example, integration secret writes require `integrations:manage`, and member invitation routes require `members:invite`. Roles are fixed today: owners have full authority, admins can operate workspace settings such as members and integrations, and members can build/use apps but cannot manage shared credentials or governance. Creator and collaborator are not workspace roles. They are app-level access categories derived from `apps.createdByUserId` and `apps.collaboratorUserIds`. Team membership is a narrower layer inside the workspace boundary. Every workspace has a default `General` team and all current members are assigned to it. Published app visibility is enforced with team membership: owners and admins can see every app, app creators and explicit app collaborators can see private drafts and review requests for that app, and other members can only open published apps whose `teamIds` overlap their workspace team memberships. Team IDs and collaborator user IDs supplied by clients are accepted only after they are validated inside the same `workspaceId`. Nested routes add parent-resource binding before returning data: ``` const app = findAppById({ workspaceId: context.workspaceId, appId }) const run = loadRunForApp(runId, context.workspaceId, appId) ``` This matters inside a workspace too. A user may legitimately access workspace A, but a run, app-agent run, or file request must still belong to the specific `appId` in the route. Same-workspace cross-app access returns `404`. Published app access adds a second same-workspace check before nested routes load files, data, runs, agents, or streams. A member who belongs to workspace A but not to any team selected for app X receives `404` for app X and every child resource under it. Admins, owners, app creators, and explicit app collaborators bypass the team visibility check so they can review or build private apps. Published runtime files are served from the promoted published source snapshot. Draft edits update the draft source snapshot only, so team viewers keep using the last published app while an app creator or collaborator continues building. Current source snapshots live in `app_source_snapshots`; the hot `apps` metadata path keeps only snapshot IDs, hashes, file counts, and sizes. Legacy embedded `sourceFiles` and `publishedSourceFiles` remain readable as a compatibility fallback, but new writes use the snapshot collection. When an app creator or collaborator changes an app that is already in review, the pending review is superseded and the app returns to draft before the edit is accepted. Review actions against superseded requests are rejected. Agent configuration has an additional governance check. `agents.json` can be edited as part of a draft, including by worker file tools, and draft app-agent runs can start from that draft file. Live custom/data tools still require the current versioned canonical `agents.json` hash to match an approval recorded by a workspace admin or owner. The hash canonicalizer is schema-versioned so non-policy representation changes, such as omitted optional empty arrays, do not expand or invalidate access. Publishing or review approval promotes that approved payload with the published snapshot. Internal tool routes then verify the requested custom tool or data collection is present in the approved payload for the calling agent. Integration secrets add one more boundary. A custom tool cannot choose an arbitrary host at call time: `/api/internal/tool-execute` checks the approved agent payload, resolves the current app's integration grant by `workspaceId`, `appId`, provider domain, and key slug, follows that grant's credential binding, injects only named secrets, and rejects final URLs outside that domain or inside private network ranges. See [App Governance](/app-governance) and [Integrations](/integrations). OAuth connected accounts add a user-specific boundary without trusting the worker or model to choose the user. OAuth tool execution requires a `runId`; the web route loads the app-agent run by `{ workspaceId, appId, runId }` and reads `triggeredByUserId` from that server-created document. Provider config lookup is scoped by `{ workspaceId, providerKey }`, and connected account lookup is scoped by `{ workspaceId, userId, providerConfigId }`. A connected account for another workspace, another app-triggering user, or another provider config cannot satisfy the tool call. OAuth authorization and token URLs are not taken from live model input. They must appear in the approved `agents.json` tool and synced app integration grant, must match the workspace provider config, must be HTTPS, and must resolve outside private network ranges before Second redirects or exchanges tokens. Agents never receive OAuth client secrets, refresh tokens, access tokens, Vault IDs, or token endpoint responses. Realtime events do not bypass these checks. Workspace events are Redis invalidation hints used by mounted clients to refetch compact read models or update known run status. The data fetch after an event still goes through the same route-handler authorization and repository scoping described above. Short settings request dedupe is also scoped by workspace, current user, role, and membership version, and does not cache a global authorization decision. ### Layer 4: Repositories The final boundary. Workspace-scoped repository methods require `workspaceId` and include it in every query: * **List apps:** `find({ workspaceId })` * **Get app by ID:** `findOne({ _id: appId, workspaceId })` * **Get builder run by app:** `findOne({ _id: runId, workspaceId, appId })` * **Get app-agent run by app:** `findOne({ _id: runId, workspaceId, appId })` * **Get app data:** `find({ workspaceId, appId: scopedAppId, collection })` Even if a caller has a valid app ID from another workspace, the query returns nothing because `workspaceId` won't match. ## Examples ### Listing apps in your own workspace `GET /api/workspaces/acme/apps` — user is a member of the `acme` workspace. 1. Guard resolves actor from session/provider. 2. Guard resolves `workspaceId=acme` from route. 3. Membership check confirms actor belongs to `acme`. 4. Repository runs `find({ workspaceId: "acme" })`. 5. Response contains only `acme` workspace apps. ### Fetching another workspace's app by ID `GET /api/workspaces/acme/apps/` — user is a member of `acme`, but the app belongs to `globex`. 1. Guard passes membership for workspace `acme`. 2. Repository runs `findOne({ _id: appIdFromGlobex, workspaceId: "acme" })`. 3. No document matches — the app exists in `globex`, not `acme`. 4. API returns `404`. The user learns nothing about whether that app exists elsewhere. ### Creating an app User submits the form on `/w/acme`: 1. Browser posts to `/api/workspaces/acme/apps`. 2. Guard verifies membership for `acme`. 3. Write path injects `workspaceId=acme` from guard context (not from the request body). 4. App is inserted with `workspaceId=acme`. 5. Response redirects back to `/w/acme`. ### Managing an integration as a member `PATCH /api/workspaces/acme/integrations/` — user is a member of `acme` with role `member`. 1. Guard passes membership for workspace `acme`. 2. Route checks `integrations:manage`. 3. `member` does not have that permission. 4. API returns `403`. The resource is in the caller's workspace, but the action is not allowed for the caller's role. ## Workspace selection resolution order When the guard needs to determine which workspace a request targets, it checks these sources in order: 1. Explicit route parameter 2. Path-derived workspace slug 3. Header `x-second-workspace-id` 4. Cookie `second_workspace_id` 5. Fallback to the user's first membership This keeps the UX smooth (the UI sets the cookie, API clients can use the header) while still enforcing membership before any data access. If the route path contains an invalid workspace ID format (not a 24-char hex string), the guard returns `404` immediately — it never falls back to header/cookie/default. ## Internal API bypass Routes under `/api/internal/` are exempted from the middleware proxy. They skip browser session and membership checks because they are called by the worker process, not by a browser. They authenticate via `INTERNAL_API_TOKEN` (Bearer token in the `Authorization` header). These endpoints are called by the worker process, which has no browser session. The proxy exemption is in `proxy.ts`: ```typescript theme={null} if (pathname.startsWith("/api/internal/")) { return NextResponse.next(); } ``` Internal endpoints: | Path | Purpose | | ---------------------------------------- | ------------------------------------------------------------------------------------- | | `/api/internal/tool-execute` | Execute custom HTTP tools with secret injection | | `/api/internal/integration-requirements` | Sync builder-requested integrations, setup steps, permission groups, and secret names | | `/api/internal/workspace-integrations` | Return current app integration grant metadata to the builder without secret values | | `/api/internal/agent-run-complete` | Worker callback when an agent finishes | | `/api/internal/app-data-write` | Agent writes data to app collections | | `/api/internal/app-data-read` | Agent reads data from app collections | When `INTERNAL_API_TOKEN` is not set in local development, the auth check is skipped so `npm run dev` works without extra secrets. In production, `INTERNAL_API_TOKEN` is required; the web runtime fails fast if it is missing, and internal endpoints fail closed instead of accepting unauthenticated traffic. Set the same strong token on both web and worker. See [Self-hosting](/self-hosting). The token only authenticates the worker as an internal caller. Internal endpoints must still validate tenant scope from the request body, for example `workspaceId`, scoped app ID, collection, run ID, integration domain, source version, and the approved `agents.json` payload that grants the requested tool or data collection. ## Hardening notes * `SECOND_AUTH_MODE=none` is for local and trusted networks only. See [Authentication](/authentication) for details on external mode. * Internal API tokens are compared with timing-safe equality. * Worker HTTP routes, except `/health`, also require `INTERNAL_API_TOKEN` when configured. The web server attaches this token when calling `WORKER_URL`. * The worker scrubs Second infrastructure secrets from the Claude SDK subprocess environment. * Custom HTTP tools are domain-locked to the configured integration domain, reject private/internal IPs, and require non-secret input placeholders when the agent provides input. See [Integrations](/integrations#tool-execution). * Workspace member, invitation, team, app, and integration writes all stay scoped by `workspaceId`; clients do not provide external organization IDs or team IDs for the current invitation flow. * App preview iframes run without `allow-same-origin`, and bridge handlers only accept `postMessage` events from the expected iframe window. * For deployment guidance, see [Self-hosting](/self-hosting). # Get started with Second Source: https://docs.second.so/index A governed workspace for building custom internal software, where your team and AI agents work together on the same generated interfaces. Second is a governed workspace platform that lets every team ship purpose-built internal software: collaborative apps designed from the ground up for humans and AI agents to work together. Prompt an app, and Second generates a full internal interface already deployed in your workspace, with a real-time database, permissions, and audit logs out of the box. Every app treats agents as first-class citizens: they read and write to the same live database as your team, get scoped tools to do real work, and collaborate alongside humans on the same UI. Think of it as an internal, secure, and collaborative Lovable that runs on-prem, purpose-built for long-running, asynchronous work with AI agents. For enterprise review, start with [Enterprise Deployment and Security](/enterprise). It covers customer-owned auth and OAuth apps, `agents.json` approval, secret injection, tenant isolation, and auditability. Need help with secure deployment, cost management, runtime setup, or production support? Contact [sales@second.so](mailto:sales@second.so). The platform follows a zero-trust architecture for agents. No agent is granted implicit access to anything. Every capability, data collection, and integration must be explicitly declared, scoped, and approved before an agent can act. Run it locally from source in minutes, then plug in an external auth provider when you're ready to deploy. ## Try it ```bash theme={null} npm --prefix apps/web install npm --prefix apps/worker install npm run dev ``` The dev script starts local infrastructure and writes the actual app URL to `.second-dev.txt`. Walk through onboarding, then type a prompt and click **Build** to see the agent work. For the full developer setup, see [Quickstart](/quickstart). ## What you get | Capability | What it means | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | AI agent that builds apps | Type a prompt → agent writes code, runs commands, iterates | | App agents with custom tools | Apps trigger scoped AI agents that call external APIs (HubSpot, Slack, etc.) with secure secret injection | | Draft/review governance | Draft edits, agent permissions, integrations, and published snapshots stay under admin/owner control | | Source-control app storage | Store app source in repositories such as GitHub, GitLab, or Bitbucket, keep MongoDB as metadata/cache, and optionally share selected apps through Available Apps | | Audit logs | Owners/admins can inspect workspace-scoped governance, agent, integration, and app-data changes without exposing secrets or payloads | | Live data persistence | Apps persist data in MongoDB via `useCollection`/`useDoc` with live updates via Change Streams | | Async agent execution | Agents run in the background and write results to the app's database, even after the user closes their browser | | Real-time streaming | See text, tool calls, and reasoning appear as they happen | | Workspace-scoped data | Every record belongs to exactly one workspace | | Membership enforcement | API access requires proven membership | | Pluggable auth | Local `none` mode for development, `external` mode for production | | Local Claude auth | Uses your existing `claude` login, no API key needed for local dev | | Provider-agnostic design | Claude today, extensible to other agent providers | ## How it works ``` Browser (useChat) → Next.js API → Worker (Claude Agent SDK) → streams back ``` 1. User types a prompt in the composer and clicks **Build**. 2. An app and run are created in MongoDB. 3. The browser navigates to the app page. 4. `useChat` sends the prompt to the chat API route. 5. The API route connects to the worker, which starts a Claude agent session. 6. The worker streams raw SDK events back to Next.js. 7. Next.js translates them into the AI SDK UIMessageStream protocol. 8. The browser renders text, tool calls, and reasoning in real time. 9. The agent edits a Vite + React + TypeScript workspace and calls `done_building`, then the worker runs `npm run build`. 10. The frontend fetches live workspace files through the web API (which proxies to the worker) and renders a sandboxed iframe preview. 11. When the agent finishes, messages and source snapshots are persisted to MongoDB. 12. Persisted source snapshots are used for recovery/rehydration after worker churn; live preview reads come from the worker filesystem. When an app is source-control-backed, the repository becomes the source of truth for that app's source. The app page still renders the saved built artifact/cache for speed; source control is used for explicit publish/sync, workspace source-storage sync, Available Apps install/update, and source restore after a live worker session is gone. See [Source Control](/source-control) for the full storage and distribution model. ## Next steps * [Quickstart](/quickstart): run locally and build your first app * [Enterprise Deployment and Security](/enterprise): customer-owned auth, OAuth apps, app-scoped credentials, `agents.json`, and app-agent governance * [Architecture](/architecture): system overview with diagrams * [App Governance](/app-governance): draft vs published snapshots, review flow, and governed agent config * [Source Control](/source-control): source-control-backed app source storage, Available Apps, auto-versioning, and restore boundaries * [Audit Logs](/audit-logs): workspace audit schema, redaction, permissions, and event coverage * [Agent System](/agent-system): worker, bridge, and provider abstraction * [App Agents](/app-agents): how apps trigger AI agents with custom tools * [App Data](/app-data): live data persistence with MongoDB and Change Streams * [Integrations](/integrations): API secrets, custom HTTP tools, and mock data * [Streaming](/streaming): the two-hop streaming protocol in detail * [Authentication](/authentication): local vs external auth modes * [Product Analytics](/product-analytics): PostHog capture, anonymization, and opt-out behavior * [Self-hosting](/self-hosting): deploy to production * [App Preview](/app-preview): artifact preview pipeline, build step, iframe rendering, and source file persistence * [Contributing](/contributing): help improve Second # Integrations Source: https://docs.second.so/integrations How Second keeps static secrets and OAuth tokens under server control while app agents and app code use approved custom HTTP actions. Integrations connect app agents and generated app code to external services such as Linear, HubSpot, Slack, Gmail, Calendar, and other APIs without exposing credentials to the agent runtime or sandboxed iframe. Second supports these credential shapes: * an **integration grant** records what one app asked to use, including provider domain, key slug, auth mode, permission groups, setup steps, app, and requester metadata * a **static credential** stores app-scoped API keys, bot tokens, or private app tokens for static-secret grants * an **OAuth provider config** stores one workspace/provider OAuth client, such as a customer-owned Google OAuth app * a **connected account** stores one user's OAuth connection metadata and token secret references for one provider config A configured credential for one app never silently powers another app. If Roadmap Tracker asks for Linear read access and Sprint Writer asks for Linear write access, those are separate grants and separate setup decisions even though both use `linear.app`. OAuth adds a second boundary: a provider client configured by an admin does not grant access to any user's data by itself. Each user connects their own account. Agent tools resolve the account from the server-created app-agent run record; app-callable integration actions resolve the account from the current authenticated app viewer. ## How it works Static-secret custom tools: ``` Agent calls custom tool → Worker MCP tool handler → POST /api/internal/tool-execute → Verify tool appears in approved agents.json payload → Resolve grant by workspaceId + appId + domain + keySlug → Follow that grant's credential binding → Read named secrets from Vault or local development storage → Inject named secrets and tool input into endpoint templates → Validate hostname, protocol, and resolved IPs → Execute HTTP request to external API → Return response to agent ``` If the current app grant is missing, unconnected, not configured, or missing a required secret, the endpoint returns a random entry from the tool's `mockData` instead of using another app's credential. OAuth custom tools use the same approved `agents.json` boundary, but the credential lookup is per triggering user: ``` Agent calls OAuth custom tool → Worker posts toolName, approved toolSpec, toolInput, runId → POST /api/internal/tool-execute → Verify tool appears in approved agents.json payload → Resolve grant by workspaceId + appId + domain + keySlug → Verify grant auth metadata matches the approved tool → Load app_agent_runs by workspaceId + appId + runId → Resolve triggering user from the run record → Load connected_account by workspaceId + userId + providerConfigId → Check scopes and revoked state → Refresh access token on demand if missing or near expiry → Inject Authorization: Bearer server-side → Validate hostname, protocol, and resolved IPs → Execute HTTP request to external API → Return bounded response to agent ``` App-callable integration actions use the same approved `agents.json` boundary and the same hardened HTTP executor, but they are called by app code through the iframe bridge: ``` App code calls callIntegrationTool(toolName, input) → App iframe posts second:integration:execute → AppIntegrationBridge validates iframe source → POST /api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute → Authenticate browser workspace context → Resolve app access and draft/published version → Resolve canonical approved appTools[] spec server-side → Resolve grant by workspaceId + appId + domain + keySlug → Inject static secrets or current viewer OAuth access token → Validate hostname, protocol, and resolved IPs → Execute bounded HTTP request to external API → Return response to app code ``` The iframe sends only `toolName` and input. It never sends endpoint URLs, secret placeholders, OAuth metadata, credential IDs, or provider tokens. There is no separate OAuth refresh service, cron, sidecar, or Kubernetes job. Refresh happens synchronously inside the existing Next.js API path when a tool needs an access token. The "refresh server" is the provider token endpoint. ## Data model `integrations` stores app grants: ```ts theme={null} type IntegrationDocument = { _id: string; workspaceId: string; appId: string; appName: string; name: string; // "Linear" domain: string; // "linear.app" keySlug: string; // "default", "write-access", etc. keyName: string; // "Linear read key for Roadmap Tracker" capabilityLabel: string; // "Linear read" auth: | { type: "static_secret" } | { type: "oauth2"; providerKey: string; identity: "triggering_user"; authorizationUrl: string; tokenUrl: string; scopes: string[]; tokenAuthMethod: "client_secret_post" | "client_secret_basic" | "none"; authorizationParams?: Record; tokenParams?: Record; }; accessLevel: "read" | "write" | "delete_admin" | "mixed" | "unknown"; credentialBinding: { mode: "none" } | { mode: "dedicated"; credentialId: string }; permissionGroups: IntegrationPermissionGroup[]; secretRequirements: IntegrationSecretRequirement[]; setupInstructions: IntegrationSetupInstructions | null; requestedByUserId: string; requestedByUserName: string; requestedAt: Date; createdAt: Date; updatedAt: Date; }; ``` `integration_credentials` stores secret material and configured snapshots: ```ts theme={null} type IntegrationCredentialDocument = { _id: string; workspaceId: string; domain: string; credentialName: string; configured: boolean; vaultSecretIds: Record; localSecrets: Record; configuredPermissionGroups: IntegrationPermissionGroup[]; configuredSecrets: string[]; capabilityFingerprint: string; linkedGrantIds: string[]; createdAt: Date; updatedAt: Date; }; ``` Read models expose configured secret names, never secret values or Vault IDs. OAuth provider configs store workspace/provider OAuth client metadata: ```ts theme={null} type OAuthProviderConfigDocument = { _id: string; workspaceId: string; providerKey: string; // "google", "microsoft", "zoom", etc. displayName: string; authorizationUrl: string; tokenUrl: string; tokenAuthMethod: "client_secret_post" | "client_secret_basic" | "none"; defaultAuthorizationParams?: Record; defaultTokenParams?: Record; clientId: string | null; clientSecretRef: string | null; // WorkOS Vault or local encrypted ref configured: boolean; configuredAt?: Date | null; createdAt: Date; updatedAt: Date; }; ``` Connected accounts store per-user OAuth state: ```ts theme={null} type ConnectedAccountDocument = { _id: string; workspaceId: string; userId: string; providerConfigId: string; providerKey: string; source: "customer_oauth" | "local_direct" | "hosted_broker"; externalSubject?: string | null; accountEmail?: string | null; accountName?: string | null; grantedScopes: string[]; refreshTokenRef?: string | null; // WorkOS Vault or local encrypted ref accessTokenRef?: string | null; // optional short-lived cache accessTokenExpiresAt?: Date | null; lastRefreshAt?: Date | null; lastRefreshError?: string | null; revokedAt?: Date | null; createdAt: Date; updatedAt: Date; }; ``` Provider keys are workspace-local grouping keys, not a hardcoded registry. The builder discovers official OAuth URLs and scopes from provider docs and writes them into `agents.json` and `integration-setup.json`; runtime safety comes from admin approval plus URL/scope invariants. ## integration-setup.json The builder creates `integration-setup.json` only when this app needs setup. The same provider can appear in many apps because the grant identity includes `appId` and `keySlug`. ```json theme={null} { "integrations": [ { "name": "Slack", "domain": "slack.com", "keySlug": "default", "keyName": "Slack post key for this app", "capabilityLabel": "Slack post", "why": "This app sends Slack messages.", "permissionGroups": [ { "name": "Write", "description": "Allows the app to post messages into selected Slack channels.", "permissions": ["chat:write"] } ], "secrets": [ { "name": "SLACK_BOT_TOKEN", "label": "Slack bot token", "description": "Paste the Bot User OAuth Token that starts with xoxb-.", "required": true } ], "setupInstructions": { "overview": "Create or update a Slack app, grant the bot scope, install it to the workspace, and paste the bot token in Second.", "steps": [ { "title": "Open Slack apps", "description": "Go to [Slack | API apps](https://api.slack.com/apps) and create a new app or open the existing app you want Second to use.", "url": "https://api.slack.com/apps" } ] } } ] } ``` When the builder calls `present_integration_setup`, the worker reads this file and posts it to `/api/internal/integration-requirements`. That sync is idempotent for the current app: listed grants are upserted, and grants no longer present for the app are removed. OAuth setup items use the same outer shape but declare `auth.type = "oauth2"` instead of static secrets: ```json theme={null} { "integrations": [ { "name": "Google Gmail", "domain": "googleapis.com", "keySlug": "gmail-read", "keyName": "Google OAuth client for this app", "capabilityLabel": "Gmail metadata search", "why": "This app searches the triggering user's Gmail metadata.", "auth": { "type": "oauth2", "providerKey": "google", "identity": "triggering_user", "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth", "tokenUrl": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/gmail.metadata"], "tokenAuthMethod": "client_secret_post", "authorizationParams": { "access_type": "offline", "prompt": "consent" } }, "permissionGroups": [ { "name": "Read-only", "description": "Allows the app to search Gmail metadata for the connected user.", "permissions": ["https://www.googleapis.com/auth/gmail.metadata"] } ], "setupInstructions": { "overview": "Create a customer-owned OAuth app, add Second's redirect URI, configure the client ID/secret in Second, then each user connects their own account.", "steps": [ { "title": "Create OAuth client", "description": "Create a provider OAuth app with the listed scopes. For Google Workspace enterprise deployments, use an Internal app when appropriate." }, { "title": "Add redirect URI", "description": "Copy the redirect URI shown in Second's integration settings into the provider OAuth app." }, { "title": "Configure and connect", "description": "Paste the client ID and client secret into Second, then connect your own account." } ] } } ] } ``` For local OAuth smoke tests, run the dev server without portless: ```bash theme={null} SECOND_DEV_PORTLESS=0 PORT=4198 npm run dev ``` Use the resulting `http://localhost:` app URL and register the exact redirect URI shown in Second, such as `http://localhost:4198/api/oauth/callback`. Portless `*.second.localhost` URLs are useful for normal development, but providers such as Google do not treat them as loopback OAuth redirect URIs. The packaged `npx --yes @second-inc/cli` local runtime should follow the same plain loopback shape for OAuth-capable local runs; portless is only a `npm run dev` convenience. ## agents.json custom tools Custom tools reference the same app key with `integration.keySlug`. If omitted, Second normalizes the slug to `"default"`. ```json theme={null} { "type": "custom", "name": "linear_search_issues", "integration": { "name": "Linear", "domain": "linear.app", "keySlug": "default" }, "endpoint": { "method": "POST", "url": "https://api.linear.app/graphql", "headers": { "Authorization": "{{secrets.LINEAR_API_KEY}}" } }, "mockData": [{ "issues": [] }] } ``` `present_agents` validates that custom tools include integration metadata, endpoint method, and endpoint URL. Static-secret tools include a named `{{secrets.NAME}}` placeholder, OAuth tools include auth metadata, and public unauthenticated tools may omit both when the provider's official API requires no credentials. Draft and published runtime calls then verify the requested tool still appears in the approved `agents.json` payload before credentials are injected or, for public tools, before the bounded public request is executed. Top-level `appTools` use the same custom HTTP shape, auth metadata, mock-data behavior, domain lock, and app-scoped integration grant lookup. They are for deterministic app code, not AI agent reasoning: ```json theme={null} { "appTools": [ { "type": "custom", "name": "posthog_events_page", "displayName": "Fetch PostHog events page", "integration": { "name": "PostHog", "domain": "posthog.com", "keySlug": "default" }, "endpoint": { "method": "GET", "url": "https://app.posthog.com/api/projects/{{projectId}}/events/", "headers": { "Authorization": "Bearer {{secrets.POSTHOG_PERSONAL_API_KEY}}" }, "queryParams": { "after": "{{after}}", "before": "{{before}}", "limit": "{{limit}}" } }, "mockData": [{ "results": [], "next": null }] } ], "agents": [] } ``` When an app action and an agent tool use the same provider credential, they share the same grant by using the same `domain` and `keySlug`. The builder must write `integration-setup.json` with the complete union of permissions, scopes, and named secrets required by both. OAuth custom tools are still custom HTTP tools. They declare `integration.auth` and omit `Authorization` headers: ```json theme={null} { "type": "custom", "name": "gmail_search_messages", "integration": { "name": "Google Gmail", "domain": "googleapis.com", "keySlug": "gmail-read", "auth": { "type": "oauth2", "providerKey": "google", "identity": "triggering_user", "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth", "tokenUrl": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/gmail.metadata"], "tokenAuthMethod": "client_secret_post", "authorizationParams": { "access_type": "offline", "prompt": "consent" } } }, "endpoint": { "method": "GET", "url": "https://gmail.googleapis.com/gmail/v1/users/me/messages", "queryParams": { "q": "{{query}}", "maxResults": "10" } }, "mockData": [{ "messages": [] }] } ``` OAuth tools must not include `{{oauth.access_token}}`, `{{access_token}}`, `{{token}}`, `{{secrets.*}}`, or an explicit `Authorization` header. The broker injects the bearer token after resolving the triggering user from `runId` for agent tools, or the current app viewer for app-callable actions. ## Setup state A grant needs setup when: * no credential is bound to the current app grant * the bound credential is not configured * a requested permission/scope is not present in configured snapshots * a requested required secret name is not present in configured snapshots * an OAuth provider config shell exists but has no client ID/secret * the current user has not connected the required OAuth account * the connected OAuth account is revoked or missing required scopes Review approval and direct publish check only the current app's grants. A different app's configured provider key does not clear the gate. ## Secret management | Mode | Storage | When | | ------------------ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | WorkOS Vault | `vaultSecretIds[name]` in `integration_credentials` | Production deployments with WorkOS configured | | Local secret | `localSecrets[name]` in `integration_credentials` | Local development without WorkOS | | OAuth secret store | `vault:` or `local:v1:` refs in provider/account rows | OAuth client secrets, refresh tokens, and optional short-lived access-token cache | Secrets are injected by replacing named placeholders such as `{{secrets.SLACK_BOT_TOKEN}}` in URL, headers, query params, and request body. Tool input fields can also be used as placeholders, such as `{{query}}`. OAuth client secrets and refresh tokens use `apps/web/src/lib/oauth/secret-store.ts`. When WorkOS Vault is configured, values go to Vault. In local development, the adapter encrypts values with `SECOND_TOKEN_ENCRYPTION_KEY` or a generated gitignored key under `.second-dev/`. In production without WorkOS Vault, the local adapter fails closed unless `SECOND_TOKEN_ENCRYPTION_KEY` is configured. If the agent provides tool input but the endpoint spec does not use any non-secret placeholders, execution fails. This prevents custom tools from accidentally turning a lookup into a broad static API call. ## Tool execution constraints * HTTPS only, except `localhost` during development * final URL hostname must match `integration.domain` or one of its subdomains * requested tool must be present in the approved app `agents.json` payload * app-callable actions must be present in top-level approved `appTools[]`; the browser cannot provide endpoint specs or credential metadata * runtime grant lookup includes `workspaceId`, `appId`, `domain`, and `keySlug` * OAuth runtime additionally requires `runId`, loads the run by `workspaceId + appId + runId`, and resolves the triggering user from that server-created row for agent tools; app actions use the current authenticated app viewer * OAuth provider config and connected account lookups include `workspaceId`, and connected account lookup also includes `userId` * OAuth authorization and token URLs must be HTTPS and resolve outside private network ranges before Second redirects or exchanges tokens * external requests to private/internal IP ranges are rejected * 30-second external request timeout * 1MB response limit * mock data is returned for missing or unconfigured integrations, including OAuth missing-account, revoked-account, missing-scope, or provider-config cases * live failures return structured, redacted diagnostics such as provider status, provider message, `errorCategory`, `resolution`, `retryable`, and whether the failure is reasonable for the builder to repair ## API routes | Method | Path | Purpose | | -------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | `GET` | `/api/workspaces/[wId]/integrations` | Projected settings read model grouped by app/key. No secret values or Vault IDs | | `POST` | `/api/workspaces/[wId]/integrations` | Rejected for app-blind creates; grants come from app setup sync | | `PATCH` | `/api/workspaces/[wId]/integrations/[id]` | Configure or rotate the credential for one app grant | | `POST` | `/api/workspaces/[wId]/integrations/[id]` | Reset saved credential state for one app grant | | `DELETE` | `/api/workspaces/[wId]/integrations/[id]` | Delete one app grant and its dedicated credential | | `POST` | `/api/internal/integration-requirements` | Sync app-scoped grant requirements from `integration-setup.json` | | `POST` | `/api/internal/workspace-integrations` | Return current app grant metadata to the builder, without secret values | | `POST` | `/api/internal/tool-execute` | Execute a custom HTTP tool with app-grant credential resolution | | `POST` | `/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute` | Execute an approved app-callable integration action for the current app viewer | | `POST` | `/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/report-failure` | Report a repairable draft app backend function failure to the builder | | `PATCH` | `/api/workspaces/[wId]/oauth-provider-configs/[providerConfigId]` | Configure or rotate a workspace OAuth client | | `GET` | `/api/workspaces/[wId]/oauth/[providerConfigId]/start` | Start current-user OAuth consent for one app grant | | `GET` | `/api/oauth/callback` | Generic OAuth callback that exchanges code, stores tokens, and redirects back | | `DELETE` | `/api/workspaces/[wId]/connected-accounts/[accountId]` | Revoke the current user's connected account | Workspace realtime publishes compact `integration.changed` invalidation events after successful mutations. Events may include IDs and key slug metadata, but never secrets, prompts, source files, headers, cookies, or full documents. ## Key files | File | Role | | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `apps/web/src/lib/db/types.ts` | Grant and credential document types | | `apps/web/src/lib/db/repositories/integrations.ts` | Grant sync, credential configure/reset/delete, setup checks | | `apps/web/src/lib/db/repositories/oauth-provider-configs.ts` | OAuth provider config shell/configure helpers | | `apps/web/src/lib/db/repositories/connected-accounts.ts` | Connected-account lookup, scope checks, token cache, revoke state | | `apps/web/src/lib/oauth/secret-store.ts` | WorkOS/local encrypted OAuth secret references | | `apps/web/src/lib/oauth/token-exchange.ts` | Code exchange and refresh-token request helper | | `apps/web/src/lib/oauth/token-broker.ts` | On-demand access-token cache/refresh broker | | `apps/web/src/lib/integrations/execute-http-action.ts` | Shared runtime secret/OAuth injection, mock fallback, response bounds, and domain/IP guards | | `apps/web/src/app/api/internal/tool-execute/route.ts` | Internal app-agent tool approval check and audit wrapper | | `apps/web/src/app/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute/route.ts` | Browser-authenticated app action route | | `apps/web/src/app/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/report-failure/route.ts` | Draft-only builder recovery reports from generated app code | | `apps/web/src/components/app-integration-bridge.tsx` | Iframe parent bridge for `callIntegrationTool` | | `apps/web/src/app/api/internal/integration-requirements/route.ts` | Worker-to-web setup sync | | `apps/web/src/app/api/workspaces/[wId]/integrations/[id]/route.ts` | Configure, reset, and delete one app grant | | `apps/web/src/app/api/workspaces/[wId]/oauth/[providerConfigId]/start/route.ts` | OAuth consent start | | `apps/web/src/app/api/oauth/callback/route.ts` | OAuth callback | | `apps/web/src/app/w/[wId]/settings/integrations/integrations-client.tsx` | App/key settings UI | | `apps/worker/src/runner.ts` | Builder integration tools, `present_agents`, and custom app tool bridge | | `apps/worker/src/workspace-template.ts` | Generated app SDK, including `callIntegrationTool` | # Runtimes, Models & Usage Tracking Source: https://docs.second.so/models-and-usage How runtime/model selection works, what happens under the hood, and how costs are tracked per-app and per-model. ## Runtime selection Second supports three builder runtimes: | Runtime | Runtime ID | Model format | Parameter controls | | ----------- | ------------- | ------------------------------------------------------ | ---------------------------------- | | Claude Code | `claude-code` | Claude model IDs such as `claude-opus-4-8` | Effort and thinking | | Codex CLI | `codex-cli` | OpenAI model IDs such as `gpt-5.4` | Reasoning effort and Codex sandbox | | OpenCode | `opencode` | OpenCode `provider/model` IDs such as `openai/gpt-5.5` | Model variant | Apps persist runtime settings as: ```typescript theme={null} { runtimeId: "claude-code" | "codex-cli" | "opencode"; runtimeModel: string; runtimeParams: Record; } ``` The model picker is driven by `apps/web/src/lib/agent/runtime-registry.ts`. It groups models by runtime and renders only the parameter controls supported by the selected runtime. The composer and chat transport send `runtimeId`, `runtimeModel`, and `runtimeParams` on every app creation, settings update, and chat POST. The local onboarding runtime choice is also saved as a browser preference so the app composer opens with the selected runtime instead of falling back to the project default. ## How command runtimes work under the hood Claude uses the Claude Agent SDK. Codex is launched through the Codex CLI app-server protocol over stdio, which is the same local Codex runtime surface used by the Codex SDK but without adding an extra SDK dependency in the worker. OpenCode is launched in non-interactive JSON mode. The worker normalizes all runtime output into the same Claude-shaped worker SSE events so the existing chat bridge and AI element cards continue to render streamed text, plans, terminal commands, file edits, app data tools, integration setup, and `done_building`. OpenCode support requires an OpenCode CLI version whose `opencode run --help` includes `--format json`. Older OpenCode binaries are reported during onboarding as installed but not usable for the OpenCode runtime, and the worker returns a clear runtime error instead of starting a non-streamable plain-text run. OpenCode readiness is based on the CLI's own configured model list: if `opencode models --verbose` can return usable models, Second treats OpenCode as configured even when the setup uses custom providers such as LiteLLM, vLLM, or another OpenAI-compatible gateway instead of `opencode auth login`. Model discovery exposes each model's `variants` as the OpenCode intelligence control. The selected variant is passed to `opencode run --variant`; `auto` omits the flag and lets OpenCode choose the model default. ### Claude Agent SDK Understanding model selection requires understanding what `query()` does at the process level. ### Every call spawns a new process The Claude Agent SDK does not keep a long-running connection to the Anthropic API. Each `query()` call spawns a **brand new CLI process**: ``` query({ prompt: "hello", options: { model: "claude-opus-4-8" } }) → child_process.spawn("node", ["cli.js", "--model", "claude-opus-4-8", ...]) ``` The CLI binary handles the entire agent loop internally: 1. Sends `POST https://api.anthropic.com/v1/messages` with the specified model 2. Claude responds with text and/or tool calls 3. CLI executes tools locally (Read, Edit, Bash, etc.) 4. CLI appends tool results and sends another API call 5. Repeat until Claude responds with no tool calls 6. Process exits There is no "direct API mode." The SDK is a wrapper around the `claude` CLI binary. ### Sessions are files on disk The CLI writes every API request and response to a JSONL file: ``` ~/.claude/projects//.jsonl ``` Each line is a complete message with the raw API response, including the `model` field and full `usage` object. This file is the CLI's own record of what happened — not written by our code. ### The API is stateless Anthropic's Messages API has no server-side sessions. Every API call includes the entire conversation history as the `messages` array. Resuming a session means re-sending all previous messages as input tokens. Prompt caching mitigates this: system prompts, tool definitions, and early messages get cached at 0.1x the input price. In practice, most resumed conversations hit the cache heavily. ## Model selection and switching ### Available models The runtime registry includes Claude Code, Codex CLI, and OpenCode defaults. It stores runtime-native IDs, display names, descriptions, defaults, and parameter constraints. OpenCode also has a dynamic model picker that reads the installed OpenCode catalog/config at runtime. Dynamically discovered OpenCode models keep their native `provider/model` IDs instead of being collapsed back to the static defaults. Claude pricing metadata is available for cost display: | Display name | Model ID | Description | Input / MTok | Output / MTok | Cache read / MTok | | ------------ | ------------------- | ------------------------------------------ | ------------ | ------------- | ----------------- | | Opus 4.8 | `claude-opus-4-8` | Most capable for long-horizon agentic work | \$5 | \$25 | \$0.50 | | Opus 4.6 | `claude-opus-4-6` | Previous Opus release, still available | \$5 | \$25 | \$0.50 | | Sonnet 4.6 | `claude-sonnet-4-6` | Most efficient for everyday tasks | \$3 | \$15 | \$0.30 | | Haiku 4.5 | `claude-haiku-4-5` | Fastest for quick answers | \$1 | \$5 | \$0.10 | The default runtime is Claude Code with Opus 4.8, `xhigh` effort, adaptive thinking, and summarized thinking display. Runtime defaults and model display names are defined in `lib/agent/runtime-registry.ts`. ### Model-specific capabilities Some features are only available on certain models: | Feature | Available on | Fallback for other models | | -------------------- | ------------------------------ | ------------------------- | | Effort: `xhigh` | Opus 4.8 | `high` | | Effort: `max` | Opus 4.8, Opus 4.6, Sonnet 4.6 | `high` | | Thinking: `adaptive` | Opus 4.8, Opus 4.6, Sonnet 4.6 | `disabled` | | Thinking: `enabled` | Opus 4.6, Sonnet 4.6 | `adaptive` on Opus 4.8 | The UI enforces these constraints from the runtime registry. If the user switches models, unsupported parameter selections are automatically downgraded to a supported default. Opus 4.8 defaults provider thinking display to omitted, so the worker explicitly sends `display: "summarized"` with adaptive thinking. Without that flag, Claude may spend thinking tokens but return empty thinking text to the UI. ### How switching works The user selects a runtime model and runtime-specific parameters from the composer. Each message carries the normalized runtime settings through the full stack: ``` Composer dropdowns → React refs (runtimeId, runtimeModel, runtimeParams) ↓ Custom fetch on DefaultChatTransport (reads refs, injects runtime settings into POST body) ↓ POST /api/.../chat → body.runtimeId, body.runtimeModel, body.runtimeParams ↓ worker-bridge → POST /sessions/:appId/messages → runtime settings ↓ session.sendMessage(prompt, runtimeSettings) ↓ runtime adapter dispatches to Claude, Codex CLI, or OpenCode ``` Switching from Sonnet to Opus mid-conversation means the **next** message spawns a new Claude CLI process with `--model claude-opus-4-8 --resume `. The CLI reads the session JSONL (which includes all previous Sonnet messages), sends the full history to the API with the new model, and continues the conversation. Effort and thinking settings take effect on the same call. Second stores provider-native session state per runtime on the run document. When the user keeps using a runtime whose native session state is current, the next message sends only the latest user prompt plus that runtime's session state. When the user switches to another runtime, Second uses the persisted provider-agnostic `UIMessage[]` transcript as the handoff layer. The chat route builds a bounded neutral transcript for the messages that the target runtime has not already seen, then appends the latest user message. The target runtime receives that handoff as plain prompt context plus its own provider session state when one exists. Second does not write vendor-private session files to "convert" a Claude session into a Codex or OpenCode session. The durable source of truth is the stored `UIMessage[]` plus the workspace files on disk; provider session state is an optimization for native resume, not the tenant boundary or the only conversation record. No re-run and no conversation restart. Same-runtime switches use native resume when possible; cross-runtime switches use the neutral transcript handoff and continue from the same Second run. ### Why custom fetch (not transport body) The Vercel AI SDK's `useChat` hook captures the `DefaultChatTransport` instance on first render and never swaps it. If you create a new transport when the model changes, `useChat` ignores it. The solution: create one stable transport (memoized on `chatApi` only) with a custom `fetch` function that reads current values from React refs on every request: ```typescript theme={null} const runtimeSettingsRef = useRef(runtimeSettings); const transport = useMemo(() => new DefaultChatTransport({ api: chatApi, fetch: async (input, init) => { if (init?.method === "POST" && typeof init.body === "string") { const body = JSON.parse(init.body); const latest = runtimeSettingsRef.current; // always reads latest body.runtimeId = latest.runtimeId; body.runtimeModel = latest.model; body.runtimeParams = latest.params; return globalThis.fetch(input, { ...init, body: JSON.stringify(body) }); } return globalThis.fetch(input, init); }, }), [chatApi]); // no dependency on selected values ``` ### Composer layout ``` ┌──────────────────────────────────────────────────┐ │ [textarea] │ │ │ │ [+] [Sonnet 4.6 ▼] [runtime params...] [⬆ / ⏸] │ └──────────────────────────────────────────────────┘ ``` * **`+` button** — Attach files (placeholder, not wired yet). * **Model dropdown** (`components/model-selector.tsx`) — shared between the workspace composer and the chat composer. Shows Claude and Codex models inline and opens a searchable OpenCode model dialog for larger OpenCode catalogs. Includes an "Add runtime" dialog with setup notes for Claude Code, Codex CLI, and OpenCode. * **Runtime parameter dropdowns** (`components/runtime-parameter-selectors.tsx`) — rendered from `runtime-registry.ts`. Claude shows effort and thinking. Codex CLI shows reasoning effort and sandbox mode. OpenCode shows the selected model variant. * **Submit button** — Circle with `ArrowUp` icon. Switches to `Pause` while streaming. Clicking during a stream calls `stop()` to abort. When the user switches runtime or model, settings are normalized against the selected runtime's defaults and supported options. For example, switching from Opus to a non-Opus Claude model downgrades Opus-only selections to supported Claude values. ## Local provider setup During onboarding in local mode (`SECOND_AUTH_MODE=none`), a provider setup screen at `/onboarding/provider` auto-detects what's available: 1. **Claude CLI on PATH** — checked via `which claude` on the worker, or `SECOND_CLAUDE_PATH` when an operator pins a custom executable path 2. **Codex CLI on PATH** — checked via `which codex` on the worker, or `SECOND_CODEX_PATH` when configured 3. **OpenCode CLI on PATH with JSON events and configured models** — checked via `which opencode`, `opencode run --help`, and `opencode models --verbose` on the worker, or `SECOND_OPENCODE_PATH` when configured. OpenCode model discovery is available through the worker's `/opencode/models` endpoint and returns only model metadata, not auth files or config contents. 4. **Runtime auth env hints** — `ANTHROPIC_API_KEY`, `CODEX_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, and `GEMINI_API_KEY` are reported only as booleans, never values If the Claude CLI is installed and the user has logged in (`claude login`), everything works automatically — no API key needed. The SDK spawns the user's local `claude` binary, which uses their existing auth. If `ANTHROPIC_API_KEY` is set, it takes priority — the CLI switches to API billing regardless of whether the user is also logged in via subscription. Codex CLI can use its own login state or `CODEX_API_KEY`/`OPENAI_API_KEY`, depending on the installed CLI configuration. Detection runs `codex login status` and checks stdout and stderr because Codex may print login status on stderr even when the command succeeds. It reports only a boolean auth result; it never returns token values or reads auth file contents. OpenCode uses the provider credentials required by the selected `provider/model` ID. This screen only exists in local mode. In enterprise deployments (`SECOND_AUTH_MODE=external`), the API key is configured before deployment and the screen is skipped entirely. ### Files involved | File | Role | | ----------------------------------------------------- | -------------------------------------------------------------------------------------- | | `apps/worker/src/index.ts` | `GET /detect-provider` — detects `claude`, `codex`, `opencode`, and auth-mode booleans | | `apps/web/src/app/api/setup/detect-provider/route.ts` | Proxies to worker | | `apps/web/src/app/onboarding/provider/page.tsx` | Server component — guards, renders setup | | `apps/web/src/components/provider-setup.tsx` | Client component — calls detect, shows results | ## Billing modes Second separates runtime authentication from token/cost visibility. Runtimes can emit token counts and API-equivalent dollar estimates even when the local CLI usage is covered by a subscription plan. | Runtime | Local subscription mode | API billing mode | | ----------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | Claude Code | `SECOND_AUTH_MODE=none`, no `ANTHROPIC_API_KEY`, Claude CLI logged in via Claude.ai | `ANTHROPIC_API_KEY` configured | | Codex CLI | `SECOND_AUTH_MODE=none`, no `CODEX_API_KEY`/`OPENAI_API_KEY`, Codex CLI logged in with ChatGPT | `CODEX_API_KEY` or `OPENAI_API_KEY` configured | | OpenCode | Not treated as subscription-backed by Second | Provider key required by the selected `provider/model` | The app usage panel still shows token counts in all modes. In local subscription mode, it treats provider dollar values as API-equivalent estimates: the estimate is struck through and the displayed run cost excludes that subscription-backed model usage. For example, local Claude Code shows "Running on your Claude subscription"; local Codex CLI with ChatGPT login shows "Running through your Codex CLI ChatGPT login." Detection happens in `page.tsx` from server environment flags, then `AppWorkspace` applies the billing display per model row. This matters for mixed-runtime runs: a Claude subscription row and a Codex ChatGPT-login row can both be struck through, while an API-key-backed OpenCode row still displays as billable. ## Usage tracking ### Where the data comes from Claude emits a `result` message at the end of every SDK `query()` call: ```json theme={null} { "type": "result", "total_cost_usd": 0.0342, "num_turns": 3, "duration_ms": 12400, "duration_api_ms": 8200, "modelUsage": { "claude-opus-4-8": { "inputTokens": 8420, "outputTokens": 1203, "cacheReadInputTokens": 6100, "cacheCreationInputTokens": 500, "costUSD": 0.0342 } } } ``` The `modelUsage` field is computed by the runtime adapter from provider runtime events. Claude includes cost and token data from the Claude CLI result. Codex app-server exposes token usage but not a dollar value, so Second estimates OpenAI cost from the selected model's current input, cached-input, and output token rates. OpenCode emits the same result shape when its JSON stream exposes usage data; when a runtime does not expose cost and Second has no pricing metadata for the selected model, Second records token counts when available and zero cost. ### How it's captured ``` Runtime result message (emitted or normalized by the worker) → worker SSE stream → worker-bridge captures msg.type === "result" → extracts totalCostUsd + modelUsage → API route calls accumulateRunUsage() → MongoDB $inc on the run document ``` Usage is accumulated atomically with `$inc`. Each runtime turn adds to the run's totals. Multiple messages in a run accumulate correctly. ### Schema The `usage` field on `AgentRunDocument`: ```typescript theme={null} type RunUsage = { totalCostUsd: number; // sum across all runtime turns in this run totalInputTokens: number; totalOutputTokens: number; totalCacheReadTokens: number; totalCacheCreationTokens: number; byModel: Record; }; ``` ### Querying for billing Per-app cost across all runs: ```javascript theme={null} db.agent_runs.aggregate([ { $match: { workspaceId: "" } }, { $group: { _id: "$appId", totalCost: { $sum: "$usage.totalCostUsd" }, totalInput: { $sum: "$usage.totalInputTokens" }, totalOutput: { $sum: "$usage.totalOutputTokens" }, }} ]) ``` Per-workspace cost (all apps): ```javascript theme={null} db.agent_runs.aggregate([ { $match: { workspaceId: "" } }, { $group: { _id: null, totalCost: { $sum: "$usage.totalCostUsd" }, }} ]) ``` ### UI: info panel A small `ⓘ` icon in the top-right corner of the app page opens a dropdown showing: * Total run cost * Input / output / cache-read token counts * Per-model breakdown (model name, token count, cost) The data refreshes automatically when a stream finishes — the frontend detects the streaming → ready transition and fetches `GET /chat` which includes `usage` in the response. ## Verification and debugging Five levels of proof that the correct runtime/model was used, from lowest (closest to the metal) to highest: ### 1. Runtime-native logs Claude writes every raw API response to a session JSONL file. The `model` field in each response is what the Anthropic API returned. ```bash theme={null} # Find the session file for a specific app ls ~/.claude/projects/-private-tmp-second-workspaces-/ # Parse it and show which models were used python3 -c " import json, sys, collections models = collections.Counter() for line in open(sys.argv[1]): d = json.loads(line) m = d.get('message', {}).get('model', '') if m: u = d.get('message', {}).get('usage', {}) models[m] += u.get('output_tokens', 0) for m, tokens in models.items(): print(f'{m}: {tokens} output tokens') " ~/.claude/projects/-private-tmp-second-workspaces-/*.jsonl ``` Each line in the JSONL contains the full API response: ```json theme={null} { "type": "assistant", "message": { "id": "msg_01HnLBE9DJDSMKxTRNMZWqvj", "model": "claude-sonnet-4-6", "usage": { "input_tokens": 3, "output_tokens": 66, "cache_read_input_tokens": 7294, "cache_creation_input_tokens": 2725 } } } ``` The `msg_01...` ID is assigned by Anthropic's API. Different IDs = different API calls. Different `model` values = different models served the request. Codex CLI and OpenCode keep their own runtime/session records depending on the installed CLI configuration. Use those native logs together with Second's stored `sessionState` when debugging resume behavior. ### 2. Provider console For API-key backed runtimes, use the provider's console or usage logs. For example, Anthropic logs Claude API calls with model, token counts, and cost. ### 3. MongoDB ```bash theme={null} mongosh second --eval \ 'db.agent_runs.find({}, {"usage.byModel":1}).sort({updatedAt:-1}).limit(1).pretty()' ``` Shows the accumulated `modelUsage` from all `result` messages in the run. ### 4. Worker terminal The worker logs each request: ``` [worker] appId=69c6f381... model=claude-opus-4-8 ``` ### 5. Browser Network tab Open devtools → Network → filter by `chat`. Inspect the POST request payload. It contains `runtimeId`, `runtimeModel`, and `runtimeParams` injected by the custom fetch. # Product analytics Source: https://docs.second.so/product-analytics How Second captures PostHog events while preserving the anonymized usage-data setting. Second captures lightweight product analytics after onboarding. Analytics are enabled by default, and the default user setting is anonymized. The PostHog project token is a public client/project token, not a private API key. Configure it with `SECOND_POSTHOG_TOKEN` when overriding the built-in release default, and use `SECOND_POSTHOG_HOST` for non-US PostHog projects. ## Capture path Browser code does not send directly to PostHog. It posts events to Second's same-origin `/api/analytics/capture` endpoint. The web route: * requires an authenticated, onboarded user * allowlists the supported event names * sanitizes property keys and values * applies the current anonymization mode * forwards the final payload to PostHog This keeps the privacy and tenant checks in Second instead of trusting every call site to send the right PostHog payload. ## Events Second currently captures: * `page viewed` * `onboarding finished` * `chat initiated` * `sidebar clicked` * `import existing app clicked` * `import existing app triggered` * `build completed` * `build failed` * `approval shown` * `approval acted` * `integration setup started` * `integration setup completed` * `app displayed` * `app agent triggered` * `app agent finished` * `app agent error` * `showed suggestions tool called` * `suggestion picked` * `agents approved` All browser analytics events include anonymous-safe page context: * `surface`, such as `workspace_home`, `app_chat`, `settings`, `library`, or `workspace_agents` * `route_shape`, such as `/w/:workspace/apps/:objectId` * small client context such as viewport size The route shape removes workspace IDs, app IDs, run IDs, UUIDs, and integration route segments before the event is sent. Build and agent events use counts and outcomes where possible: message count, tool call count, attachment count, file count, duration, runtime ID/model, and failure phase/error code. Raw prompts, messages, app names, agent names, URLs, and IDs are still stripped in anonymized mode. The analytics endpoint also adds release metadata server-side to every event: * `release_version` * `release_package` * `release_runtime` * `cli_launcher_version` * `cli_launcher_package` For CLI runs these values come from the launcher/runtime environment. In other deployment modes they may be `null` unless the deployment sets release environment variables. ## Anonymized mode When anonymization is on, the browser creates one stable local anonymous ID in `localStorage`: ```text theme={null} second:analytics-anonymous-id:v1 ``` The value is an `anon_...` UUID generated by Second. It is sent as the PostHog `distinct_id` for anonymized events, so repeated events from the same browser are grouped together without identifying the user. Anonymized events deliberately: * do not call PostHog `$identify` * do not alias the anonymous ID to the real user ID * set `$process_person_profile: false` * strip user, workspace, app, run, agent, prompt, message, raw error, suggestion, URL, and referrer identifiers * strip generic identifier-shaped properties such as `*_id`, `*_ids`, `*_email`, `*_name`, and `*_names` This means anonymized events stay useful for aggregate product analytics while remaining separate from the user's PostHog person profile. ## Non-anonymized mode Users can turn off anonymization from the workspace account menu under **Usage data settings**. When anonymization is off, Second sends a PostHog `$identify` event for the onboarded user and uses the Second user ID as the PostHog `distinct_id`. Non-anonymized events may include the user, workspace, app, agent, message, and screen properties needed for debugging and product analysis. ## Screen recording Screen recording is a separate opt-in inside **Usage data settings**. It is off by default, and enabling it also turns anonymization off because replay captures the actual Second interface for product debugging. Regular product analytics still use the same-origin `/api/analytics/capture` route. Screen recording is the exception: PostHog session replay runs in the browser through `posthog-js`, because replay is captured from the browser DOM and cannot be produced by the server-side capture endpoint. When screen recording is enabled, the browser loads the public PostHog project token and host from Second's authenticated analytics config route, identifies the current onboarded user, and starts PostHog session recording. The SDK is configured without PostHog pageview/autocapture product events so it does not replace or duplicate Second's sanitized analytics capture path. When screen recording is disabled, anonymization is re-enabled, telemetry is disabled, or the user signs out, Second stops PostHog session recording and resets the browser SDK state on that tab. Browser-level password-field masking from PostHog/rrweb still applies, but Second does not apply its anonymized analytics redaction model to replay after the user explicitly enables recording. ## Switching modes Switching from anonymized to non-anonymized affects future events only. Second does not merge or alias the local anonymous ID into the identified PostHog person. That is intentional: PostHog's standard frontend SDK can link anonymous history to a user after `identify`, but doing that here would make the anonymized setting weaker. In Second, historical anonymized events remain anonymous even if the same user later disables anonymization. On sign-out, Second resets the local anonymous ID. This prevents two different users sharing one browser profile from being grouped under the same anonymous PostHog identity. ## Disabling analytics For local development, disable analytics for a run with: ```bash theme={null} npm run dev -- --disable-telemetry npm run dev -- --no-analytics SECOND_POSTHOG_DISABLED=1 npm run dev ``` For CLI runs: ```bash theme={null} npx --yes @second-inc/cli --disable-telemetry ``` For deployments, set either of: ```bash theme={null} SECOND_POSTHOG_DISABLED=1 SECOND_TELEMETRY_DISABLED=1 ``` # Quickstart Source: https://docs.second.so/quickstart Run Second locally and build your first app with an AI agent. ## Platform support The packaged local CLI (`npx --yes @second-inc/cli`) currently supports Apple Silicon Macs: M1, M2, M3, and M4. Intel Mac, Windows, and Linux support is coming soon. ## Developer setup Use this path to run Second from a source checkout. ### Prerequisites | Tool | Version | | -------------- | ------- | | Node.js | 20+ | | npm | 10+ | | Docker Desktop | latest | ### Install and run ```bash theme={null} npm --prefix apps/web install npm --prefix apps/worker install npm run dev ``` This starts MongoDB and Redis in Docker, then runs the web app and agent worker on your machine. The worker runs on the host so it can use your local Claude CLI; if you've logged in with `claude` before, the agent works without any extra configuration. The dev script writes `.second-dev.txt` in the repo root with the actual `url=` value to open. It uses portless when available for a stable `.localhost` URL and otherwise falls back to an auto-picked `http://localhost:` URL. Code changes appear automatically. The worker uses your local Claude authentication. If you've previously logged in with `claude`, the agent works without any API key configuration. Open the `url=` value from `.second-dev.txt` and continue to [Complete onboarding](#complete-onboarding). ## Packaged CLI The public command uses the package name `@second-inc/cli`. During private release rehearsal, sign in to the npm scope before using this path. The local CLI does not require Docker, Docker Compose, GHCR, a web container image, Homebrew, OpenSSL, or a user-installed MongoDB/Redis server. `@second-inc/cli` is intentionally a tiny launcher. It prints startup context, then invokes the matching platform payload package, for example `@second-inc/cli-local-darwin-arm64`. The payload package contains the packaged Next.js standalone web server, bundled worker, MongoDB binary, Redis binary, and runtime libraries needed by those binaries. After npm has installed that payload, startup does not download MongoDB, Redis, or OpenSSL separately: ```bash theme={null} npx --yes @second-inc/cli ``` `--yes` skips npm's first-run install prompt. CLI commands: | Command | What it does | | --------------------------------- | ------------------------------ | | `npx --yes @second-inc/cli` | Start Second (default) | | `npx --yes @second-inc/cli stop` | Stop all services | | `npx --yes @second-inc/cli reset` | Stop and delete all local data | Options: `--port ` to change the web port (default 3030), `--disable-telemetry` to disable product analytics. Local runtime binary overrides are optional and intended for development: `SECOND_MONGOD_PATH=/path/to/mongod` and `SECOND_REDIS_SERVER_PATH=/path/to/redis-server`. ## Complete onboarding 1. Enter your display name and email on `/onboarding/identity`. 2. Create your first workspace on `/onboarding/workspace`. 3. **Set up your AI provider** on `/onboarding/provider` — Second auto-detects whether the Claude CLI and/or an API key are available. If you've logged in with `claude` before, you're already set. 4. You'll land on the workspace home page with the composer. ## Build your first app 1. Type a prompt in the composer (e.g., "Build me a hello world React app"). 2. Click **Build**. 3. You'll navigate to the app page where the agent streams its response in real time. 4. Watch the agent write code, run commands, and explain what it's doing. 5. Send follow-up messages to iterate. You should see: * Text streaming word by word * Tool calls (Bash, Read, Write, etc.) with their inputs and outputs * The full conversation persisted — reload the page and it's still there ## Verify workspace isolation A quick way to confirm tenancy enforcement is working: 1. Create two workspaces. 2. Grab an app ID from workspace B. 3. Try to fetch it through workspace A's API: ```bash theme={null} curl -i "$(awk -F= '$1 == "url" { print $2 }' .second-dev.txt)/api/workspaces//apps/" # Expected: 404 ``` The `404` confirms the guard layer is blocking cross-workspace access. ## Run fully containerized If you prefer to run everything inside Docker (e.g., for CI or environments without a local Claude CLI): ```bash theme={null} ANTHROPIC_API_KEY=sk-ant-... npm run start ``` Builds and runs all four services (web, worker, Mongo, Redis) in Docker. The `ANTHROPIC_API_KEY` is required because Docker containers can't access your local Claude authentication. This is mainly for [self-hosting](/self-hosting) and CI. For local development, `npm run dev` is preferred because it uses your local Claude auth automatically. After publication, `npx --yes @second-inc/cli` will provide the packaged local CLI. ## Next steps * [Development](/development) — repo layout, scripts, and environment variables * [Architecture](/architecture) — system overview and request flow * [Agent System](/agent-system) — how the agent worker and bridge layer work * [Authentication](/authentication) — local vs external auth modes # Self-hosting Source: https://docs.second.so/self-hosting Deploy Second with your own MongoDB, Redis, authentication provider, and workspace governance controls. ## Production checklist Before going live, make sure you have: * [ ] `SECOND_AUTH_MODE=external` with a working auth provider extension * [ ] External auth provider syncs workspace memberships, roles, and invitations * [ ] `MONGODB_URI` pointing to a managed MongoDB instance (must support replica sets for app data Change Streams) * [ ] `SECOND_PUBLIC_URL` set to your public HTTPS origin * [ ] If using OAuth integrations, provider OAuth apps use `${SECOND_PUBLIC_URL}/api/oauth/callback` as the redirect URI * [ ] At least one supported runtime installed for the worker: `claude`, `codex`, or `opencode` * [ ] If enabling OpenCode, use a version whose `opencode run --help` includes `--format json` * [ ] Runtime provider credentials configured for the runtime(s) you enable * [ ] `REDIS_URL` pointing to a Redis instance * [ ] `INTERNAL_API_TOKEN` set to the same strong secret on both web and worker * [ ] HTTPS termination via a trusted reverse proxy * [ ] MongoDB and Redis access restricted to your application network For on-prem deployments, admins and owners remain the control point for published app access, app-scoped integration keys, and reviewed agent permissions. Local development can run without external auth, but production deployments should use external auth and a strong internal API token. Need help with a secure production rollout, runtime/provider setup, cost management, or support? Contact [sales@second.so](mailto:sales@second.so). ## Environment variables ### Web (`apps/web`) ```bash theme={null} SECOND_AUTH_MODE=external MONGODB_URI=mongodb+srv://:@/ SECOND_PUBLIC_URL=https://your-domain.example WORKER_URL=http://worker:3001 REDIS_URL=redis://redis:6379 INTERNAL_API_TOKEN= # Local auth mode only: set a stable value so no-auth sessions survive restarts. # SECOND_NO_AUTH_SESSION_SECRET=<32+-char-random-secret> # Optional but recommended in production for integration/OAuth secret storage: # WORKOS_API_KEY=... # Required in production if WorkOS Vault is not configured and OAuth is enabled: # SECOND_TOKEN_ENCRYPTION_KEY=<32-byte-base64-or-64-char-hex-or-passphrase> # Optional, diagnostics only: # SECOND_PERF_TRACE=1 # Optional, product analytics. Enabled by default in anonymized mode: # SECOND_POSTHOG_TOKEN=phc_... # SECOND_POSTHOG_HOST=https://us.i.posthog.com # SECOND_POSTHOG_DISABLED=1 # SECOND_SENTRY_DSN=https://...@...ingest.us.sentry.io/... # NEXT_PUBLIC_SENTRY_DSN=https://...@...ingest.us.sentry.io/... # SECOND_SENTRY_DISABLED=1 # SENTRY_AUTH_TOKEN=... # SECOND_TELEMETRY_DISABLED=1 ``` `SECOND_POSTHOG_TOKEN` is a PostHog project token, not a private API key. Product analytics are enabled by default after onboarding in anonymized mode, and events are forwarded by the web app's same-origin analytics endpoint. If you do not want a deployment to send PostHog analytics, set `SECOND_POSTHOG_DISABLED=1` or `SECOND_TELEMETRY_DISABLED=1`. The same public PostHog token and host are also used for the separate off-by-default screen recording opt-in in Usage data settings. Screen recording uses PostHog's browser session replay SDK and only starts when a user explicitly enables it in non-anonymized mode. `SECOND_SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_DSN` are public Sentry DSNs, not private API keys. Error reporting is enabled by default with masked replay on error only. If you do not want a deployment to send Sentry error reports, set `SECOND_SENTRY_DISABLED=1`, `SECOND_ERROR_REPORTING_DISABLED=1`, or `SECOND_TELEMETRY_DISABLED=1`. Source-map upload requires a private `SENTRY_AUTH_TOKEN` in CI; never commit that token. With anonymization on, Second forwards personless events only and strips user, workspace, app, prompt, and URL identifiers. Anonymized events share a stable local `anon_...` ID so they can be grouped in PostHog without being linked to the user's person profile. Users can turn anonymization off from the workspace account menu. With anonymization off, Second sends a dedicated PostHog `$identify` event for that onboarded user and then forwards product events with the non-anonymized event properties. See [Product analytics](/product-analytics) for the full capture and privacy model. ### Worker (`apps/worker`) ```bash theme={null} PORT=3001 INTERNAL_API_TOKEN= TOOL_EXECUTE_URL=http://web:3000/api/internal/tool-execute # Configure only the provider keys needed by enabled runtimes: # ANTHROPIC_API_KEY=sk-ant-... # CODEX_API_KEY=... # OPENAI_API_KEY=... # GOOGLE_API_KEY=... # GEMINI_API_KEY=... # Optional executable overrides when the worker PATH differs: # SECOND_CLAUDE_PATH=/usr/local/bin/claude # SECOND_CODEX_PATH=/usr/local/bin/codex # SECOND_OPENCODE_PATH=/usr/local/bin/opencode # Optional local-development Codex tuning: # SECOND_CODEX_APP_SERVER_WARM=0 # disables local warm Codex app-server reuse # Optional; only for intentionally isolated deployments using mounted Codex login state: # SECOND_ALLOW_CODEX_LOCAL_AUTH=1 # Optional; only for intentionally isolated deployments using mounted OpenCode login state: # SECOND_ALLOW_OPENCODE_LOCAL_AUTH=1 # SECOND_OPENCODE_DATA_HOME=/home/second/.local/share # SECOND_OPENCODE_AUTH_FILE=/home/second/.local/share/opencode/auth.json # Optional; points dynamic OpenCode provider discovery/runtime mirroring at a mounted config: # SECOND_OPENCODE_CONFIG_HOME=/home/second/.config # SECOND_OPENCODE_CONFIG_FILE=/home/second/.config/opencode/opencode.jsonc ``` `INTERNAL_API_TOKEN` authenticates internal web↔worker calls. The worker uses it for web internal APIs (tool execution, agent completion callbacks, app data reads/writes), and the web server uses it when calling the worker HTTP API. Use a strong random secret and set the same value on both services. The worker must never pass `INTERNAL_API_TOKEN`, MongoDB URLs, Redis URLs, WorkOS secrets, cookies, headers, or integration secret values into CLI runtimes. Codex CLI and OpenCode are launched with an allowlisted environment plus private per app/run `HOME` and config/data directories. The only token they receive for Second tools is a short-lived scoped MCP broker token. Codex receives an OpenAI key through app-server login instead of through the spawned process environment, and Codex shell commands get a separate shell `HOME` plus key/token/secret environment exclusions. In local Codex login mode, the private Codex home is seeded with only the local Codex `auth.json`; in local OpenCode login mode, the private OpenCode data directory is seeded with only OpenCode `auth.json`. If custom OpenCode providers are configured, Second mirrors only the OpenCode `provider` config object into the private runtime config so selected `provider/model` IDs resolve without inheriting user MCP servers or plugins. If that provider config references keys with OpenCode's `{env:NAME}` syntax, only those referenced provider env keys are passed through the runtime allowlist. Production deployments should prefer explicit provider keys, and local auth seeding is disabled by default under `NODE_ENV=production`. Claude Code runs with subprocess environment scrubbing enabled by default. On Linux workers, that requires the `bubblewrap` package (`bwrap` executable). Keep it installed in custom worker images. `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0` is an explicit escape hatch only for externally isolated workers that accept Claude subprocesses not getting Claude's inner env scrubber. Codex's Linux `workspace-write` sandbox can fail inside containers when the host blocks the namespace or `bwrap` operations it needs. In production, Second treats the worker/container environment as the external sandbox for normal Codex build runs and sends Codex `danger-full-access` when the selected runtime setting is `workspace-write`. Local development still uses Codex `workspace-write`. For local development only, Codex builder sessions keep a warm `codex app-server` process per app/runtime session to reduce repeated startup cost. This is disabled under `NODE_ENV=production`, does not apply to app-agent runs, and can be turned off locally with `SECOND_CODEX_APP_SERVER_WARM=0`. Redis is required for collaborative streaming and workspace coordination. It backs live stream resume/replay, run events, and workspace event invalidations. OAuth also uses Redis for short-lived OAuth state and single-flight refresh locks so concurrent tool calls do not stampede the provider token endpoint. `SECOND_PERF_TRACE=1` can be enabled temporarily during incident diagnosis. It logs route names, request IDs, elapsed timings, counts, CPU, and memory. It does not log prompts, source files, cookies, tokens, headers, or secret values, but it adds log volume and should normally stay off. ## OAuth integrations Self-hosted and on-prem deployments use customer-owned OAuth apps. Second does not require WorkOS Pipes, Pipedream, Composio, or any hosted OAuth broker for this path. The enterprise Gmail/Calendar setup flow is: 1. The builder declares OAuth metadata in `agents.json` and `integration-setup.json`: provider key, authorization URL, token URL, exact scopes, and API endpoint. 2. A workspace admin opens Settings → Integrations and copies Second's redirect URI, usually `https://your-domain.example/api/oauth/callback`. 3. The customer's Google Workspace admin creates a Google Cloud OAuth client for that deployment. For internal Workspace use, configure the consent screen as Internal and add the listed Gmail/Calendar scopes. 4. The admin pastes the OAuth client ID and client secret into Second. In production with WorkOS configured, the client secret is stored in WorkOS Vault. Without WorkOS Vault, Second requires `SECOND_TOKEN_ENCRYPTION_KEY` and stores an encrypted local reference. 5. Each end user clicks Connect for that provider. Second redirects to the provider, receives an authorization code at `/api/oauth/callback`, exchanges it server-side, and stores the refresh token through the same secret-store adapter. 6. When an app agent uses an OAuth tool, `/api/internal/tool-execute` resolves the triggering user from the app-agent run, checks the connected account and scopes, refreshes the access token on demand if needed, injects the bearer token server-side, and calls the provider API. There is no background refresh daemon. Access-token refresh is a normal provider token endpoint call made inside the existing web API request when a tool needs a valid access token. Refresh-token revocation, provider token rotation, missing scopes, and provider network failures are surfaced as reconnect or tool-failure states; token values are never logged or returned to agents. Local development uses the same manual path: create your own provider OAuth app, paste client ID/secret into Second settings, and connect your account. The local secret adapter encrypts OAuth secrets with `SECOND_TOKEN_ENCRYPTION_KEY` or a generated gitignored key in `.second-dev/`; changing OAuth client credentials in the UI does not require restarting the service. When manually testing OAuth in local development, run Second on a plain loopback origin instead of the portless `*.second.localhost` dev URL: ```bash theme={null} SECOND_DEV_PORTLESS=0 PORT=4198 npm run dev ``` Then register the exact redirect URI shown in Second, for example `http://localhost:4198/api/oauth/callback`. Providers such as Google only grant special HTTP redirect handling to loopback hosts like `localhost` or `127.0.0.1`; they do not treat generated `*.second.localhost` hosts as loopback OAuth redirect URIs. Portless is only a developer convenience for `npm run dev`. It is not used by `npm run start`, `npm run release`, on-prem deployments, or the packaged `npx --yes @second-inc/cli` local runtime. The CLI should use the same plain loopback shape for OAuth-capable local runs. ## Source control Workspace source control connects Second to a repository provider such as GitHub, GitLab, Bitbucket, or self-hosted source control. Admins configure the provider owner or organization from Settings -> Source Control. For the full source storage, app-level publish, Available Apps, preview/cache, and worker restore model, see [Source Control](/source-control). Second stores the token through the same secret storage boundary used for OAuth secrets: WorkOS Vault when configured, otherwise the encrypted local secret store. The token is not returned to the browser, worker, audit metadata, or realtime events. Connecting source control does not upload apps by itself. In on-prem or managed deployments, turn on Store app source in source control to make successful builds commit app source to the configured provider after `done_building`. Existing Mongo-backed apps are adopted the next time a successful build creates a new snapshot. Local CLI/desktop installs still use app-level Publish to source control. App viewing stays fast. The app page renders the materialized built artifact from the saved snapshot/cache; it does not download from source control or compile on page load. For source-control-backed apps, ephemeral worker/container restore uses the configured provider only when the live workspace is gone and a new agent turn needs source files. Restored files are cached back into MongoDB for preview and file explorer reads. Available Apps is separate from source storage. Storage-only repos created by the workspace source storage policy do not need to appear in Available Apps. When the provider uses personal access tokens, prefer a fine-grained token with Metadata read, Contents read/write, and Administration write for the owner that will hold app repositories. In GitHub's fine-grained token UI, choose All repositories for normal Second-managed source storage, then add only repository Administration read/write and Contents read/write. Prefer private repositories and rotate expiring tokens. ## Running with Docker Compose **Option A** — build from source: ```bash theme={null} ANTHROPIC_API_KEY=sk-ant-... npm run start ``` **Option B** — use prebuilt images: ```bash theme={null} SECOND_WEB_IMAGE=ghcr.io//: \ ANTHROPIC_API_KEY=sk-ant-... \ npm run release ``` Both options start all four services: MongoDB, Redis, the worker, and the web app. ## Why `WORKER_URL` is required The Next.js web server calls the worker over HTTP for agent operations and live workspace reads (for example `/sessions/:appId/messages`, `/sessions/:appId/status`, `/sessions/:appId/files`). `WORKER_URL` must resolve from the web runtime to the worker runtime over your private network. ## Architecture in production ``` Internet → Load Balancer / Reverse Proxy → Web (port 3000) ├─ → Worker (port 3001, internal) ├─ → MongoDB (internal) └─ → Redis (internal) ``` Only the web app needs to be exposed publicly. The worker, MongoDB, and Redis should be on an internal network. ## Capacity and scaling The application code is designed so the web tier can scale horizontally behind a load balancer: durable state lives in MongoDB, live coordination lives in Redis, and every route still authorizes by workspace before returning data. Workspace realtime uses one workspace event subscription per browser profile when BroadcastChannel/Web Locks are available, and settings pages use projected read models instead of loading large app source snapshots on navigation. The shipped local/Docker Compose setup does not autoscale. If you deploy to Kubernetes, node autoscaling and pod autoscaling are separate concerns. Managed clusters such as GKE Autopilot can add nodes for schedulable pod requests, but they do not automatically create more web pods unless your deployment defines more replicas or an HPA/KEDA policy. A single web pod may handle small teams, but production deployments should set explicit web replicas or autoscaling targets based on request latency, CPU, memory, and streaming connection count. Worker scaling needs more care than web scaling. The worker keeps active agent SDK sessions in memory, while durable run messages and source snapshots are saved through the web layer. Additional worker replicas can improve capacity, but active sessions, workspace filesystem persistence, and load-balancer routing need to be planned for the deployment model. ## Security notes * Never use `SECOND_AUTH_MODE=none` on the public internet. See [Authentication](/authentication) for details on external mode. * Production collaboration depends on the external provider mapping accepted invitations into Second's `users`, `workspace_memberships`, and default `General` team membership. Unknown external roles should not grant elevated access. * `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `INTERNAL_API_TOKEN` are secrets — treat them accordingly and never commit them to source control. * The worker's HTTP API (`/sessions/*`) should not be exposed publicly — only the web app should be able to reach it. * The worker's scoped MCP route (`/mcp/*`) is for CLI runtimes only. It requires a per-run bearer token and should still stay on an internal network. * Internal API endpoints (`/api/internal/*`) bypass the browser auth proxy and rely on `INTERNAL_API_TOKEN` for authentication. Missing tokens fail closed in production. Keep these on an internal network. * Make sure your reverse proxy forwards and sanitizes headers correctly (`X-Forwarded-For`, `X-Forwarded-Proto`). ## Deployment hardening boundary This open-source repo owns runtime env/config/tool isolation. It does not define production container images, per-run pod/job isolation, Kubernetes service accounts, seccomp/AppArmor profiles, read-only root filesystems, network egress policy, metadata-server blocking, firewall rules, VPC segmentation, DNS controls, or per-container network observability. Those controls belong to the operator-managed deployment layer. They are recommended for production, especially when enabling OpenCode, because OpenCode permissions are not a strong OS sandbox by themselves. Managed Second deployments can wrap these runtimes in stronger isolated worker environments and expose stricter networking/container overrides as a security feature. # Source Control Source: https://docs.second.so/source-control How Second uses source control as authoritative app source storage while keeping preview fast with a local cache. Source control lets Second store app source in a repository instead of treating MongoDB as the authoritative code store. MongoDB still stores app metadata, run history, audit data, and a materialized snapshot/cache so preview stays fast. For source-control-backed apps, the code source of truth is the repository. Supported providers include GitHub, GitLab, Bitbucket, and self-hosted source control in enterprise deployments. ## Mental model There are three separate concepts: | Concept | Meaning | | ------------------------- | -------------------------------------------------------------------------------------------------------------- | | Source-control connection | Workspace credentials and target owner exist. Nothing is uploaded just because this exists. | | Source storage policy | On-prem/managed setting that makes successful builds write app source to source control after `done_building`. | | Available Apps | Optional discovery/install layer for apps intentionally shared with local CLI/desktop users. | Connecting source control only enables the feature. It does not upload apps. In local CLI/desktop mode, source-control storage is app-level: a builder turns on Publish to source control for a specific app from the app top bar. In on-prem or managed deployments, admins can turn on Store app source in source control in Settings -> Source Control. When that workspace-level storage policy is on, successful `done_building` snapshots are committed to the configured provider automatically. Existing Mongo-only apps are adopted the next time a successful build creates a new app snapshot. Available Apps is separate. A source-control-backed app can be stored in a repository without being listed as an Available App. ## What gets loaded from where App viewing and agent source restore are different paths. The app page must be fast. It renders the built artifact from the saved snapshot/cache. It does not download source from source control and it does not compile on page load. Agent restore is different. If a worker/container/local session is still alive, Second keeps using the live files already on disk. If the session died and the app is source-control-backed, Second restores source from source control, then caches that restored snapshot back into MongoDB. | Deployment | Source control | App preview/page | Agent files when session is alive | Agent files when restore is needed | | ----------------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------- | ------------------------------------ | | Local CLI/desktop | Off | Mongo snapshot is authoritative. | Live local worker files. | Mongo snapshot. | | Local CLI/desktop | On for that app | Source control is authoritative. Render the cached built artifact for the selected repository version. | Live local worker files. | Source control, then cache in Mongo. | | On-prem/cloud | Off | Mongo snapshot is authoritative. | Live container files. | Mongo snapshot. | | On-prem/cloud | Workspace source storage on | Source control is authoritative. Render the cached built artifact for the selected repository version. | Live container files. | Source control, then cache in Mongo. | The important rule: * App preview/page = saved built artifact/cache. * Source restore for a dead session = source control when that app is source-control-backed. * If the session is still alive, no restore is needed. * If the app is not source-control-backed, Mongo remains authoritative. MongoDB is still used in source-control mode, but it is a materialized cache for fast app rendering and recovery. It is not the source of truth for a source-control-backed app. ## Source storage modes ### Local CLI/desktop Local installs use app-level opt-in. The app top bar shows Publish to source control only when: * workspace source control is connected, * the user can edit the app, * the user is looking at the draft app. The first publish takes the current app state from live worker files when available, otherwise from the saved Mongo snapshot. Second then: 1. Creates a repository if the app does not already have one. 2. Writes the sanitized app files. 3. Writes root `second-app.json`. 4. Best-effort adds the `second-app` repository topic. 5. Commits the snapshot. 6. Creates `second-app-v1`. 7. Stores compact source-control metadata on the app document. Apps that are not published stay local. `done_building` continues to save the snapshot locally, but it does not create a repo, commit, or tag. ### On-prem and managed deployments On-prem and managed deployments can use a workspace-level storage policy: Store app source in source control. When this setting is off: * MongoDB is authoritative for app source. * `done_building` saves snapshots as it does today. * The source-control connection exists only for explicit features that use it. When this setting is on: * successful `done_building` saves the local snapshot/cache first, * then commits the sanitized app source to the configured provider, * creates or updates the app's repository, * creates an auto-bumped `second-app-v` tag when source changed, * marks source control as the authoritative app source. This does not automatically list the app in Available Apps. Storage and distribution are separate. ## Builds and versions `done_building` remains the build gate. The worker runs the build, requires `dist/index.html`, collects the snapshot, and returns the successful build summary. The web route saves that snapshot first. Only after the snapshot is saved does Second try to sync to source control. Versioning is automatic: * First source-control-backed snapshot creates `second-app-v1`. * Each later successful build with changed source creates the next `second-app-v` tag. * If the source hash did not change, Second does not create a duplicate version. * Tag messages use the successful `done_building` summary. * If source-control sync fails after local save, the app remains usable locally and the app source-control status shows the failure with a retry path. ## Repository shape A source-control-backed app repository contains: * generated app source files, * the built `dist/**` output from the successful build snapshot, * root `second-app.json`. The manifest makes the repository self-describing: ```json theme={null} { "type": "second.app.export.v1", "schemaVersion": 1, "app": { "name": "Customer Console" }, "source": { "fileCount": 42, "totalBytes": 812345, "hash": "sha256:..." }, "sourceControl": { "provider": "github", "owner": "acme", "repo": "second-app-customer-console", "tag": "second-app-v12", "version": 12, "commitSha": "...", "availableInCatalog": false } } ``` The repository must not contain secrets or local runtime state. Source-control sync uses the same app-bundle filters that exclude unsafe files such as `.env`, `.npmrc`, `.git`, `node_modules`, local caches, and other non-app artifacts. ## Available Apps Local CLI/desktop users can open Available Apps from the workspace sidebar. The page reads the configured source-control owner and lists repositories that contain a valid root `second-app.json` and are marked as available in the manifest. The repository topic `second-app` speeds up discovery, but the manifest is the authority. Actions: | Action | Behavior | | ------ | ----------------------------------------------------------------------------------------------------------------------- | | Get | Downloads the selected repository archive server-side, imports it as a local app, and records `installedFrom` metadata. | | Update | Downloads the newer upstream version and updates the existing installed app from the same owner/repo. | | Open | Opens an already installed local copy. | Installing from Available Apps creates a local copy. It does not turn on Publish to source control for that app. The app can still be published later, but that remains an explicit app-level action. Storage-only repos created by the on-prem workspace source storage policy are not listed in Available Apps unless a future sharing policy marks them discoverable. ## Provider connection Owners/admins configure source control from Settings -> Source Control. Enterprise deployments can use supported providers such as GitHub, GitLab, Bitbucket, or self-hosted source control. When the provider uses personal access tokens, prefer a fine-grained token owned by the user or organization that will hold app repositories. Recommended permissions: | Permission | Why | | --------------------- | ------------------------------------------------------------ | | Metadata: read | Validate and discover repositories. | | Contents: read/write | Read manifests, commit app snapshots, and download archives. | | Administration: write | Create repositories and manage repository topics. | For GitHub fine-grained tokens, use the GitHub UI like this: * Resource owner: the user or organization that will own app repositories. * Repository access: choose All repositories for normal Second-managed source storage, because new app repositories are created over time. * Add permissions: stay on the Repositories tab and add Administration read/write plus Contents read/write. Classic PAT fallback: * `repo` for private repositories. * `public_repo` only for explicitly public app repositories. Repository visibility defaults to private. User-owned repositories must be owned by the authenticated provider account. For organization-owned repositories, configure the organization owner. Repo name prefix is optional. When it is blank, new repositories use `second-app-`, for example `second-app-customer-console`. When a prefix is configured, new repositories use `-`. ## Secret handling The PAT is stored only through Second's server-side secret store: * WorkOS Vault when configured, * encrypted local storage otherwise. The token value is never returned to: * the browser, * the worker, * agent runtimes, * realtime events, * audit metadata, * logs. Provider errors are normalized and redacted before they are shown to the user or stored on app metadata. ## Tenant isolation Source-control records are workspace-scoped. Every query includes `workspaceId`. Install, update, publish, and restore routes prove workspace/app access before mutating app files. GET/read paths do not create repos, sync snapshots, restore files, or write audit events. Provider calls that mutate state happen only from explicit mutation paths such as settings save, app publish, post-build sync, Available Apps install/update, or worker/session restore. Realtime events remain compact invalidation hints. They do not include source files, prompts, provider responses, tokens, cookies, headers, or full database documents. ## Related pages * [App Preview](/app-preview): build artifacts, iframe rendering, and restore boundaries * [App Governance](/app-governance): draft/published snapshots and review flow * [Self-hosting](/self-hosting): deployment and secret-store setup * [Guard and Tenancy](/guard-and-tenancy): workspace isolation and route guards # Streaming Source: https://docs.second.so/streaming How agent responses stream from the worker to the browser — protocols, translation, persistence, and multi-client resume. Agent responses stream in real time from the agent worker through two hops before reaching the browser. Each hop uses a different protocol. The architecture is **runtime-agnostic** for the browser: Claude Code, Codex CLI, and OpenCode all normalize to the same AI SDK UIMessageStream parts before rendering. ## The two-hop architecture ``` Agent Worker → Worker SSE → Next.js Bridge → AI SDK UIMessageStream → Browser (useChat) ``` **Hop 1: Worker → Next.js** — Runtime events serialized as SSE. The worker does not know about the Vercel AI SDK. Claude emits Claude SDK messages directly; Codex emits app-server notifications over stdio JSON-RPC; OpenCode emits JSON events. The worker normalizes all of them into the same worker message shape. **Hop 2: Next.js → Browser** — AI SDK UIMessageStream protocol. The browser doesn't know which provider generated the events. This is the **abstraction boundary** — everything downstream of the bridge is provider-agnostic. This architecture serves both the **builder agent** (chat-based) and **app agents** (triggered from within apps). App agents use the same bridge translation and UIMessageStream protocol. The difference is in the worker endpoint: builder agents use `POST /sessions/:appId/messages`, while app agents use `POST /sessions/:appId/agent-run` with background execution via `AgentRunManager`. See [App Agents](/app-agents) for the full app agent flow. ### What's provider-specific vs. provider-agnostic | Layer | Provider-specific? | Notes | | ------------------------------ | ------------------ | ----------------------------------------------------------------------------- | | Worker runtime adapter | Yes | Each runtime has its own launch/config/session behavior | | Worker SSE format (Hop 1) | Mostly no | Adapters normalize Claude/Codex/OpenCode into canonical worker messages | | Bridge (`worker-bridge.ts`) | Yes | Translates provider events → UIMessageStream chunks | | Session persistence (JSONL) | Yes | Claude uses `~/.claude/projects/` JSONL files. Codex will have its own format | | UIMessageStream (Hop 2) | **No** | Same protocol regardless of provider | | Redis resumable/replay streams | **No** | Operates on UIMessageStream SSE, not provider events | | MongoDB persistence | **No** | Stores `UIMessage[]` — provider-agnostic | | Frontend (`useChat`) | **No** | Renders `UIMessage` parts — doesn't know the provider | New runtimes should keep this boundary: absorb event/session/tool-name differences in the worker adapter or bridge, not in individual UI components. ## Runtime normalization Codex CLI runs with `codex app-server --listen stdio://`; OpenCode runs with `opencode run --format json`. Their events are parsed by the worker and mapped to canonical tool names before reaching the UI: | Runtime event/tool | Canonical UI tool | | -------------------------------- | ---------------------------------------------------------------- | | Second MCP `present_plan` | `mcp__second__present_plan` | | Second MCP `present_agents` | `mcp__second__present_agents` | | Second MCP `done_building` | `mcp__second__done_building` | | App custom tools | `mcp__app_tools__` | | App data tools | `mcp__app_data__update_app_data`, `mcp__app_data__read_app_data` | | Shell/command tools | `Bash` | | File edits/writes/reads/searches | `Edit`, `Write`, `Read`, `Glob`, `Grep` | | Web tools | `WebFetch`, `WebSearch` | Unknown runtime events are ignored or summarized instead of crashing the stream. Codex app-server exposes file edits and web research as native item types rather than Claude-style tool names. It does not expose a standalone Claude-style `Write` tool; when Codex edits through its patch/file-edit path, the adapter maps `fileChange` items into `Write` or `Edit` and preserves the full `{path, kind, diff}` change list so the UI can render single-file and multi-file patch cards. Builder prompts tell Codex to prefer `apply_patch` for file creation and edits so Second gets structured file-change cards instead of plain Bash cards from shell redirection. Codex `commandExecution` output deltas stream as preliminary `tool-output-available` chunks for the existing `Bash` tool card, while `fileChange` output deltas are treated as underlying patch-tool stdout and are not surfaced as assistant text. Codex starts `webSearch` items before the query is known, so the adapter waits for the completed `webSearch` item, maps `search` actions into `WebSearch`, maps `openPage`/`findInPage` actions into `WebFetch`, and immediately resolves the search card once Codex reports the search action. Later opened page URLs and source URLs in the final assistant text are emitted as follow-up `tool-output-available` updates for the same `toolCallId`, which enriches the completed `WebSearch` card with source chips without keeping the loader active for the whole assistant answer. Codex MCP tool outputs may arrive wrapped as `{ content: [{ type: "text", text: "..." }] }`, so approval-stop detection unwraps that envelope before checking structured `ok` / `status` fields. Codex MCP tool calls still arrive only after the tool arguments are complete, so Second can render the plan card as soon as Codex starts the `present_plan` call, but Codex does not currently provide partial MCP argument deltas for the plan fields themselves. In local development, Codex builder sessions keep a `codex app-server --listen stdio://` process warm per app/runtime session for up to 10 minutes of idle time. The warm process is initialized before the worker stream starts and is reused for later builder messages. Production and app-agent runs keep the one-process-per-turn behavior, and local warming can be disabled with `SECOND_CODEX_APP_SERVER_WARM=0`. Set `SECOND_CODEX_TRACE=1` on both the worker and web server when debugging Codex tool rendering. The worker logs sanitized Codex app-server notifications and the synthetic worker SSE messages it emits. The web server logs the received worker messages and the AI SDK tool chunks it writes. The trace intentionally records ids, statuses, file paths, diff line counts, output sizes, and timing; it does not log full prompts, full command output, file contents, or unified diff bodies. For Codex remote MCP tools, app-server emits `mcpServer/elicitation/request` before the actual `tools/call`. The worker adapter accepts only brokered MCP tool-call approval elicitations for allowlisted `mcp__second__*`, `mcp__app_tools__*`, and `mcp__app_data__*` tools; all other MCP elicitations are declined. This keeps `done_building` and the approval cards callable without treating arbitrary MCP prompts as trusted user input. ## Worker SSE format (Hop 1) — Claude The worker streams raw Claude SDK messages as JSON, one per SSE `data:` line: ``` data: {"type":"system","subtype":"init","session_id":"abc123"} data: {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}} data: {"type":"stream_event","event":{"type":"content_block_start","content_block":{"type":"tool_use","name":"Bash","id":"tc_1"}}} data: {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\"command\":\"ls\"}"}}} data: {"type":"stream_event","event":{"type":"content_block_stop"}} data: {"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tc_1","content":"file1.ts\nfile2.ts"}]}} data: {"type":"assistant","message":{"content":[...]}} data: {"type":"result","result":"Done."} data: [DONE] ``` ### SDK message types | Type | When it fires | | ------------------------- | ------------------------------------------------------------ | | `system` (subtype `init`) | Once at session start — contains `session_id` | | `stream_event` | During streaming — wraps raw Anthropic API stream events | | `assistant` | After each assistant turn completes — contains full message | | `user` | After tool execution — contains `tool_result` content blocks | | `result` | When the agent finishes — contains total cost, usage stats | ## AI SDK UIMessageStream format (Hop 2) — all providers The bridge translates provider-specific events into the [Vercel AI SDK UIMessageStream protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol): ``` data: {"type":"text-start","id":"txt_1"} data: {"type":"text-delta","id":"txt_1","delta":"Hello, "} data: {"type":"text-delta","id":"txt_1","delta":"let me check."} data: {"type":"text-end","id":"txt_1"} data: {"type":"tool-input-start","toolCallId":"tc_1","toolName":"Bash","dynamic":true} data: {"type":"tool-input-delta","toolCallId":"tc_1","inputTextDelta":"{\"command\":\"ls\"}"} data: {"type":"tool-input-available","toolCallId":"tc_1","toolName":"Bash","input":{"command":"ls"},"dynamic":true} data: {"type":"tool-output-available","toolCallId":"tc_1","output":"file1.ts\nfile2.ts","dynamic":true} data: {"type":"finish"} data: [DONE] ``` ### Claude bridge translation rules | Claude SDK event | AI SDK chunk | | -------------------------------------------------------- | -------------------------------------------------------- | | `content_block_start` + `thinking` / `redacted_thinking` | Closes any open text block | | `content_block_delta` + `thinking_delta` | `reasoning-start` (first time) + `reasoning-delta` | | `content_block_stop` (thinking / redacted thinking) | `reasoning-end` | | `content_block_start` + `text` | Closes any open reasoning block | | `content_block_delta` + `text_delta` | `text-start` (first time) + `text-delta` | | `content_block_stop` (text) | `text-end` | | `content_block_start` + `tool_use` | Closes text + reasoning, then `tool-input-start` | | `content_block_delta` + `input_json_delta` | `tool-input-delta` | | `content_block_stop` (tool) | `tool-input-available` | | `user` message with `tool_result` | `tool-output-available` (only for tracked tools) | | `message_start` (new turn) | Flushes any remaining pending tool outputs | | `[DONE]` | Closes open text/reasoning blocks, flushes pending tools | Content blocks are properly tracked by type and index. Each `content_block_start` closes the previous block's open parts (text or reasoning), and each `content_block_stop` finalizes the current block. This prevents overlapping parts in the UIMessageStream. **Thinking mode handling:** When thinking is set to `enabled`, the SDK may not emit `stream_event` messages for thinking blocks. The bridge has a fallback path: if no `stream_event` messages were received for a turn, it processes the complete `assistant` message and emits reasoning blocks from `thinking` content blocks. When thinking is `adaptive`, the model decides when and how much to think. Opus 4.8 is requested with summarized thinking display; if the SDK emits only `thinking_tokens` progress before the summary text arrives, the bridge opens a reasoning block with a small live placeholder instead of leaving the chat visually idle. ### Dynamic tool parts All tool chunks include `dynamic: true`. This tells the AI SDK to create `dynamic-tool` parts (rather than typed `tool-{name}` parts), since the agent's tools are not known at compile time. ## Builder run lifecycle Builder runs move through a small explicit state machine: | Status | Meaning | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pending` | Run exists in MongoDB, but no worker query has been claimed yet | | `streaming` | One chat POST claimed the run and is responsible for the worker query + persistence | | `completed` | Final `UIMessage[]` was persisted, `activeStreamId` was cleared, and the turn either reached a valid approval stop or any builder implementation that wrote files reached a successful `done_building` snapshot | | `failed` | Worker stream failed, a stale stream was recovered, the user stopped the run, or a builder implementation stopped outside an approval gate before a successful `done_building` snapshot | The first `POST .../chat` for a pending run calls `startRunStream()` with `{ workspaceId, appId, runId }`. That update is atomic and only succeeds when the run is pending, or when a completed/failed run is being extended with a longer message list. If a second tab, route remount, or back/forward navigation sends the same initial POST while the first request is still initializing the sandbox, the duplicate POST returns an empty successful stream and does not start another worker query. If stale browser history sends an old message list for a completed run, the claim is rejected so persisted conversation history is not overwritten. Once the browser-facing stream exists, `consumeSseStream` registers it in Redis and saves `activeStreamId` on the run. At that point other tabs can resume the live stream. The chat route also captures UI stream chunks into a Redis replay buffer with ordered sequence numbers and a terminal marker. Before `activeStreamId` exists, reconnecting tabs wait briefly for stream-ready or terminal run events, then fall back to bounded polling rather than starting a duplicate worker query. Provider-native session state is best-effort. Claude stores a JSONL session file snapshot that can be restored after worker churn. Codex CLI and OpenCode session ids depend on runtime-local state in the worker pod, so after a pod restart the chat route does not treat those ids as covering the persisted Second transcript. It sends a bounded transcript handoff plus restored source files instead. If Codex still reports that a stored thread/rollout is missing, the worker starts a fresh Codex thread and continues the same Second run rather than surfacing the native resume error to the user. ## Frontend integration The chat UI uses `useChat` from `@ai-sdk/react` with a `DefaultChatTransport` pointing at the chat API route: ```typescript theme={null} import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; const { messages, sendMessage, status, resumeStream } = useChat({ transport: new DefaultChatTransport({ api: `/api/workspaces/${workspaceId}/apps/${appId}/runs/${runId}/chat`, prepareReconnectToStreamRequest: ({ api }) => ({ api: `${api}/stream` }), }), messages: initialMessages, }); ``` ### Resume behavior `AppChat` uses a dedicated `useRunSync` hook as the single resume orchestrator. For a brand-new run, the server creates the run as `pending`, and the first mounted `AppChat` sends the initial prompt. Before auto-sending, `AppChat` fetches the current run state with `no-store` and only sends when the server still reports `pending` with zero persisted messages. If browser back/forward restores stale route props, the client hydrates from the server instead of replaying the first prompt. For an already-streaming run, `useRunSync` attaches to the active Redis stream instead of sending a new message. The hook calls `resumeStream()` for: 1. Workspace realtime run events (`run.starting`, `run.stream_ready`) 2. Initial page load when the run is already streaming **and** persisted messages already exist (`initialMessages.length > 0`) 3. Browser back/forward restores where a no-store status check finds a streaming run even if the route props were stale This avoids overlapping resume requests from multiple code paths. If more than one `resumeStream()` call overlaps on the same `useChat` instance, the AI SDK can duplicate assistant content or throw runtime errors. When `resumeStream()` reconnects to `GET .../chat/stream`, buffered content appears instantly. If replay chunks exist, the stream endpoint uses the Redis replay buffer first and follows new chunks live; otherwise it resumes the active Redis resumable stream. New content after catch-up streams live. If the stream endpoint returns `204` while the run still reports `streaming`, the tab does not treat that as terminal. This can happen in production during the small window after the POST claimed the run but before the stream is attachable. The endpoint waits briefly on Redis run events before returning `204`; the client fallback polls `GET .../chat` for snapshots and periodically retries `resumeStream()` until it attaches or the run completes. Chat POST streams are deliberately not aborted on React unmount. Navigating away closes observer connections, but the authoritative POST is allowed to finish so `onFinish` can persist messages and clear the active stream. The Stop button still aborts intentionally. ### Multi-tab message sync When Tab A sends a message, Tab B (same app/run) sees the new user message and streaming response in real time — no page reload required. This works via Redis pub/sub pushed over SSE: ``` POST /chat handler → workspace event publish → shared workspace SSE → useRunSync hook → setMessages + resumeStream/replay ``` **How it works:** 1. `WorkspaceRealtimeProvider` owns one shared `GET /api/workspaces/[workspaceId]/events` SSE connection around the workspace shell. The connection is shared across tabs with `BroadcastChannel` and Web Locks. 2. Builder run repository updates publish compact workspace events: `run.starting`, `run.stream_ready`, `run.completed`, and `run.failed`. The payload contains ids and status only, never prompts, source files, secrets, or full messages. 3. On connect or reconnect, the workspace events endpoint emits compact catch-up events for currently streaming runs so mounted tabs can recover if they missed the original publish. 4. `AppChat` owns `useChat`; `useRunSync` listens to the workspace realtime provider for events matching its `{ workspaceId, appId, runId }`. 5. When a tab's `useRunSync` hook receives `run.starting` or `run.stream_ready` **and** `useChat` status is `"ready"` (not already streaming/submitted), it: * Fetches the latest messages from `GET .../chat` * Calls `setMessages()` to update `useChat`'s state in-place (no component remount) * Calls `resumeStream()` to reconnect to the live Redis stream 6. When Tab B receives `run.completed` or `run.failed` (and is not already streaming), it fetches final messages and calls `setMessages()` to display the complete conversation. 7. When streaming ends after a sync-triggered resume, a final fetch ensures messages are complete (covers the case where `completed` was skipped because Tab B was mid-resume). Events from the tab's own activity are ignored: if `useChat` status is `"streaming"` or `"submitted"`, the sync hook skips event-driven `setMessages`/resume work. ### Race-condition hardening Recent fixes added explicit guards in `AppChat` + `useRunSync`: * **Pending-to-streaming claim**: the server atomically claims a run before talking to the worker, so remounts during sandbox initialization cannot start a second worker query. * **Duplicate POST no-op**: if a run is already streaming, the chat POST returns an empty successful stream instead of failing the UI or starting another query. * **Single resume owner**: `useChat` no longer auto-resumes on mount via `resume: true`; `useRunSync` owns resume flow. * **Sender guard**: local send paths set `statusRef.current = "submitted"` before calling `sendMessage(...)`, preventing run-event handlers from racing the sender tab before React state commits. * **No clobber during local send**: sync `setMessages(...)` updates are ignored while local status is `"submitted"`/`"streaming"`, so optimistic local user messages are not overwritten by stale server snapshots. * **Initial prompt preflight**: brand-new app pages verify the run is still `pending` and empty before auto-sending the stored app prompt. Cancelled preflight checks release their in-memory guard so route transitions and browser history can retry safely. * **Stale history POST guard**: completed/failed runs can only be re-claimed when the posted message list is longer than the persisted one, so stale back/forward requests cannot replace a full conversation with the first message. * **Initial load guard for new runs**: initial live sync runs only when `runStatus === "streaming"` and `initialMessages.length > 0`, preventing false "connecting" state on brand-new runs. * **Back/forward status check**: restored app pages do a delayed no-store status read. If the server says the run is already streaming, the page hydrates the latest snapshot and enters the live-sync path even when route props were stale. * **Resume retry after 204**: a `204` from `GET .../chat/stream` means "not attachable yet", not "done". While the run remains `streaming`, the polling fallback keeps retrying the real stream attach so browser forward does not wait for final persistence. * **Replay buffer fallback**: UI stream chunks are captured in Redis with ordered sequence numbers and terminal state. A reconnecting tab can catch up from replay and then follow live chunks even if the resumable-stream instance is unavailable. * **Unmount-safe POST**: route changes do not abort the active chat POST, so browser back/forward can reconnect to the same run instead of terminating it. * **Optimistic sidebar app entry**: app creation updates the mounted sidebar via a local event before navigation. This avoids a post-navigation `router.refresh()` that could interrupt first-mount chat initialization. * **Shared events connection**: workspace lifecycle events and app data streams are shared across tabs with `BroadcastChannel` + Web Locks when available, so many tabs do not exhaust the browser's per-origin HTTP connection budget. * **Interruptible rendering**: message rendering uses deferred values and throttled stream updates so navigation remains responsive during long streams. ### Rendering Messages are rendered by iterating `msg.parts` and switching on `part.type`. Each part type maps to a dedicated component in `components/ai-elements/`: | Part type | Component | Rendered as | | --------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `text` | `react-markdown` + `CodeBlock` | Markdown with GFM tables, syntax-highlighted code blocks (via `sugar-high`), language icons, copy buttons. Light/dark theme aware. | | `reasoning` | `Reasoning` | Collapsible block with brain icon — shows `Reasoning...` while the part is streaming and `Done reasoning` once the part is closed | | `dynamic-tool` (`mcp__second__present_plan`) | `PlanCard` | Interactive card showing the build plan with Approve & Build / Request Changes buttons | | `dynamic-tool` (`mcp__second__present_integration_setup`) | `IntegrationSetupCard` | Compact setup-instructions card that opens a dialog with required secrets, permission groups, exact permissions, and verified setup links | | `dynamic-tool` (Bash) | `Terminal` or `ToolCard` | Mutating or arbitrary commands render as the macOS-style terminal. Read-only shell wrappers such as `cat`, `sed`, `ls`, `find`, `rg --files`, and simple compound `/bin/zsh -lc` probes are visually translated into `Read`, `List`, or `Grep` cards. | | `dynamic-tool` (Write, Edit) | `ToolCard` | Collapsible one-liner: file icon + filename + "Created"/"Edited" + colored `+N -N` diff stats. Expands to GitHub-style diff view | | `dynamic-tool` (Read, List, Glob, Grep) | `ToolCard` | One-liner: file/search icon + filename/path/pattern + status | | `dynamic-tool` (WebSearch) | `ToolCard` | Search query + source chips with favicons. fewer than 3 results inline, 3+ collapsible with stacked favicon circles | | `dynamic-tool` (WebFetch) | `ToolCard` | Hostname + "Fetched" + clickable source chip with favicon | | `dynamic-tool` (`mcp__app_tools__*`) | `CustomToolCard` | Integration favicon + action display name, then expandable formatted input/output payloads | | `dynamic-tool` (other) | Inline card | Tool name, input summary, running/done state | ### AI Element components Located in `components/ai-elements/`. These are composable React components that render the different `UIMessagePart` types from the AI SDK. They are **not** part of the AI SDK itself — they're custom UI built on top of the standard `UIMessage` data structure. #### `code-block.tsx` Drop-in `code` component for `react-markdown`. Detects fenced code blocks (has `language-*` className) and renders them with: * Composer-style card (`--composer-bg`, `--composer-shadow`, `rounded-2xl`) * Language icon per file type (terminal for bash/sh, braces for JS/TS, JSON icon, globe for HTML, etc.) + language label * Syntax highlighting via `sugar-high` (3KB, zero-dep, no async/WASM) * Copy-to-clipboard button with hover state * Light/dark theme aware — uses `--sh-*` CSS variables from `globals.css` Inline code renders as a plain `` with muted background. The `prose` wrapper disables Tailwind Typography's decorative backtick pseudo-elements (`prose-code:before:content-none prose-code:after:content-none`). #### `reasoning.tsx` Collapsible reasoning/thinking block built on Radix `Collapsible`. Manages its own open/close state: * **Auto-opens** when `isStreaming` becomes true * **Uses part state** from the AI SDK to label active vs finished reasoning * **Falls back to message position** when older persisted messages do not have a precise reasoning state * User can manually toggle at any time Uses a context pattern (`ReasoningContext`) so `ReasoningTrigger` and `ReasoningContent` can access streaming state without prop drilling. #### `plan-card.tsx` Interactive build plan card rendered when the agent calls the `present_plan` custom tool. Uses the composer card style (`--composer-bg`, `--composer-shadow`, `rounded-2xl`) with a gradient swoosh + glow animation when the plan is ready. Sections: * **Overview** — high-level summary paragraph * **Main Features** — flat list with name + description per feature * **Data Flow** — how data moves through the app * **Agents / Backend** — side-by-side sections, showing "Not available" badge when null * **Actions** — "Approve & Build" and "Request Changes" buttons (always visible, disabled during streaming). Skeleton placeholders shown while tool input streams. #### `terminal.tsx` macOS-style terminal renderer for Bash tool calls. Uses the composer card style. Displays: * Traffic light dots (red/yellow/green) in the header * Command with `$` prefix in green (emerald for light mode, green-400 for dark) * Scrollable output area (max 192px) * Green checkmark when done, spinner while running, copy button next to command * Light/dark theme aware — white bg in light mode, dark in dark mode #### `tool-card.tsx` Compact one-liner cards for file and web tools, styled to match the reasoning block (same `text-sm`, `size-4` icons). Each tool type has a dedicated icon and status text: * **Write** — `FilePlusIcon` + filename + "Created" + green `+N` stats. Collapsible: expands to show a GitHub-style diff (all lines green for new files). * **Edit** — `FilePenLineIcon` + filename + "Edited" + colored `+N -N` stats. Collapsible: expands to show red (deleted) and green (added) lines. Diff is computed from the tool's `old_string`/`new_string` input. * **Read** — `FileSearchIcon` + filename for one file. Multi-file reads show `Read N files` as a closed-by-default collapsible list of the exact file paths. * **List** — `FolderSearchIcon` + folder path for one location. Multi-location lists show `Listed N locations` as a closed-by-default collapsible list of the exact paths. * **Glob** — `FolderSearchIcon` + pattern. Simple one-liner. * **Grep** — `SearchIcon` + pattern. Simple one-liner. * **WebSearch** — `GlobeIcon` + query. With fewer than 3 results: inline source chips with favicons. With 3+ results: stacked overlapping favicon circles + "N sources", collapsible to show all source chips. * **WebFetch** — `GlobeIcon` + hostname + "Fetched" + clickable source chip with favicon. #### `custom-tool-card.tsx` Dedicated renderer for app-agent custom HTTP tools (`mcp__app_tools__*`). It uses metadata from `agents.json` when available: * Integration name and favicon from `tool.integration` * Action label from `tool.displayName`, falling back to a title-cased tool name * HTTP method and endpoint host from `tool.endpoint` * Expandable **Input** and **Output** panels with parsed JSON formatting. If the worker returns mock data or an error preface before a JSON payload, the card keeps the note and formats the payload separately. Source chips are rounded-full pills with Google favicon, truncated title, and external link icon. Favicons fetched from `google.com/s2/favicons`. #### Architecture The rendering flow is: ``` useChat → messages[].parts[] → part.type switch → AI Element component ``` Each `UIMessagePart` has a `type` field that determines which component renders it. The mapping happens in `app-chat.tsx`'s message rendering loop. Adding a new part type renderer is just another `if (part.type === "...")` branch with a new component. ### Scroll behavior The chat uses `use-stick-to-bottom` (same library as the reference app) for automatic scroll management during streaming. The layout uses absolute positioning: ``` ┌─ relative container (flex-1) ──────────────┐ │ ┌─ absolute inset-0 ────────────────────┐ │ │ │ StickToBottom (messages, pb-48) │ │ │ └───────────────────────────────────────┘ │ │ ┌─ absolute bottom-0 z-20 ─────────────┐ │ │ │ Composer input (pointer-events-auto) │ │ │ └───────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ ``` The message area has `pb-48` bottom padding so content doesn't hide behind the input overlay. A `h-4 bg-background` separator hides the scroll edge. ## API routes ### `POST /api/workspaces/[workspaceId]/apps/[appId]/runs/[runId]/chat` Sends a message to the agent. Returns a UIMessageStream SSE response. 1. Authenticates the request and loads the app + run by `{ workspaceId, appId, runId }`. 2. Atomically marks the run as `streaming`. The claim succeeds only for pending runs or legitimate follow-up messages with a longer message list. If the claim fails because another request already started the run, or because a stale browser history request posted old messages, returns an empty successful stream. 3. Creates a `UIMessageStream` with the bridge in the `execute` callback. 4. The SSE stream is tee'd via `consumeSseStream`, registered in Redis, saved to the run as `activeStreamId`, and captured into the run replay buffer. 5. After the bridge finishes, fetches the session file from the worker and saves it to MongoDB for cross-container resume. 6. On finish, persists the final messages to MongoDB via `completeRun`. ### `GET /api/workspaces/[workspaceId]/apps/[appId]/runs/[runId]/chat` Returns the persisted chat history as JSON. Used for loading existing conversations. ### `GET .../chat/stream` Resume endpoint for in-flight streams. Uses a Redis replay buffer when available and falls back to Redis-backed resumable streams (`resumable-stream` library) to reconnect to an active SSE stream. 1. Loads the run's `activeStreamId` and `status` from MongoDB. 2. If the run is `streaming` but not attachable yet, waits briefly for Redis run events before deciding. 3. If no active stream or run is completed/failed, returns `204` (no content). 4. If replay chunks exist, returns a replay/follow SSE stream. The optional `cursor` query parameter skips chunks the client already saw. 5. If replay is not available, creates a `ResumableStreamContext` with Redis pub/sub and calls `resumeExistingStream`. 6. Returns the resumed stream as SSE with `x-vercel-ai-ui-message-stream: v1` header. This enables multiple tabs/clients to see the same live stream. ### `GET /api/workspaces/[workspaceId]/events` Workspace sync endpoint. Subscribes to the workspace Redis pub/sub channel and pushes compact workspace events used by sidebar, app chrome, settings, and run observers. `WorkspaceRealtimeProvider` keeps one shared browser connection for this endpoint and fans events out to mounted components in-process and across tabs. On subscribe, the endpoint also emits compact catch-up events for currently streaming builder runs, scoped by `workspaceId`, so reconnecting browsers can resume without opening per-run event streams. Run observers react only to events scoped to their `{ workspaceId, appId, runId }`: | Event | When | Client action | | ------------------ | ------------------------------------------------------------------- | --------------------------------------------------- | | `run.starting` | Run status changed to `"streaming"` before the stream is attachable | Fetch messages, start live-sync fallback | | `run.stream_ready` | `activeStreamId` set to a non-null value | Fetch messages, call `setMessages` + `resumeStream` | | `run.completed` | `status` changed to `"completed"` | Fetch messages, call `setMessages` with final state | | `run.failed` | `status` changed to `"failed"` | Fetch messages, call `setMessages` with error state | The older run-specific `GET .../runs/[runId]/events` endpoint still exists for compatibility and for stream attach coordination, but normal app pages do not open a separate browser `EventSource` for each run. ## Persistence Messages are persisted to MongoDB after the agent finishes each response: 1. Run is created as `pending` with empty messages. 2. User sends a message → chat POST saves the optimistic `UIMessage[]` and marks the run as `streaming`. 3. Agent streams its response → the SSE stream is tee'd via `consumeSseStream`, published to Redis for multi-client resume, and captured in a Redis replay buffer. The `activeStreamId` is saved to the run document. 4. Agent finishes → the bridge fetches provider-aware session state from the worker. 5. `onFinish` saves the latest provider session state under both `sessionState` and `runtimeSessionStates.`, records how many persisted UI messages that native session covers, then `completeRun` saves the full `UIMessage[]` array, clears `activeStreamId`, and marks the run as `"completed"`. On page load, the server component fetches the latest run and passes `initialMessages` to the chat component. `runtimeSessionStates` lets the same run switch between Claude Code, Codex CLI, and OpenCode without losing each runtime's native resume handle. If the selected runtime has not seen the whole Second transcript, the chat route sends a bounded provider-neutral handoff prompt containing the missing persisted UI messages before the latest user message. ## Cross-container resume When the worker's 15-minute TTL expires and the session is destroyed, the next message triggers a full context restore: 1. The chat route loads the selected runtime's entry from `runtimeSessionStates`, falling back to `sessionState` when it belongs to the selected runtime. 2. The session state is passed to the worker request. 3. Claude restores JSONL state when needed; Codex CLI and OpenCode receive their native session IDs when available. 4. The runtime adapter resumes the provider session and streams normalized events. The user sees one continuous conversation. Each runtime gets the strongest resume behavior it supports through the generic `ProviderSessionState` shape. # Worker Source: https://docs.second.so/worker The standalone agent worker — HTTP API, session management, workspace isolation, tool permissions, and SDK integration. The worker is a standalone Node.js HTTP server (`apps/worker/`) that runs AI agent sessions. It's separate from the Next.js app so agent processes don't block web requests. It supports Claude Code, Codex CLI, and OpenCode through a runtime adapter layer. ## Runtime adapters Builder and app-agent runs enter the worker with: ```typescript theme={null} { runtimeId: "claude-code" | "codex-cli" | "opencode", runtimeModel: string, runtimeParams: Record } ``` `SessionManager` dispatches to a runtime adapter: * `claude-code` wraps the existing Claude Agent SDK path. * `codex-cli` launches `codex app-server --listen stdio://` with `approval_policy = "never"` in private config, the selected Codex sandbox, and the worker system prompt as app-server base instructions. The worker consumes app-server JSON-RPC notifications such as `item/agentMessage/delta` so Codex text streams live instead of arriving as one final `exec --json` event. * `opencode` launches `opencode run --format json` with a private OpenCode config and a `second-builder` agent. When a variant is selected, the worker passes `--variant ` after confirming the installed OpenCode model metadata lists that variant. Each adapter emits normalized worker SSE messages. The browser-facing bridge still receives text, reasoning, tool input, tool output, and result messages in one canonical shape. ## Workspace model Each app gets its own workspace directory. The runtime process starts with that directory as its `cwd`, and Second only persists source and artifact snapshots from that directory. ``` /tmp/second-workspaces/ ← base (configurable via WORKSPACES_DIR) ├── {appId-1}/ ← app 1's workspace │ ├── package.json ← Vite project manifest │ ├── src/ ← source files (React + TS) │ ├── dist/ ← compiled artifact (created by done_building) │ └── ... ├── {appId-2}/ ← app 2's workspace (completely separate) │ └── ... ``` New workspaces are scaffolded from a Vite + TS + Tailwind + Shadcn template (`WORKSPACE_TEMPLATE` in `workspace-template.ts`). If the app has a persisted source snapshot in MongoDB from a previous build, the web app sends that snapshot as `sourceFiles` and those files are restored instead. See [App Preview](/app-preview) for the full lifecycle. The workspace directory is a tenant/app working boundary, not by itself a complete OS sandbox. Runtime adapters must keep CLI config and environment scoped to the run, and production deployments should add process/container isolation, filesystem restrictions, and network policy in the deployment layer. ### Key properties * **One directory per app** — keyed by `appId`. Created automatically on the first message. * **Persistent across messages** — within a session (15-min TTL), the agent sees files from previous turns. After TTL, the directory still exists on the host (or container volume). * **The agent's `cwd`** — passed to the selected runtime. Built-in file tools are expected to operate relative to this directory; shell tools still need runtime sandboxing and deployment hardening if the operator needs an OS-enforced boundary. * **Configurable base path** — set `WORKSPACES_DIR` env var to change the base. Default: `/tmp/second-workspaces`. * **Build step** — the `done_building` tool runs `npm run typecheck` and `npm run build` in parallel, validates `dist/index.html`, and persists a bounded workspace snapshot. See [App Preview](/app-preview). ### In development vs. production | Mode | Workspace location | Persistence | | --------------------------- | -------------------------------------------------- | -------------------------------------------------------- | | `npm run dev` (host) | `/tmp/second-workspaces/{appId}` | Survives restarts (host filesystem) | | `npx --yes @second-inc/cli` | `~/.second/data/workspaces/{appId}` | Survives CLI stop/start and normal machine restarts | | Docker container | `/tmp/second-workspaces/{appId}` or mounted volume | Lost when container is destroyed (unless volume-mounted) | | Production (K8s) | Ephemeral container filesystem or PVC | Depends on deployment config | In production, workspace contents are stored in MongoDB source snapshots and restored to the container when needed. The workspace directory is a working copy, not the source of truth. See [App Preview — Persistence and conditional hydration](/app-preview#persistence-and-conditional-hydration). ## Agent tools and permissions The Claude runtime runs with `permissionMode: "bypassPermissions"` and an explicit `allowedTools` list. Command-backed runtimes receive the same canonical tool allowlist through runtime-specific config, MCP broker scoping, and adapter-level blocking behavior. ### Default tools ```typescript theme={null} const DEFAULT_ALLOWED_TOOLS = [ "Read", // Read files "Write", // Create/overwrite files "Edit", // Patch files (search & replace) "Bash", // Run shell commands "Glob", // Find files by pattern "Grep", // Search file contents "WebSearch", // Search the web "WebFetch", // Fetch a URL "mcp__second__present_plan", // Custom: present a build plan for approval "mcp__second__list_app_integration_keys", // Custom: list current app integration keys "mcp__second__present_agents", // Custom: present agents.json for governed approval "mcp__second__present_integration_setup", // Custom: present setup instructions "mcp__second__done_building", // Custom: run build & trigger live preview ]; ``` Built-in tools are runtime-specific but normalized to canonical names such as `Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`, `WebSearch`, and `WebFetch`. Claude exposes Claude-style file tools directly. Codex CLI does not expose a standalone Claude-style `Write` tool; it normally creates structured `fileChange` events when it edits through its patch/file-edit path, and the adapter maps those events to `Write` or `Edit` UI cards. Custom tools (prefixed `mcp__second__`, `mcp__app_tools__`, or `mcp__app_data__`) are defined in the worker and exposed through either Claude in-process MCP servers or the scoped MCP broker. ### Built-in tools Each runtime provides its own built-in file, shell, and web tools. The worker normalizes their events into the same tool names for the UI: * **Read/Write/Edit/Glob/Grep** — operate on the filesystem, scoped to `cwd`; runtime adapters normalize provider-specific file events to these names where the provider emits them * **Bash** — runs shell commands with `cwd` as the working directory. The agent can `npm install`, `npx`, `node`, `git`, etc. * **WebSearch/WebFetch** — make HTTP requests (when enabled) Shell tools can access standard CLI tools available in the runtime environment (node, npm, git, grep, find, etc.). In development, that is the host environment. In deployment, the operator controls the image and process sandbox. Before spawning the Claude SDK subprocess, the runner removes Second infrastructure secrets from the environment (`INTERNAL_API_TOKEN`, `MONGODB_URI`, `REDIS_URL`, WorkOS secrets, cookies, auth headers, and internal URLs). Codex CLI and OpenCode use a stricter allowlist environment: only stable process variables, private per app/run `HOME`/config paths, and a scoped MCP token are passed. Codex API keys are sent through the app-server login request instead of being placed in the Codex process environment. Codex-spawned shell commands then use Codex's `shell_environment_policy` with core environment inheritance, token/key/secret exclusions, shell profile loading disabled, and a separate shell `HOME` that does not point at Codex auth/config. Agents should use MCP tools for app data and integrations, not raw platform secrets from environment variables. For local Codex login mode, the worker can seed the private Codex home with only the user's `auth.json` from `SECOND_CODEX_HOME`, `CODEX_HOME`, or `~/.codex`. This is enabled automatically outside `NODE_ENV=production`; production must use `CODEX_API_KEY`/`OPENAI_API_KEY` unless an operator explicitly sets `SECOND_ALLOW_CODEX_LOCAL_AUTH=1`. The worker does not copy the user's full Codex config, sessions, prompts, or skills into the app workspace. For local OpenCode login mode, the worker seeds only OpenCode `auth.json` into a private data directory. The runtime config is generated per app/run and contains Second's scoped MCP broker, `second-builder` agent, permission denials, and selected model. To support user-configured OpenCode providers, the worker mirrors only the user's OpenCode `provider` config object into that private config. It does not copy user OpenCode MCP servers, plugins, commands, prompts, sessions, or project config into the runtime. Codex's Linux `workspace-write` sandbox depends on kernel/container support for the underlying Linux sandbox. In production, the worker treats the normal Codex build mode as externally sandboxed by the deployed worker environment and sends `danger-full-access` to Codex for `workspace-write` requests. This avoids the `bwrap` namespace failure in Kubernetes-style containers while keeping local development on Codex's normal `workspace-write` sandbox. The production security boundary is therefore the worker/container isolation plus Second's runtime env/config/tool isolation, not Codex's inner Linux sandbox. ### Scoped MCP broker Second tools are implemented once in the worker and exposed two ways: * Claude receives in-process MCP servers from the Claude Agent SDK. * Codex CLI and OpenCode receive remote MCP server entries that point back to the worker's scoped MCP broker. Every command-backed runtime turn gets a short-lived tool broker session with one random token. That token is not `INTERNAL_API_TOKEN`; it is scoped to one app/run/runtime session, expires automatically, and only authorizes the tool set for that run. The scoped token may be passed to the CLI through a private runtime env var or private runtime config file outside the app workspace. Tool handlers may call web internal APIs with the worker's real internal token, but that internal token stays inside the worker process and is never written to runtime env, runtime config, stdout, stderr, browser responses, or app workspace files. The MCP broker exposes: * `second`: `present_plan`, `list_app_integration_keys`, `present_agents`, `present_integration_setup`, `done_building` * `app_tools`: approved custom HTTP tools from `agents.json`, plus the platform-owned `report_tool_call_failed` recovery tool * `app_data`: `update_app_data` and `read_app_data` when the approved agent has data collections Codex app-server asks the client to approve each remote MCP tool call through an MCP elicitation before it sends `tools/call`. Second auto-accepts only those Codex elicitations that are explicitly marked as MCP tool-call approvals, target one of the worker's scoped broker servers, and match the canonical `allowedTools` list for the run. General MCP elicitations are declined so a remote tool cannot collect arbitrary user input through the runtime adapter. `present_plan` and `present_agents` are approval-stop tools. They return a card payload, then the runtime adapter stops the active turn so the model cannot continue past the approval point in the same turn. ### Custom tools (builder) Custom tools for the builder agent are implemented as provider-neutral handlers in `runner.ts`. Claude wraps those handlers with the Claude Agent SDK's `tool()` function and an in-process MCP server. Codex CLI and OpenCode call the same handlers through the scoped MCP broker. Five builder tools are registered in the `second` tool namespace: | Tool | MCP name | Purpose | | --------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_app_integration_keys` | `mcp__second__list_app_integration_keys` | List only the current app's app-scoped integration key grants without secret values | | `present_plan` | `mcp__second__present_plan` | Present build plan for approval | | `present_agents` | `mcp__second__present_agents` | Present agent configuration for governed approval. See [App Agents](/app-agents#present_agents-tool) | | `present_integration_setup` | `mcp__second__present_integration_setup` | Present setup instructions for app-scoped integration keys that are not connected or are missing newly required permissions/secrets. See [Integrations](/integrations#integration-setupjson) | | `done_building` | `mcp__second__done_building` | Run build & trigger preview. See [App Preview](/app-preview#build-step-done_building) | ```typescript theme={null} import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; const presentPlan = tool( "present_plan", "Present a structured build plan to the user for approval before writing any code.", { overview: z.string().describe("High-level summary of what will be built"), features: z.array(z.object({ name: z.string(), description: z.string(), })).describe("Main features / capabilities"), dataFlow: z.string().describe("How data moves through the app"), agents: z.string().nullable().describe("Agent definitions, null if not available"), backend: z.string().nullable().describe("Custom backend, null if not available"), }, async (args) => ({ content: [{ type: "text", text: `Plan presented to user.\n\n${args.overview}` }], }), ); function createSecondToolsMcpServer(config: SessionConfig) { return createSdkMcpServer({ name: "second", version: "1.0.0", tools: [ createListAppIntegrationKeysTool(config), presentPlan, createPresentAgentsTool(config), createPresentIntegrationSetupTool(config), createDoneBuildingTool(config.workingDirectory), ], }); } ``` When a workspace is scaffolded or restored, the worker writes runtime guidance into the workspace. Claude receives `.claude/skills/add-integrations/SKILL.md`; command-backed runtimes receive the same integration rules through the shared system prompt and tool descriptions. The goal is that provider research, setup instructions, named secret placeholders, and permission grouping follow the same rules across apps. When agents contain custom tools, the builder calls `list_app_integration_keys` to get the live grant state for the current app before deciding whether setup is needed. Integration metadata is intentionally not injected into the builder system prompt; the tool returns the current app's configured/requested permission groups, exact permissions/scopes, named secrets, setup instructions, and grant metadata at the moment the builder needs it. A credential configured for another app does not satisfy this app. `present_agents` reads `agents.json` from the workspace root and validates that file before approval. A custom tool must include integration metadata, endpoint method, and endpoint URL. Static-secret tools must include a named secret placeholder such as `{{secrets.SLACK_BOT_TOKEN}}` in the endpoint template. OAuth tools must declare `integration.auth.type = "oauth2"` with provider key, triggering-user identity, authorization URL, token URL, and exact scopes, and must not declare token placeholders or an `Authorization` header. Public unauthenticated tools may omit secrets and auth metadata when the provider's official API requires no credentials. If validation fails, the tool result tells the builder to fix `agents.json` and call `present_agents` again; the UI card is marked as needing changes and approval is disabled. When an admin or owner approves the card, the web app stores the versioned canonical hash and normalized approved payload so draft runtime can verify the approved config before running live tools. Current `v1` canonicalization ignores harmless empty optional arrays such as `appTools: []`, `tools: []`, and `dataCollections: []`. When agents contain custom tools that need setup, the builder writes `integration-setup.json` after the agent configuration is approved and calls `present_integration_setup` before app implementation continues. The setup tool reads `integration-setup.json` from disk and posts that file's metadata to `/api/internal/integration-requirements`, so the integrations settings page can show the app, requester, permission groups, exact permissions, secret names, key slug, and setup instructions before the final build completes. If requirements change later, the builder updates `integration-setup.json` with the complete current requirements and calls `present_integration_setup` again; that replaces this app's grant set and re-syncs the chat card and integrations page. If the file is missing or invalid JSON, the tool does not sync anything. The `done_building` custom tool runs `npm run typecheck` + `npm run build` in parallel and signals that the app is ready for preview. See [App Preview — Build step](/app-preview#build-step-done_building) for details. ### Approval stops `present_plan` and `present_agents` are hard approval stops. Their tool handlers return the card payload immediately, and the runtime adapter ends the active turn after the tool result is emitted. Claude uses `query.close()` after the matching tool result. Codex app-server and OpenCode terminate the active command-backed process after the matching MCP tool result. The next user approval or change request starts a normal follow-up turn with the saved provider session state when available. The frontend treats the latest completed `mcp__second__present_plan` or `mcp__second__present_agents` dynamic-tool part as a pending approval. `AppChat` disables normal message submission and changes the composer placeholder until Approve or Request Changes is clicked on the `PlanCard` or `AgentsCard`. Agents approval writes the governed hash/payload, then sends the follow-up user message that continues the build. Custom tools follow the MCP naming convention: `mcp__{server_name}__{tool_name}`. The `present_plan` tool becomes `mcp__second__present_plan`. Add new builder tools to `createSecondToolsMcpServer()` and include them in `DEFAULT_ALLOWED_TOOLS`. `done_building` is created with the current `workingDirectory` as a closure. This avoids shared mutable state between concurrent agent runs and ensures the build step always runs in the workspace for that specific app/run. #### Adding a new builder tool 1. Define the provider-neutral handler and schema in `runner.ts`. 2. Add it to the Claude SDK MCP server and the scoped MCP broker tool list. 3. Add `mcp__second__{tool_name}` to the default allowed tool names where appropriate. 4. Handle the tool's UI rendering in `app-chat.tsx` (in the `dynamic-tool` section). ### Custom tools (app agents) When an app agent runs, the worker dynamically exposes additional tools based on the approved agent configuration from `agents.json`: **Custom HTTP tools** — the `app_tools` namespace exposes one tool per approved custom tool definition. Each tool calls `POST /api/internal/tool-execute` on the web server, including the server-created `runId`. The web server handles static secret injection, OAuth connected-account lookup and token refresh, and mock/static fallback behavior. See [Integrations](/integrations). **Tool failure recovery** — the `app_tools` namespace also exposes `report_tool_call_failed`, which is reserved for the platform and cannot be declared by generated `agents.json` files. When a custom HTTP tool returns a blocking execution failure, the worker keeps the latest bounded, redacted failure records in the app-agent session. If the app agent calls `mcp__app_tools__report_tool_call_failed`, the worker posts that report and the captured failure details to `/api/internal/tool-failure-report`; the web app verifies `{ workspaceId, appId, runId }`, creates a builder repair run, and publishes only compact recovery status hints. **App data tools** — the `app_data` namespace exposes `update_app_data` and `read_app_data` when the agent has `dataCollections` defined. See [App Data — Agent data access](/app-data#agent-data-access). ```typescript theme={null} const allowedTools = [ ...agent.allowedTools, ...agent.tools .filter((tool) => tool.type === "custom" && tool.enabled) .map((tool) => `mcp__app_tools__${tool.name}`), "mcp__app_tools__report_tool_call_failed", ...(agent.dataCollections?.length ? ["mcp__app_data__update_app_data", "mcp__app_data__read_app_data"] : []), ]; ``` The `allowedTools` list is extended to include custom tool names (`mcp__app_tools__{name}`), the platform recovery tool (`mcp__app_tools__report_tool_call_failed`), and data tool names (`mcp__app_data__update_app_data`, `mcp__app_data__read_app_data`). Custom tool definitions can also include `displayName`, which is not used for execution. The runtime still calls the stable `name`, while the UI can show the human-readable action label alongside the integration name and favicon. For OAuth tools, the worker sends only `runId`. It never sends or claims the user whose Gmail/Calendar/Zoom/etc. account should be used. The web route loads `app_agent_runs` by `{ workspaceId, appId, runId }` and resolves the triggering user from that trusted row before reading any connected account. Each runtime turn gets isolated tool server state. Claude gets fresh in-process MCP server instances because the Claude Agent SDK does not allow the same MCP protocol instance to connect to multiple transports at the same time. Codex CLI and OpenCode get fresh broker sessions with separate bearer tokens. ## Worker API auth The worker's HTTP API requires `INTERNAL_API_TOKEN` when the token is configured. `/health` is public. `/mcp/*` skips the shared internal token but requires the per-run scoped MCP bearer token issued by the worker. In local development, the token can be omitted. In production, the web runtime requires `INTERNAL_API_TOKEN`, and the worker rejects missing or wrong tokens on `/sessions/*`. The web server attaches the token automatically through `workerFetch()`. ## HTTP API ### `POST /sessions/:appId/messages` Start a new session or send a message to an existing one. Returns an SSE stream of raw SDK events. A single app session can process only one message at a time. If another message is sent while the session is busy, the worker rejects it with a clear error; clients should reconnect to the active stream instead of starting a second query for the same app/run. **Request body:** ```json theme={null} { "prompt": "Build me a React dashboard", "systemPrompt": "You are Second, an AI agent...", "runtimeId": "codex-cli", "runtimeModel": "gpt-5.4", "runtimeParams": { "reasoningEffort": "high", "sandbox": "workspace-write" }, "workingDirectory": "/tmp/second-workspaces/abc123", "allowedTools": ["Read", "Write", "Edit", "Bash"], "maxTurns": 50, "sessionState": { "runtimeId": "codex-cli", "sessionId": "abc-123" }, "sourceFiles": { "src/main.tsx": "...", "dist/index.html": "...", "dist/assets/index-abc123.js": "..." } } ``` | Field | Required | Description | | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `prompt` | Yes | The user's message | | `systemPrompt` | Yes | System instructions for the agent | | `runtimeId` | Yes | Runtime adapter ID: `claude-code`, `codex-cli`, or `opencode` | | `runtimeModel` | Yes | Runtime-native model ID | | `runtimeParams` | Yes | Runtime-specific parameter bag | | `workingDirectory` | No | Override workspace path (defaults to `/tmp/second-workspaces/{appId}`) | | `allowedTools` | No | Tool whitelist (defaults to all default tools) | | `maxTurns` | No | Max agent turns before stopping | | `sessionState` | No | Provider-aware session state for cross-container resume | | `sourceFiles` | No | Workspace snapshot to restore when needed (source + built artifact from MongoDB). See [App Preview](/app-preview#persistence-and-conditional-hydration) | When `sessionState` is provided, the worker passes it to the selected runtime adapter. Claude state may include JSONL data to restore before `query({ resume })`; Codex CLI and OpenCode use runtime-native session IDs when available. **Response:** SSE stream (`text/event-stream`) ``` data: {"type":"system","subtype":"init","session_id":"..."} data: {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}} data: {"type":"stream_event","event":{"type":"content_block_start","content_block":{"type":"tool_use","name":"Bash","id":"tc_1"}}} data: {"type":"assistant","message":{"content":[...]}} data: {"type":"result","result":"Done.","total_cost_usd":0.05} data: [DONE] ``` ### `GET /sessions/:appId/status` Check if a session exists and its current state. ```json theme={null} { "exists": true, "status": "idle", "sessionId": "abc-123", "ttlRemainingMs": 840000, "workspaceExists": true, "workspaceHasFiles": true, "restoreNeeded": false, "createdAt": "2026-03-24T12:00:00Z", "lastActiveAt": "2026-03-24T12:05:00Z" } ``` ### `DELETE /sessions/:appId` Kill a session immediately. ### `GET /sessions/:appId/session-file` Returns provider-aware session state for an active session. Used by the bridge to persist resume state to MongoDB for cross-container recovery. ```json theme={null} { "sessionState": { "runtimeId": "claude-code", "sessionId": "abc-123", "data": { "jsonl": "..." } } } ``` Returns `{ sessionState: null }` if no session exists or no provider state is available. Claude state may include the JSONL file content from `~/.claude/projects/{cwdKey}/{sessionId}.jsonl` so a different worker can restore it before resuming. Codex CLI and OpenCode store native session IDs when their JSON stream exposes them. ### `GET /sessions/:appId/files` Returns all source files from the workspace directory as a JSON object. Used in two places: * by the bridge to collect files after `done_building` completes * by the web app file explorer to refresh live files after tool calls The worker endpoint only reports the current workspace filesystem. The web file API merges live files over the app's persisted MongoDB source snapshot, so current source edits can show while the last compiled `dist/**` artifact remains available after navigation, sandbox churn, or TTL expiry. ```json theme={null} { "files": { "src/main.tsx": "import React from \"react\";\n...", "dist/index.html": "...", "dist/assets/index-abc123.js": "import { ... } from \"react\";\n..." } } ``` Excludes `node_modules`, `.git`, and other ignored infrastructure folders. Snapshot collection enforces per-file and total-size guardrails (1MB per file, 12MB total hard limit). Returns `{ files: {} }` if the workspace doesn't exist. ### `POST /sessions/:appId/agent-run` Start a background agent run. Returns immediately with `{ status: "started" }`. The agent executes asynchronously via `AgentRunManager`. **Request body:** ```json theme={null} { "runId": "abc-123", "prompt": "Enrich lead Sarah Chen", "systemPrompt": "You are a lead enrichment specialist...", "agentConfig": { "id": "lead-enricher", "tools": [...], "dataCollections": ["leads"] }, "allowedTools": ["WebSearch", "mcp__app_data__update_app_data"], "runtimeId": "codex-cli", "runtimeModel": "gpt-5.4", "runtimeParams": { "reasoningEffort": "high", "sandbox": "workspace-write" }, "workspaceId": "ws-1", "appId": "app-1", "callbackUrl": "http://web:3000/api/internal/agent-run-complete", "sourceFiles": { "src/App.tsx": "..." } } ``` The worker scaffolds the workspace, then fires `AgentRunManager.start()` which runs the agent in the background and calls the `callbackUrl` when done. See [App Agents — Async execution](/app-agents#async-execution-agentrunmanager). The web app uses a worker session key of `{appId}__agent__{runId}` for app-agent runs. That keeps background app-agent sessions independent from the builder chat session and from each other. ### `GET /sessions/:appId/agent-run/:runId/events` SSE stream of raw SDK messages from a running (or recently completed) agent run. Yields buffered messages first (catch-up), then live events. Returns `404` if the run is not found. ### `GET /health` Returns worker health and active sessions. ## Session management Sessions are held in memory, keyed by `appId`. Each session wraps one selected runtime adapter and its provider-specific resume state. ### Lifecycle 1. **First message** — No session exists. Worker creates one, starts the selected runtime, and captures provider session state when available. Session goes to `"busy"`. 2. **Subsequent messages (within TTL)** — Session exists. Worker starts the selected runtime with the saved provider state. The agent picks up with full context when the runtime supports resume. 3. **TTL expiry** — After 15 minutes of idle time, the session is destroyed. The next message restores context from MongoDB (session file + session ID) and creates a fresh session. ### Concurrency model * **Same builder app session:** serialized. One active runtime turn at a time per `appId`. * **Different builder apps:** concurrent. Each app has its own workspace directory, session object, and MCP server instances. * **App-agent runs:** concurrent. Each run is keyed by run ID in `AgentRunManager` and uses a separate worker session key. * **Stream viewers:** multiple browsers can attach to the same active run stream; they should not start a second worker query. ### TTL behavior * TTL is 15 minutes, reset on every message. * While a session is `"busy"` (agent is running), TTL is paused. * When the agent finishes, TTL starts counting down. * On expiry, the session is removed from memory. ### Cross-container resume flow When provider-aware `sessionState` is provided in the request: 1. Worker restores any provider-specific state that needs files on disk. 2. Worker creates a new in-memory session with the saved provider session state. 3. The runtime adapter resumes the provider session using its native mechanism. From the agent's perspective, it's as if the process never died. ## Claude Agent SDK integration The worker uses `@anthropic-ai/claude-agent-sdk`: ```typescript theme={null} import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; const q = query({ prompt: "Build a dashboard", options: { model: "claude-opus-4-8", // per-message model selection effort: "xhigh", // "low" | "medium" | "high" | "xhigh" | "max" thinking: { type: "adaptive", display: "summarized" }, systemPrompt: "You are Second...", cwd: "/tmp/second-workspaces/abc123", allowedTools: ["Read", "Write", "Edit", "Bash", "mcp__second__present_plan"], maxTurns: 50, includePartialMessages: true, // enables stream_event messages mcpServers: { second: createSecondToolsMcpServer(config) }, // resume: sessionId, // for continuing a session }, }); for await (const message of q) { // message.type: "system" | "stream_event" | "assistant" | "user" | "result" } ``` Key options: | Option | Purpose | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `model` | Claude model ID (`claude-opus-4-8`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5`). Can differ per call — see [Models & Usage](/models-and-usage) | | `effort` | `"low"`, `"medium"`, `"high"`, `"xhigh"` (default for Opus 4.8), or `"max"` (supported high-capability 4.6+ models). Controls reasoning depth | | `thinking` | `{ type: "adaptive", display: "summarized" }` (default for supported models), `{ type: "enabled", display: "summarized" }` (legacy 4.6 thinking), or `{ type: "disabled" }`. Controls extended thinking. See [Thinking mapping](#thinking-mapping) below | | `systemPrompt` | Custom system prompt (replaces default) | | `cwd` | Working directory — all file/Bash operations happen here | | `allowedTools` | Which tools the agent can use (built-in + MCP tool names) | | `maxTurns` | Safety limit on agent turns | | `includePartialMessages` | Enables `stream_event` messages for real-time streaming | | `mcpServers` | In-process MCP servers providing custom tools | | `resume` | Session ID to continue a previous session | ### Thinking mapping The UI and transport layer pass thinking as a simple string (`"adaptive"`, `"enabled"`, `"disabled"`). The runner in `runner.ts` maps this to the SDK's typed object format: `runner.ts` normalizes stale or unsupported combinations before calling the SDK. Opus 4.8 uses adaptive thinking only, so an old `"enabled"` value for Opus 4.8 is treated as `"adaptive"` rather than sending a fixed-budget request that the API rejects. Opus 4.8 also defaults API thinking display to omitted, so the runner explicitly sets `display: "summarized"` whenever thinking is enabled. | UI string | SDK object | Behavior | Models | | ------------ | --------------------------------------------- | ----------------------------------------- | ------------------------------ | | `"adaptive"` | `{ type: "adaptive", display: "summarized" }` | Model decides when and how much to reason | Opus 4.8, Opus 4.6, Sonnet 4.6 | | `"enabled"` | `{ type: "enabled", display: "summarized" }` | Legacy fixed thinking | Opus 4.6, Sonnet 4.6 | | `"disabled"` | `{ type: "disabled" }` | No extended thinking | All models | The string format keeps the HTTP API and transport simple — only the runner needs to know about the SDK's type system. ### How `query()` works under the hood The Claude Agent SDK spawns the `claude` CLI binary and communicates via stdin/stdout NDJSON. There is no "direct API mode" — the SDK IS a CLI wrapper. This means: * In development: uses the user's locally installed `claude` CLI and their auth from `~/.claude/` * In Docker: `claude` CLI is installed in the image, `ANTHROPIC_API_KEY` is in the env * Same code path either way ## Runtime authentication In development (`npm run dev`), the worker runs on the host. Claude can use the user's existing `~/.claude/` auth. Codex can use `CODEX_API_KEY`/`OPENAI_API_KEY` or a local Codex login seeded from `SECOND_CODEX_HOME`, `CODEX_HOME`, or `~/.codex/auth.json`. OpenCode can use the provider keys required by the selected `provider/model`, custom provider config that already works with `opencode models`, or a local OpenCode login seeded from `SECOND_OPENCODE_AUTH_FILE`, `SECOND_OPENCODE_DATA_HOME`, `XDG_DATA_HOME/opencode/auth.json`, or `~/.local/share/opencode/auth.json`. In production, configure only the provider keys needed by enabled runtimes: for example `ANTHROPIC_API_KEY` for Claude Code, `CODEX_API_KEY` or `OPENAI_API_KEY` for Codex CLI, and provider-specific keys such as `OPENAI_API_KEY`, `GOOGLE_API_KEY`, or `GEMINI_API_KEY` for OpenCode models. If a CLI is not on the worker `PATH`, set `SECOND_CLAUDE_PATH`, `SECOND_CODEX_PATH`, or `SECOND_OPENCODE_PATH` to the executable path. Do not mount a shared Codex login home in production unless the deployment is intentionally single-tenant or otherwise isolated and `SECOND_ALLOW_CODEX_LOCAL_AUTH=1` is part of that explicit deployment policy. Also set `INTERNAL_API_TOKEN` on both web and worker so worker HTTP routes and web internal routes authenticate each other. Claude Code subprocess environment scrubbing is enabled by default. On Linux, Claude Code requires `bubblewrap` (`bwrap`) for that mode. The worker Dockerfile installs it; custom production images must keep it installed or Claude detection will mark the runtime unavailable. `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0` disables Claude's inner subprocess env scrubber and should only be used when the worker is externally isolated and that tradeoff is intentional. Codex CLI and OpenCode are launched with allowlisted environments and private per app/run `HOME`/config/data directories. Local OpenCode auth seeding copies only `auth.json` into the private runtime data directory; it does not mount the user's full OpenCode database, sessions, plugins, logs, or config. Custom OpenCode provider support mirrors the `provider` config object and the env keys explicitly referenced by that provider config; it does not import user MCP servers, plugins, commands, prompts, sessions, or project config. Production deployments should prefer explicit provider keys, and local OpenCode auth seeding is disabled by default under `NODE_ENV=production` unless `SECOND_ALLOW_OPENCODE_LOCAL_AUTH=1` is set for an intentionally isolated deployment. In production, Codex `workspace-write` requests run as Codex `danger-full-access` inside the already-isolated worker environment because Linux sandboxing can be unavailable inside containers. Do not rely on CLI permission systems as the only production boundary; deployment-level container/process/network controls are recommended and belong outside this repo. ## File structure ``` apps/worker/ ├── package.json ├── Dockerfile ├── tsconfig.json └── src/ ├── index.ts # Hono HTTP server + route handlers + session file I/O ├── session-manager.ts # TTL-based session lifecycle ├── runner.ts # Provider-neutral Second tool handlers + Claude tool wrappers ├── tool-broker.ts # Scoped MCP broker for command-backed runtimes ├── runtimes/ # Claude, Codex CLI, and OpenCode adapters ├── builder-skills.ts # Local Claude skills injected into app workspaces ├── agent-run-manager.ts # Background agent execution with event buffering ├── event-stream.ts # SSE encoding helpers ├── dep-warmup.ts # Background npm install at scaffold time └── workspace-template.ts # Vite + TS + Shadcn scaffold + SDK (useAgent, useCollection, etc.) ``` ## Adding a new runtime To add another runtime: 1. Add its model and parameter metadata to `apps/web/src/lib/agent/runtime-registry.ts`. 2. Add a worker adapter under `apps/worker/src/runtimes/` and register it in `runtimes/index.ts`. 3. Normalize its JSON/event stream into the canonical worker message shape. 4. Expose Second tools through the scoped MCP broker unless the runtime has a safe in-process tool API. 5. Add provider detection hints without returning secret values. 6. Validate blocking `present_plan` and `present_agents` behavior before enabling it in the picker.