Skip to content

Structured sessions (outputSchema)

A structured session is a headless Session API session that ends in a typed deliverable rather than an edited variant. For example: “browse this customer page, analyze it against past winning experiments, and return experiment ideas.” You send a JSON Schema with the session. The agent browses and analyzes with its normal tools; when it ends its turn, Coframe Agent makes one more model call with your schema as the provider’s native structured-output format (no tools) and stores the value with the turn. If that value misses a rule of your schema the provider could not enforce natively (maxLength, minItems, pattern), one correction call shows the model its own JSON and the exact violations; a second miss is a failed deliverable. There is no submit tool and nothing for you to accept or reject: the deliverable is simply the turn’s result.

POST /api/session { outputSchema, tools? }
│
▼
agent browses + analyzes (tools, no response format)
│ ends its turn
▼
one tool-free call with response_format = your schema ──► deliverable stored
│ run.completed webhook
▼
GET agent/status → deliverable: { runId, status: "ready", value }
│
└── follow-up message? → the next turn produces its own deliverable

Add outputSchema (and optionally tools) to the POST /api/session body:

FieldTypeRequiredDescription
outputSchemaJSON Schema objectnoThe deliverable’s shape. It turns on the structured epilogue and the analysis tool profile.
tools.includestring[]noTools to add that the analysis profile leaves out.
tools.excludestring[]noTools to remove. Always takes precedence over include.
tools.externalTurnExcludestring[]noTools to remove only on turns that nobody started from the session page — see Tools only a person can use.
Terminal window
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": "Analyze this page against our past winners and return 3 experiment ideas." }] }],
"targetUrl": "https://example.com/pricing",
"idempotencyKey": "ideas-example-pricing-2026-10-01",
"outputSchema": { "type": "object", "properties": { "experiments": { "type": "array", "minItems": 1, "items": { "type": "object" } } }, "required": ["experiments"] },
"tools": { "exclude": ["run_shell"] },
"callback": { "url": "https://api.example.com/hooks/editor", "secret": "whsec_..." }
}'
  • The root must be an object schema: { "type": "object", ... }.
  • The schema can be at most 64 KiB when serialized, and it must compile. Standard keywords, combinators, and local $refs are accepted.
  • An invalid schema returns 400 { success: false, error, code: "invalid_request" } with an error message naming the outputSchema field. No session is created. The retired doneSchema field is also a 400.
  • Your schema is the contract the value is validated against. What the provider receives is a projection of it: constraints a provider’s structured-output mode cannot enforce (minItems, maxLength, …) are folded into field descriptions, optional fields become nullable, and additionalProperties is closed (a map-valued one travels as {key, value} entries and is folded back into your object). The returned value is validated against your original schema; a value that does not match is reported as a failed deliverable, never returned as ready. Keep the schema self-describing: add description on fields to tell the model what you want.

The agent keeps its normal tools and is told that the structured result is collected after its turn — it should finish the analysis, not paste JSON into its reply. Sessions without an outputSchema behave exactly as before.

An outputSchema session starts from the analysis profile. It gets read-only browsing and inspection tools plus a scratch workspace, but cannot mutate or publish variants:

Included by defaultExcluded unless you include them
read_file, write_file, list_files, run_shell, fetch_url, view_image, elicit_user_input, read_tool_result, list_evidence, read_message, list_transcript, search_transcript, browser_execute, browser_search, dom_query, save_cleaned_html, inspect_frames, capture_previewapply_variant, check_variant, refresh_previews, publish_variant, deploy_landing_page, publish_qa_report, import_legacy_session, wait_for_test_results, run_tests (and every other tool not in the left column)

Precedence:

  1. The deployment decides what exists. include can only re-enable a tool the deployment already provides. Naming a tool that isn’t available is a no-op.
  2. capabilities removes the browser or sandbox tools; include cannot bring them back.
  3. outputSchema narrows the toolset to the analysis profile.
  4. tools.include adds back named tools that the profile left out.
  5. tools.exclude removes named tools, and wins over everything else.
  6. tools.externalTurnExclude removes named tools the same way, but only on turns that were not typed by a person in the editor.

Removing apply_variant also removes the matching in-script helper (page.applyVariant inside browser_execute). No path to mutate a variant remains. Planning, requirements, delegation, and skills tools are not part of this policy and keep their own behavior.

MCP tools — Jarvis tools and the caller’s own mcpServers — are not narrowed by the analysis profile: an outputSchema session gets every tool of every server you pass. Use tools.exclude (e.g. mcp_acme__delete_* or mcp_acme__*) to remove them.

tools also works without an outputSchema. There, exclude and externalTurnExclude remove tools from the normal editing session and include has no effect. Unknown keys, empty or non-string names, or more than 128 names per list return 400.

A session you start from the API has nobody at the keyboard: a turn that stops to ask a question (elicit_user_input) waits for an answer that never comes. tools.externalTurnExclude removes such tools only while that is true:

  • Removed on the API-started first turn, on messages posted through POST /api/sessions/:sessionId/agent/messages (and the Slack / Linear relays that use it), on the agent’s own continuations and retries, and on every sub-agent turn.
  • Present again the moment a person opens the session in the editor and sends a message — including a follow-up after a headless first turn. The agent can then ask them to confirm before it proceeds.

