Session API (REST)
POST /api/session is the canonical way for a backend to create a
Coframe Agent session and start its first agent turn — no SPA, no persistent
connection. One request creates the session, persists the initial transcript,
starts exactly one turn, and returns everything needed to follow it. Prefer
the REST SDK if you’re in TypeScript;
this page is the underlying wire contract for everyone else.
Authentication
Section titled “Authentication”Send your API key with every request (including status polling):
Authorization: Bearer <api-key>X-Jarvis-Api-Key: <api-key> is accepted as an alternative header.
Unauthenticated requests receive 401.
Start a session
Section titled “Start a session”POST /api/sessionContent-Type: application/json| Field | Type | Required | Description |
|---|---|---|---|
messages | Message[] | yes | Initial transcript; the last user message seeds the first turn. |
targetUrl | string (URL) | no | Page the edit targets. |
targetPageUrls | string[] | no | Additional target pages. |
metadata | object | no | Opaque caller metadata stamped onto the session. |
idempotencyKey | string | no | Replays return the original session instead of creating a duplicate. |
userId | string | no | Stable caller identity for turn attribution. |
source | "web" | "api" | "slack" | "cli" | no | Attribution stamped onto lifecycle events. Defaults to api. |
callback | object | no | Webhook registration — see below. |
outputSchema | JSON Schema object | no | Ask for a structured deliverable: every turn ends with a JSON value matching your schema, read from agent/status.deliverable — see Structured sessions. |
tools | { include?: string[], exclude?: string[], externalTurnExclude?: string[] } | no | Per-session tool policy — see Structured sessions. externalTurnExclude removes tools only on turns nobody started from the session page (the API-started first turn, agent-API messages, the agent’s own continuations); a person typing in the editor gets them back. |
systemPrompt | string (≤ 20,000 chars) | no | Caller instructions appended to the Mark-17 harness prompt under a ## Session Instructions heading. Never replaces the harness prompt. |
mcpServers | { [id]: { url, type?, headers? } } | no | MCP servers the agent may call this session — see MCP servers. |
capabilities | { browser?: boolean, sandbox?: boolean } | no | Resources the agent may use; both default to true — see Capabilities. |
subAgents | boolean | no | Let the agent delegate to sub-agents (spawn_agents and the other delegation tools). Defaults to false: an API-created session never spawns unless you send true. A caller that also sends top-level source: "web" (the SPA’s marker) is instead governed by the PostHog sub-agents flag for its own identity — the flag must target that caller, so this is not an escalation path. A non-boolean is rejected with 400 subAgents: must be a boolean. Children of an enabled session inherit it — along with the session’s mcpServers (including the jarvis pin and server headers), tools and capabilities, but not its outputSchema (a child reports through submit_result) nor its systemPrompt (a child’s instructions are its objective); the setting is fixed at creation. |
delegation | { maxLiveChildren?: integer ≥ 0, maxLiveBrowsers?: integer ≥ 0 } | no | Per-session concurrency caps for sub-agents, next to subAgents. Each field defaults to the deployment value (DELEGATION_MAX_LIVE_CHILDREN / DELEGATION_MAX_LIVE_BROWSERS, both 4 in production), which is also its ceiling: a value above it is rejected with 400 delegation.maxLiveBrowsers: 5 exceeds the deployment ceiling DELEGATION_MAX_LIVE_BROWSERS=4 — never clamped — and maxLiveBrowsers must not exceed the children cap the session runs under (400 delegation.maxLiveBrowsers (3) must be ≤ maxLiveChildren (2)). Unknown keys, non-integers and negatives are 400s too. The caps are fixed at creation, apply to every spawn_agents admission (browser: "own" children count against maxLiveBrowsers), are inherited by the session’s children, and are reported as facts in agent/status.delegation.maxLiveChildren / .maxLiveBrowsers. If a deployment later lowers a ceiling, the lower value applies to live sessions too. Has no effect unless sub-agents are enabled for the session. |
A Message is { role: "user" | "assistant", parts: [{ type: "text", text: string }] }.
MCP servers
Section titled “MCP servers”Each entry connects one remote MCP server for every turn of the session. Its tools appear to the agent as mcp_<id>__<tool>, alongside the built-in tools (and Jarvis tools, when the caller is a Jarvis user).
{ "mcpServers": { "acme": { "url": "https://mcp.acme.com/mcp", "type": "http", "headers": { "Authorization": "Bearer <token>" } } }, "tools": { "exclude": ["mcp_acme__delete_*"] }}urlmust behttps; put credentials inheaders, never in the URL.typeis"http"(Streamable HTTP, default) or"sse".- Ids match
[a-z0-9][a-z0-9_-]{0,31}. At most 10 servers, 20 headers each. jarvisis special:"jarvis": { "url": "https://<your-jarvis-api>/api/mcp" }does not add a server — it pins the built-in Jarvis MCP server (toolsmcp_jarvis__*) to that endpoint instead of the deployment’s default, so a session launched from a Jarvis preview talks to that preview. The session’s own Jarvis token is the credential:headers(andtype: "sse") onjarvisare rejected with a 400.- Headers are stored server-side on the session and only sent to that server — they are never returned by any API, written to the transcript, or exposed to browser clients.
- Like
outputSchemaandtools, the server list,systemPromptandcapabilitiesare fixed at creation: later intakes for the same session cannot change them. tools.excludeapplies to MCP tools too, by exact name or a trailing-*prefix (mcp_acme__*drops a whole server). So doestools.externalTurnExclude, on the turns it covers.- A server that is unreachable or slower than 15 s to list its tools is skipped for that turn; the turn still runs.
Capabilities
Section titled “Capabilities”capabilities turns session resources off, for callers that only need the agent and their own tools:
browser: false— the Kernel browser is never started, and the browser and preview tools (browser_execute,screenshot,dom_query,set_previews, …) are removed.sandbox: false— no sandbox container is started, and the workspace tools (read_file,run_shell,apply_variant,run_tests, skills, …) are removed. The browser runs in the sandbox, so this also turns the browser off;{ "sandbox": false, "browser": true }is a400. The session never starts a workspace, so itsstartupStatusstaysuninitialized; turns run normally.- Removed tools can’t be re-added with
tools.include. Like the other profile fields,capabilitiesis fixed at creation.
Response — 201
Section titled “Response — 201”{ "success": true, "sessionId": "session-1c8816418724", "requestId": "req-658e8124-6da0-4f4e-8c9d-583607248d86", "runId": "run_mrrllafh_vpswfpvp", "shareUrl": "/edit/session-1c8816418724", "statusUrl": "/api/sessions/session-1c8816418724/requests/req-658e8124-...", "streamUrl": "/api/sessions/session-1c8816418724/requests/req-658e8124-.../stream"}shareUrl— open in a browser to watch or join the session.statusUrl— poll (same auth header). Returns{ success, sessionId, requestId, runId, status }wherestatusis"pending" | "running" | "completed"; unknown request IDs return404.streamUrl— Server-Sent Events of the run’s domain events.
Invalid input returns 400 { success: false, error, code: "invalid_request" }
with a field-scoped error message, and no session is created.
Example
Section titled “Example”curl -s -X POST "https://<editor-host>/api/session" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [{ "role": "user", "parts": [{ "type": "text", "text": "Make the hero CTA green." }] }], "targetUrl": "https://example.com/landing", "idempotencyKey": "my-experiment-1234", "callback": { "url": "https://api.example.com/hooks/editor", "secret": "whsec_..." } }'Webhook callbacks
Section titled “Webhook callbacks”Instead of polling or holding the SSE stream open, register a callback and
Coframe Agent pushes run lifecycle events to your endpoint:
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | https (or http) URL to POST events to. Loopback, private-range, and link-local hosts are rejected. |
secret | string | no | Enables HMAC-SHA256 signing of every delivery. |
events | string[] | no | Subset of ["run.started", "run.completed"]. Default: all. |
Delivery format
Section titled “Delivery format”Each event is POSTed as JSON:
{ "event": "run.completed", "sessionId": "session-1c8816418724", "requestId": "req-658e8124-6da0-4f4e-8c9d-583607248d86", "runId": "run_mrrllafh_vpswfpvp", "timestamp": "2026-07-19T09:33:05.669Z", "data": { "kind": "run.completed", "...": "the full domain event" }}With headers:
Content-Type: application/jsonX-Coframe-Event: run.completedX-Coframe-Delivery: whd-<uuid>X-Coframe-Signature: sha256=<hmac-hex> (only when a secret was registered)Verifying signatures
Section titled “Verifying signatures”The signature is HMAC-SHA256 of the exact raw request body, keyed by your
secret. Always verify against the raw bytes — re-serializing the parsed
JSON will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, secret: string, signatureHeader: string): boolean { const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`; return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));}import hmac, hashlib
def verify(raw_body: bytes, secret: str, signature_header: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header)Delivery semantics
Section titled “Delivery semantics”- At-least-once. Deduplicate by the
X-Coframe-Deliveryid (or by(runId, event)). - Deliveries are durable — they survive server restarts and redeploys.
- Failed attempts retry with exponential backoff (5 attempts total; each attempt times out after 10 seconds), then the delivery is dropped.
- Respond
2xxquickly from your endpoint; do slow work asynchronously.