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 deliverableStart a structured session
Section titled “Start a structured session”Add outputSchema (and optionally tools) to the
POST /api/session body:
| Field | Type | Required | Description |
|---|---|---|---|
outputSchema | JSON Schema object | no | The deliverable’s shape. It turns on the structured epilogue and the analysis tool profile. |
tools.include | string[] | no | Tools to add that the analysis profile leaves out. |
tools.exclude | string[] | no | Tools to remove. Always takes precedence over include. |
tools.externalTurnExclude | string[] | no | Tools to remove only on turns that nobody started from the session page — see Tools only a person can use. |
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_..." } }'outputSchema rules
Section titled “outputSchema rules”- 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 theoutputSchemafield. No session is created. The retireddoneSchemafield is also a400. - 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, andadditionalPropertiesis 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 asready. Keep the schema self-describing: adddescriptionon fields to tell the model what you want.
What the agent gets
Section titled “What the agent gets”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.
Tool profile
Section titled “Tool profile”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 default | Excluded 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_preview | apply_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:
- The deployment decides what exists.
includecan only re-enable a tool the deployment already provides. Naming a tool that isn’t available is a no-op. capabilitiesremoves the browser or sandbox tools;includecannot bring them back.outputSchemanarrows the toolset to the analysis profile.tools.includeadds back named tools that the profile left out.tools.excluderemoves named tools, and wins over everything else.tools.externalTurnExcluderemoves 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.
Tools only a person can use
Section titled “Tools only a person can use”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.
Read the deliverable
Section titled “Read the deliverable”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:
status | Meaning | Fields |
|---|---|---|
pending | The 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 |
ready | The value matched your schema. | runId, value |
failed | No 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.
Schema preflight
Section titled “Schema preflight”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.
Follow-up turns
Section titled “Follow-up turns”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.
Suggested schema: experiment ideas
Section titled “Suggested schema: experiment ideas”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.
Limitations
Section titled “Limitations”- Coframe Agent does not yet return annotated screenshots (element bounding
boxes) as a deliverable. Capture evidence with
browser_execute(page.screenshot) orcapture_preview, and reference it from your schema as text. - The deliverable is validated against your schema, not against your
business rules. Check
deliverable.valueyourself; 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$refdeduplication 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 afaileddeliverable with the provider error, never as a silentready.