exclude keeps its session-wide meaning; use it for tools the session must never have. Both keys accept the same names (built-in tools, and MCP tools by exact name or trailing-* prefix), and both are fixed at creation and inherited by sub-agents. Everything else about the session — what tools exist, which of them are client tools, how an unanswered question is settled — is unchanged: the removed tool is simply not offered to the model on those turns.

{
"messages": [{ "role": "user", "parts": [{ "type": "text", "text": "Build a hero variant." }] }],
"tools": { "externalTurnExclude": ["elicit_user_input"] }
}

The toolset is fixed once, when the session is composed. It never changes by switching modes, so the session keeps its cached prompt. outputSchema and tools are stored with the session on first intake. Later requests cannot change them, and they survive resumes, restarts, and redeploys.

Poll GET /api/sessions/:sessionId/agent/status (same Authorization header as for intake), or wait for the run.completed webhook and then read the status once.

{
"turnState": "idle",
"activeRunId": null,
"lastRunId": "run_mrrllafh_vpswfpvp",
"deliverable": {
"runId": "run_mrrllafh_vpswfpvp",
"status": "ready",
"value": { "experiments": [{ "title": "Lead with the annual discount", "...": "..." }] }
}
}

deliverable describes the newest run’s structured result:

statusMeaningFields
pendingThe turn is over and the structured call is in flight (or was interrupted; a still-open run resumes it, a closed run is retried as a new run). Reported only while turnState is running or compacting.runId
readyThe value matched your schema.runId, value
failedNo valid value for this turn: the provider refused, the output was cut off, it did not match the schema, or the run errored before producing one.runId, error, rawText?

It is null until the first run of a structured session has started. A turn is settled when turnState is not running and deliverable.runId equals lastRunId with a status other than pending (a scheduled auto-resume still reads running). Whenever the newest run reads terminal errored — nothing will resume it — its deliverable is reported as failed with that error, whether the structured call was interrupted or the run failed before reaching it, so the two conditions settle together. A failed deliverable does not pause the session; send a follow-up message (for instance with what to fix) and the next turn produces a new deliverable for its own runId.

The deliverable is also stored in the transcript as a data-deliverable part of the assistant message, so it survives restarts and is shown in the editor UI as a structured card.

Providers compile a response-format schema into a grammar before they generate, and each has a ceiling the schema validator cannot see — Anthropic refuses schemas whose compiled grammar is too large (an undocumented budget that arrays of objects and nested objects exhaust fastest; a 64-property schema with 9 arrays of objects is refused, a 40-property one with 6 passes), Gemini 3.8 Flash refuses schemas of roughly 200+ properties. So every structured turn opens with one tool-free, 16-token request carrying only the response format. A provider that cannot compile the schema refuses it there, within seconds: the run ends errored with the provider’s message and agent/status.deliverable reads failed — before the agent has done any paid work. Every new run probes: a follow-up turn, and each attempt of the automatic retry chain after a retryable provider failure (the provider caches the compiled grammar, so a repeat probe is fast). A resume of a still-open run does not probe again: it is the same run, and its schema was either compiled already or — if the run was interrupted during the probe itself — is compiled by the final structured call, which then reports the same provider refusal as failed.

Every turn of a structured session ends with a deliverable. To iterate, post to POST /api/sessions/:sessionId/agent/messages with the feedback as the message text and read the new run’s deliverable the same way.

This schema asks for one or more experiments. Each experiment has one or more variants and cites the past experiments it builds on:

{
"type": "object",
"properties": {
"experiments": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"title": { "type": "string" },
"hypothesis": { "type": "string" },
"page": { "type": "string" },
"targetUrl": { "type": "string" },
"mechanism": { "type": "string" },
"risk": { "type": "string" },
"p_better": { "type": "number" },
"why": { "type": "array", "items": { "type": "string" } },
"cited_precedents": {
"type": "array",
"items": {
"type": "object",
"properties": { "id": { "type": "string" }, "verdict": { "type": "string" } },
"required": ["id", "verdict"]
}
},
"variants": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"brief": { "type": "string" },
"change": { "type": "string" }
},
"required": ["name", "brief", "change"]
}
}
},
"required": ["title", "hypothesis", "targetUrl", "mechanism", "why", "variants"]
}
}
},
"required": ["experiments"]
}

Put the context the agent needs to fill fields like cited_precedents, such as a list of past winning experiments, in the session’s first user message.

  • Coframe Agent does not yet return annotated screenshots (element bounding boxes) as a deliverable. Capture evidence with browser_execute (page.screenshot) or capture_preview, and reference it from your schema as text.
  • The deliverable is validated against your schema, not against your business rules. Check deliverable.value yourself; if it falls short, send a follow-up message with what to fix and read the next turn’s deliverable.
  • Providers cap schema complexity. Gemini 3.8 Flash rejects schemas with roughly 200+ properties (measured: a 187-property schema is accepted, 236 is 400 INVALID_ARGUMENT; byte size and $ref deduplication do not change the outcome). Anthropic, OpenAI and the other retained models accept the largest schemas we send (300 properties). A rejected schema surfaces as a failed deliverable with the provider error, never as a silent ready.