This document tells you how to connect the Studio frontend to the agent
service. It is the one document you need. The source of truth for every
shape is services/harness/src/harness/wire.py, and
services/harness/tests/wire_fixtures/studio/ holds one example of each
frame and request. This document agrees with both.
-
Get the repo and install:
uv sync --all-packages(Python 3.13, uv). -
Put a
GOOGLE_API_KEYin.env(just env) and start a local server against the real model and platform:just studio-live. Every turn costs model spend. -
Send a turn and watch the stream:
curl -N -X POST http://localhost:8080/api/studio/turn \ -H 'Authorization: Bearer dev' -H 'Content-Type: application/json' \ -d '{"kind":"turn.request","schema_version":1, "conversation_id":"conv_1","message":"life expectancy in Kenya"}'
You get
data:lines: steps, a card, the message,done. Render them per section 5. -
Build each UI state against the fixtures in
services/harness/tests/wire_fixtures/studio/and the prompt gallery (section 12). -
When your UI renders all of that, change the base URL to the dev deployment. Nothing else changes.
Everything below is the reference for what you built in steps 3 and 4.
The words in this document have one meaning each. A conversation is one chat with its one canvas. A turn is one user message and the agent's response to it. A frame is one JSON object the server sends on the stream. A component is one card the agent made. The composition is the list of blocks on the canvas.
- Send all
/api/studio/*requests as HTTP POST with a JSON body. - Send the header
Authorization: Bearer <token>with every/api/studio/*request. A request without it gets HTTP 401.GET /healthzneeds no header and no body. Access control is a placeholder for now: when the server has a shared secret configured, the token must be that secret; until then any non-empty token is accepted. Send the token from your server-side proxy, never from the browser. - Make a new
conversation_idon the client for each conversation. Send it in every request body. The server keys all state to your token and this id together. On a deployment with a database configured, that state is durable, so a plan approval, an elicitation answer, or a follow-up turn reaches whichever server instance handles it. - Send
schema_version: 1in every request body. - Unknown fields are errors. The server refuses a request with a field this document does not name.
- The production server sets no CORS headers; same-origin access is your
arrangement to make. A live local server answers preflights for the
origins listed in
STUDIO_CORS_ORIGINS(comma-separated).
| Method | Path | Response |
|---|---|---|
| POST | /api/studio/turn |
SSE stream of frames |
| POST | /api/studio/interrupt |
SSE stream of frames (continues a paused turn) |
| POST | /api/studio/composition |
JSON: the composition manifest after the request |
| POST | /api/studio/cancel |
HTTP 204, no body |
| GET | /healthz |
JSON liveness |
The server answers /turn and /interrupt with Server-Sent Events.
- Each frame is one
data: <json>line. Parse the JSON. Read theeventfield to know the frame type. - Lines that start with
:are keep-alive comments. The server sends: kaabout every 5 seconds when a step is slow. Ignore these lines. seqincreases within one turn. It does not reset when a paused turn continues on a new stream. Gaps are permitted. A new turn starts at 0.- A stream ends in one of three ways. (1) A
doneframe: the turn is complete. (2) Aplan.proposedorelicitation.requestframe: the turn is paused. Answer on/interrupt. (3) Anerrorframe: the turn failed. Nodonefollows anerror. The server saves the conversation's state durably before it sends adone,plan.proposed, orelicitation.requestframe; if that save fails, the stream ends with a stream-fatalerrorframe (transient_unavailable) in that frame's place, so any pause or completion frame you see is one the server has already durably recorded. - One request streams per conversation at a time, across every server
instance: a durable lease in the conversation's ledger, renewed every 5
seconds and good for 45, enforces it. A second
/turnor/interruptduring a stream gets HTTP 409 withretry_after_secondsset to the lease's remaining time./canceland/compositionare not blocked by a stream. To stop a stream, use/cancelor close the connection;/cancelreaches a stream running on another instance within about 5 seconds.
| Field | Type | Notes |
|---|---|---|
kind |
"turn.request" |
required |
schema_version |
1 |
required |
conversation_id |
string | required (session_id is a deprecated alias; do not send both) |
message |
string | required; the user's text |
surface |
"studio" | "download" | "embed" |
optional; default "studio" |
component_ref |
object | null | optional; see section 8 |
seed_spec |
list of Seed | null |
optional; 1 to 6 Search results to start from; see 4.1.1 |
auto_add |
boolean | optional; default true; see section 9 |
A "... in Studio" action on the Search page sends the question with one
seed for each definite part of the result the user saw. seed_spec is a
list of one to six seeds, or null, or absent. A seed is
{spec, operations}:
specis the part's Spec block as the Search API returned it. The harness keepsspec_id,definition({schema_version, spec_id, identity}) andresolved.entity_groups, the sets the part's places were asked for as ("African countries"), and ignores every other member, so a client may leaveresolvedout.definition.spec_idmust equalspec_id. The block may run to 1,048,576 bytes of compact JSON; a CRS block is about 400 KB.operationslists what the question asked of that data that Search does not compute, as the Search API'sOperation:{verb, phrase, over, target_year}.verbis one ofrank,aggregate,growth,share_of,explain,forecastorreport.phraseis the question's own words for it.overisplace,origin,destination,category,yearor null.target_yearis the year a forecast projects to, a whole number between 1000 and 2200, set on aforecastalone, or null. The list holds at most eight operations and may be empty, aphraseruns to 200 characters, and an operation with any other member is refused.
seed_spec also accepts one {spec_id, content_version} object, the
shape this member was reserved with before it carried seeds. A reference
carries no Spec block, so it seeds nothing: the turn runs from the
question, as it did while the member was reserved, and the "Receive the
datasets from Search" step shows failed with code
seed_reference_unused.
Some requests are refused whole. A body past 6,356,992 bytes gets a 413
resource_exhausted before it is parsed. A seed_spec gets a 422 when
it is an empty list, holds more than six items, holds an item that is
not a JSON object, or is a list that comes with an edit
component_ref.
Each seed is checked inside the turn, so one bad seed fails alone, with
a failed step seed-<n> (n is its position in the list, from 0) that
states a code and the reason, and the other seeds go on:
identity_mismatch: the identity does not hash to thespec_id.seed_duplicate: an earlier usable seed names the samespec_id. The first stands, whatever the repeat's block says.seed_invalid: anything else. The identity must fit in 65,536 bytes of compact JSON, each member the harness keeps must have the type the Search API gives it, and each is within its bound.seed_dropped: the Search API refuses the seed's identity, no longer resolves it to a definite part, or resolves it to anotherspec_id. The step states Search's reason and asks the user to search again.service_unavailable: data-mcp, the Search API or the definition store could not be reached for the seed.
The bounds are what the Search API can serve. They hold for the members a seed carries and for Search's answer for its identity, which supplies the rest. A seed or an answer past one is refused whole; nothing is cut to fit.
Member of spec or of Search's answer |
Bound |
|---|---|
resolved.rendered_sentence |
2,000 characters |
resolved.entities |
1,024 items |
the roles of definition.identity.entities |
16 |
resolved.slots, coverage.dimensions, role_axis_facts |
64 items each |
| any other string the harness reads (a dcid, a role, a label) | 256 characters |
| any count or year the harness reads | a whole number, 12 digits |
What a seeded turn shows. The harness resolves each usable seed's
identity again through the Search API, all seeds at once, stores the
definition Search answers with in data-mcp's definition store, and
retrieves nothing yet. Every label, sentence, count and headline the turn
shows comes from that answer, never from the block the client posted. The
Activity feed shows one "Receive the datasets from Search" step counting
the seeds kept. A seed dropped or not reached shows its own failed
seed-<n> step. The agent then starts from the kept seeds as definite
search results: it fetches or plans with them without searching, and the
turn goes on as any data turn does. When no seed is kept, the turn
continues from the question alone, as a turn without seeds does.
Planning. The agent hands the operations a seed carries to the
planner. The planner pins each seed and chooses the years to retrieve,
and the plan retrieves the seed over those years. One retrieval returns
at most data-mcp's MAX_FRAME_ROWS rows, so the planner asks for the
fewest years the question needs: one year for a rank across places or
donors, and the range a growth runs over. Education aid to Kenya is 242
rows in 2024 and 3,780 over every year. A retrieval past the ceiling
shows a failed step, and the planner replans with a narrower window and
the seed still pinned. An operation over place, origin or
destination runs across every member of the axis the seed leaves open,
and a rank that asks for a top or bottom N keeps that many rows. A
component draws at most 24 series.
The download surface. A download turn retrieves one dataset and
computes nothing: the agent fetches once and does not plan.
A dropped seed. The turn drops a seed when Search's answer for its
identity is not the dataset the user handed over: Search refuses the
identity, resolves it to something other than one dataset, resolves it
to another spec_id, or answers with a block the harness cannot read.
The turn checks each seed once, as it receives the seeds and before it
retrieves anything. The seed's seed-<n> step fails with code
seed_dropped and carries the reason. The agent is told the seed's
name, dataset <n> from Search (its position in seed_spec, from 1),
and Search's reason, and its reply tells the user and suggests searching
for the dataset again.
| Field | Type | Notes |
|---|---|---|
kind |
"interrupt.response" |
required |
schema_version |
1 |
required |
conversation_id |
string | required |
interrupt_id |
string | required; copy it from the pausing frame; it is opaque and single-use |
decision |
"approve" | "amend" | "reject" | "answer" | "skip" |
required |
payload |
string | null | required for amend; for answer only on an elicitation paused before delta 21; forbidden otherwise |
answers |
object | null | required for answer (except as above): question id → answer; forbidden otherwise |
Each answer in answers is one of:
{"kind": "option", "option_id": "<id>"}: an option the question offered.{"kind": "text", "text": "<the user's words>"}: non-empty; offer it when the question setsallow_free_text: true.{"kind": "skipped"}: the user leaves this question to the agent. Only on a question that setsskippable: true; the turn still resumes as answered.
{
"kind": "interrupt.response",
"schema_version": 1,
"conversation_id": "conv_01J9ZC3AB4YT9Q6WGX0S6H2K9E",
"interrupt_id": "elc_7a1c0e5b42d94f18",
"decision": "answer",
"answers": {
"q1": {"kind": "option", "option_id": "spec_4d5e6f7a33333333"},
"q2": {"kind": "skipped"}
}
}Rules the server enforces with HTTP 422:
approve,amend,rejectapply only to aplan.proposedpause.answer,skipapply only to anelicitation.requestpause.answersnames every question id of the frame exactly once, and anoption_idmust be one that question offered.skipis permitted when the frame setsskippable: true. It dismisses the whole pause and answers no question. When the orchestrator asked, the turn closes. When the planner asked, the skip counts as the user declining to choose, and the planner plans on without a pick of theirs.- An elicitation that paused before the frame carried
questionstakes its one answer aspayload(an optionidor free text) instead ofanswers.
| Field | Type | Notes |
|---|---|---|
kind |
"composition.mutate" |
required |
schema_version |
1 |
required |
conversation_id |
string | required |
composition_id |
string | null | optional; omit to target the current composition |
expected_revision |
integer | null | optional; see below |
operations |
array | required; at least one operation |
Each operation carries op plus only the fields for that op:
op |
Fields | Effect |
|---|---|---|
add |
component_id |
Appends a block for a component this conversation streamed. When no composition exists and composition_id is omitted, the server creates one that contains the added block. |
reorder |
order (array of component ids) |
Puts the named blocks first, in the given order. |
remove |
component_id |
Removes the block. The component stays readable. |
pin / unpin |
component_id |
Sets the block's pinned flag. |
set_layout_hints |
layout_hints (array | null) |
Replaces the layout hints. Null clears them. Each hint is {type: "row" | "comparison", members: [component ids, 2 or more]}. |
set_card_meta |
component_id, meta (required) |
Sets client-owned card fields; see section 9. |
Concurrency and atomicity:
- Send
expected_revisionto prevent lost updates. A stale value gets HTTP 409 with theedit_conflicterror, and nothing applies. - Operations are atomic. If one operation is invalid, the server applies none of them.
- A request that changes nothing returns the current manifest and does not make a new revision.
- On success, the response body is the full
CompositionManifest(new, or unchanged for a no-op). On failure, it is a ToolError (section 7). /compositionapplies while a turn streams on the same conversation, whether on this instance or another: it applies against the conversation's latest saved manifest, and a turn streaming at the same time reconciles its own composition changes on top of whatever/compositionlanded, so a canvas edit and a turn's own card both survive when they collide.
Fields: kind: "turn.cancel", schema_version: 1, conversation_id.
The server stops the conversation's active stream and returns HTTP 204.
The active stream may be running on a different server instance than the
one that answers this request; the server finds it there too and stops
it within about 5 seconds. The stopped stream ends with an error frame
whose code is cancelled. The call is safe when nothing streams. A
closed connection also stops the turn, but proxies can hide a closed
connection. Use /cancel for the Stop button.
Every frame has event and seq. The other fields per type:
event |
Fields | Meaning |
|---|---|---|
message.delta |
text |
Append this text to the streaming assistant message. |
message.complete |
text |
The full final message for the turn. Replaces the deltas. |
plan.proposed |
interrupt_id, plan_version_id, version, title, steps[], actions[], decisions[]?, revision?, requirements[]?, operations[]? |
The plan card. Pauses the turn and ends the stream. |
plan.versioned |
plan_version_id, version, supersedes, changed[]? |
A new plan replaces the card supersedes names; a new plan.proposed follows. version rises after an amend and is 1, which can be lower than the superseded version, when the plan was drafted again. |
activity.step |
step_id, label, status, detail?, tool?, duration_ms?, produced?, progress? |
One execution step for the Activity feed. step_id is s plus the seq of the step's first frame, unique across the turn's segments; match a step's later frames by it, not by position. On the stream after an approval the plan steps run first; each plan step's final frame arrives in plan order, though steps that run at the same time may send their running frames in another order. Each plan step's label is that step's label on the plan card. A plan-step reference on this frame is a follow-up. |
component.created |
component_id, revision, type, catalog_version, min_catalog_version, payload, fact_summary, provenance, created_from? |
A new card exists. |
component.revised |
component_id, revision, changed[], fact_summary, payload? |
An existing card has a new revision. When payload is absent, keep your cached payload and apply the changed fields from the new fact_summary/frame. |
composition.updated |
manifest |
The canvas changed. Re-render from this manifest. |
elicitation.request |
interrupt_id, questions[] (1 to 3), skippable |
One to three questions for the user. Pauses the turn and ends the stream. |
suggestions |
items[] |
Follow-up chips. The list is empty in v1. |
error |
error (a ToolError object) |
The turn failed. No done follows. |
done |
trace_id, composition? |
The turn is complete. composition is the conversation's current manifest, or absent when the conversation has no canvas. |
Notes on specific fields:
message.*text is GitHub-flavored Markdown. Render it with a sanitizing Markdown renderer; never inject it as raw HTML.plan.proposed.steps[]:{id, label, description?, args?}. Showlabel; showdescriptionwhen the user expands the step.argsholds the tool arguments backing the step, for a client that wants to show more than the label.plan.proposed.decisions[]:{id, question, chosen, reason, resolved_by, alternatives?}, the choices the agent made for the user.plan.proposed.revisionis a string identifying the plan's approval key, distinct from the integerversion. It sits besideversion: a client that shows a version badge keeps usingversion.plan.proposed.requirements[]:{id, kind, sentence, decision_ids}. It is the evidence behind the plan.plan.proposed.operations[]:{id, kind, sentence, requirement_id?}. It is the plan's computed steps.sentenceon both is composed server-side and rendered as given, so the plan card and the model's own narration describe the same plan in the same words.revision,requirements,operations, andsteps[].argsare optional. A client that ignores them renders the same plan card it always has.activity.step.statusis one ofpending,running,complete,failed,skipped. Afailedstep is recoverable; the turn continues. A failed step can be followed by acompleteframe on the samestep_idwhen a later try succeeds, withdetail"This worked on a later try."detailholds a one-line result on success in plain words: a plan's fetch step carries the retrieval's summary (observations, years, source, series), as a fetch does; a plan's analysis step and a card's step (labelled with the card's kind and title) carry none. A tool call's failed step, a plan step's, an agent tool's or an edit's, carries none: a refusal's code and message are written for the agent and stay in the trace. A failed "Receive the datasets from Search" step says in plain words that the datasets the user brought could not be used, a "Making N charts" step keeps itsdetail(below), and a step still running when a turn is stopped or fails closesfailedwith a plain reason such as "Stopped before this step finished".produced({component_id, revision}) links a step to its card. In a data turn, the agent's reply says why a card it asked for was not drawn. An edit turn whose write is refused closes with "The edit was refused: ", quoting the refusal's own message.- An
activity.stepwith notoolis a status line: what is happening now, in the present tense. It opensrunningand later gets acompleteframe withduration_mson the samestep_id. One status line runs at a time, and one runs whenever an agent is working and no tool step is running. Show the label of the most recently opened step that is stillrunning; acompleteorfailedframe updates the Activity record only. Fold status lines in the Activity record. - The planner's status lines name its work: "Working on a plan" (from the
hand-off to the planner until its first draft) or "Revising the plan"
(from the hand-off of an amendment), "Checking the plan against the
data" (from each proposal the planner makes until the proposal is
answered), "Reworking the draft" (a draft after a refused proposal;
detailgives the reason in plain words when there is one), "Getting the plan ready for you", and, once a plan is approved, "Starting the plan" until its first step's frame. The checking line's label is the same for every plan. Itscompleteframe carries adetailnaming the data of the plan the proposal recorded, such as "health spending per person for Kenya and Uganda and 1 more" (the first dataset, then the count of the others), and nodetailwhen that plan has no dataset. The Activity feed keeps a line's first label and shows a later frame'sdetailas its summary. - The orchestrator agent's status lines are "Reading your question",
"Reading your answer", and, after each batch of tool results, a line
naming what the model is reading: "Reading what the search found",
"Looking at , –" after one fetch ("Looking at
" when the retrieval states no years; "Looking at the data"
after several fetches, or after one that failed or whose measure no
search named), "Going over the charts", "Reading the earlier turn",
"Going over your canvas" after the agent looked at the canvas (its tool
step reads "Looking at your canvas"), "Going over the notes on DFO", "Working out the next step" after a hand-off
to the planner or a question to the user that was refused, and
"Reading the plan's results" from the moment an approved plan finishes
running. A batch that mixes tools is named by its latest stage (charts,
then fetch, then search, then recall). A successful plan hand-off opens
"Working on a plan", a successful ask hands over to the question, and
neither opens a reading line. A batch of background-note reads keeps the
line before it running.
detailon these lines carries the limits the results state, in plain words: for a search, each found measure's years, the parts with more than one reading and the parts with no data; for a fetch, the retrieval's summary (observations, years, series), with each measure named when there are several. The line completes when the next thing happens: a tool call, answer text, a hand-off, or the end of the stream. A plan hand-back that carries no plan shows afailed"No plan could be made", then "Reading why no plan was made"; the closing message then says what could not be calculated. No model thought text is sent. - The agent shows one step per tool call: "Searching for data" (detail: the query, then what it found, named by the reading each measure resolved to, and what it did not find),
"Fetching " (detail: the retrieval's summary), "Making N
charts", and one step per card written ("Line chart: <title>"). The
calls a fetch makes on its own behalf (retrieving and checking the
data), skill reads and the hand-off to the planner are not shown when
they succeed; each still shows as a
failedstep when it fails. A "Making N charts" step isfailedwhen any chart it asked for was not drawn, withdetailnaming how many and the titles of the refused ones; the cards it did draw still arrive. component.created.payloadis the platform's resolved revision,{schema_version, catalog_version, component_id, revision, provenance, component}, as dfo-spec-kit's envelope schema defines it.payload.componentholds everything the card draws, points included (see "Point data" below), and its title atpayload.component.title.text(a note or a source card has none).payload.provenanceis the platform's provenance for this revision and is authoritative for the card's back. Take the headline values and attribution fromfact_summary.
Point data. The points come in payload.component: series[].points[] on a line or bar chart, each {period, value}, or {period, status} where no value is published, rows[] on a table, and value with period on a stat card. Plotting a card needs no second request.
A client that wants the raw frame behind a card takes its reference from payload.provenance.inputs[0] = {spec_id, content_version} (a note has none) and fetches it from the datasets door:
- Send
GET /api/datasets/{spec_id}/{content_version}toapis. How your app reachesapis(direct, through your proxy, or another way) is your decision; the reference pages already talk to the same service. - Read
artifacts[0].urlfrom the response. It is a root-relative link that does not expire. Resolve it against theapisorigin. - Fetch that link and follow its 302 redirect to the file. The redirect
target is a signed URL that lasts five minutes, so follow it at once.
The body is a Parquet file. Each row is one point:
{stat_var, observationAbout, time_period, value, unit, measurementMethod, provenance, status}.
The frame can hold more rows than the card draws. payload.component holds exactly the drawn points. The platform mints the file, and every row carries its provenance.
Known limit: the datasets door resolves spec_ frames only. A card whose
frame spec_id starts with ops_ rests on an analysis result (a
filter, join, growth rate, per-capita figure, or any other analysis step).
The frame is published to the platform, but
GET /api/datasets/{spec_id}/{content_version} answers 404 not_found for
it, so its raw frame is not served. The card's own points are in payload.component either way.
fact_summaryholdscomponent_id,revision,series[]({id, label, point_count, coordinates?, first?, first_period?, last?, last_period?, change?};first_periodandlast_periodare the periods of the first and last drawn points that hold a value; each is absent when its point carries no period, and both are absent for a stat card or table, which draw no series),attribution({label, source_count, indicator_count, source_detail_url?}),window?,unit?(the unit slot of the card's payload, repeated as the platform states it, with its shape below),series_truncated?,countries?,defaults?([{pointer, detail}]: the choices the platform made where the card's intent left one open, each naming the intent field it filled by JSON Pointer; absent means it made none). By design, the numbers in the message text come from fact summaries; the models are constrained to them. A chart'swindowis the one the platform states, or else the periods its axis draws: a bar chart of entities at one period has that period as bothstartandend. A table's and a stat card'swindowis null, andseries[].changeis null on every card. A table draws without either field.fact_summary.unitis the unit slot of the card's payload, in the payload's shape, so one reader serves both: a stat card'sunit, a chart's first series'unit, and a table's rowunitwhen every row has the same one. It is absent when the platform states none, as for a table whose rows differ in unit, a note, and a source card. Otherwise it is one of three shapes, and each object is closed:- a string of 1 to 200 characters, the unit's name, or its reference where the graph names none:
"US Dollar". - a structured unit,
{currency?, magnitude?, prices?, base_year?}with at least one member:{"currency": "USD", "prices": "constant", "base_year": 2023}.currencyis a three-letter ISO 4217 code,magnitudeisunits,thousands,millions,billions, ortrillions,pricesiscurrentorconstant, andbase_yearis the year of constant prices. - a composed unit,
{numerator, denominator}, the unit of one measure divided by another, read "numerator per denominator":{"numerator": "US Dollar", "denominator": {"stat_var": "dcid:Count_Person", "label": "Total population"}}. Each side is a string or a structured unit as above, or{stat_var, label}for a measure that declares no unit, wherestat_varis thedcid:-prefixed StatVar the measure counts andlabelis the name to print for it. A side is never null and never a composed unit.
- a string of 1 to 200 characters, the unit's name, or its reference where the graph names none:
provenanceholdsinputs[],sources[],freshness,warnings[],generated_at,citation,execution_ref?,refreshed_from?. It is complete on the frame; no second fetch is needed to show source information. A card built from one retrieval lists its own frame as the one input. A card built from several (a join, a compare, a share of two datasets) lists its frame first, then each retrieval it rests on.sources[]then holds one entry per distinct source,citationjoins the distinct citations with "; ",freshness.last_refreshedis the oldest input's date,freshness.data_throughis the earliest input's end year, and each warning carries thespec_idof the retrieval it came from.last_refreshedis""when any input's creation date is missing or unreadable, anddata_throughis""when any input's end year is missing or not a whole number. A card built from one retrieval with no creation date gets today's date (YYYY-MM-DD) aslast_refreshed, and itsdata_throughis""when that retrieval has no end year. A card whose retrievals cannot all be identified is withheld.elicitation.request.questions[]holds one to three questions, each{id, question, input, options[]?, allow_free_text, skippable, rationale?}. Answer each under itsid. Option ids are scoped to their question, so two questions may both offero1. One question comes with a frame-levelskippable: trueand question-levelskippable: false; several come with the frame-level flag off and each question skippable on its own. An option withrecommended: trueis the planner's pick, and the question'srationalesays why.- A question's
inputissingle_select,multi_select, ortext. In v1 the server emits onlysingle_select, and each answer is one option or one text. Treatmulti_selectas reserved.
A turn can pause more than once. The agent pauses to ask at most twice a turn, one to three questions each time, then goes on with what it has. Plan reviews recur after each amendment, with no fixed count. Follow this procedure in a loop:
- Read frames until the stream ends.
- If the last frame is
plan.proposed: show the plan. Sendinterrupt.responsewithapprove,amend(payload = the user's change request), orreject. - If the last frame is
elicitation.request: show its questions. Send oneanswerwithanswersfor every question, orskipwhen the frame isskippable. - The
/interruptresponse is a new stream that continues the same turn. Read it the same way. - After
amend, the new stream sendsplan.versionedand then a newplan.proposedwith a newinterrupt_id. Respond again. - When the harness cannot read the plan you approved or amended, it
plans the question again. The new stream sends a
message.deltaandmessage.completesaying so, then aplan.versionedwithversion1 whosesupersedesnames the card shown, then a newplan.proposed. Replace the card from thatplan.proposed, and accept aversionlower than the one it supersedes. When no plan can be drafted, the stream sendsmessage.completeanddone. rejectends the turn. The stream sendsmessage.completeanddone. Nothing was computed.
A new turn.request on a paused conversation abandons the pause.
Retry an interrupt.response you are unsure landed: the server records
the answer before it starts resuming, so retrying the identical body is
safe. While the resumed stream from your first attempt is still running,
on any instance, the retry answers 409 transient_unavailable with
retry_after_seconds; once that stream is gone, the retry resumes the
turn again on a fresh stream, with seq restarting 1000 higher than
whatever the dead stream already sent. Retrying with a different
decision, payload or answers than the one that actually resumed answers 409
edit_conflict.
The error field of an error frame, and the body of every non-200
response FROM THE STUDIO ROUTES' OWN CHECKS (404, 409, and the 422s in
section 4), is a ToolError: {error, retry, detail, constraint_id?, pointer?, fix?, retry_after_seconds?}. Two framework responses have a
different shape: 401 is {"detail": "..."}, and a request body that fails
schema validation gets a 422 with {"detail": [...]} (FastAPI's format).
errorcodes:schema_invalid,binding_invalid,constraint_violation,unsupported_component_type,unsupported_version,edit_conflict,not_found,resource_exhausted,transient_unavailable,rate_limited,cancelled,capability_unavailable.retryis one ofcorrect_and_retry,refresh_then_retry,retry_after_delay,do_not_retry. Obey it. Offer a retry button only forretry_after_delay.- A step-scoped failure is an
activity.stepwithstatus: "failed", and the turn continues. A top-levelerrorframe ends the turn. detailis text you can show. Thedoneframe carries the turn'strace_id; errordetailtext names the trace id when the failure is server-side. Show it so users can report it.not_foundwithpointer: "/conversation_id"is what/interruptand/compositionanswer for aconversation_idthe server has never seen, and what/turnanswers when its body carriescomponent_reffor one (a card to edit or follow up on needs a conversation the server already holds). A plain/turnwith nocomponent_refstarts a new conversation when theconversation_idis one the server has not seen./cancelon an unknown or idle conversation is a no-op 204.- A second
/turnsent while one is already streaming on the same conversation, on any instance, answers 409transient_unavailablewithretry_after_secondsset to the remaining lease. Wait for the first to finish, or send/cancel. - A running stream can also end with a stream-fatal
errorframe carryingtransient_unavailablewhen the instance serving it stops renewing its claim (a crash, or a slow write) and another instance has since taken the conversation over. The turn continues there; the taking-over instance's own stream, if you have one open, is what finishes it.
Set component_ref on a turn.request to scope the turn to one card:
{component_id, revision, intent} with intent = "edit" or
"follow_up".
Edit (intent: "edit", message = the user's edit instruction, or the
Edit Card panel's "Describe your edits" text):
- The turn does no search and makes no plan.
- For a title or data-selection change, the stream sends two
activity.stepframes,component.revised,composition.updated(only when the card is on the canvas),message.complete,suggestions,done. - For a type or window change, the platform cannot revise the card. The
stream sends
component.createdfor a replacement card. The new block takes the replaced block's slot on the canvas. The replaced component stays readable at its earlier revisions. - An impossible edit ends with an honest
message.completeand no component frame. - To implement quick type-switch icons, send the same edit request with a fixed message, for example "turn it into a bar chart".
Follow-up (intent: "follow_up"): the agent gets the card's summary as
context for a normal data turn, and is told the question is about that
card. The agent reads the card's latest revision. A component_id this conversation never held answers 404
not_found with pointer: "/component_ref/component_id"; a card inherited
by a copy of someone else's conversation is one of these.
Canvas questions: a message like "what is on my canvas?" is answered from the canvas state without a data search. An empty canvas gets the answer "nothing yet".
Cross-turn memory: the agent remembers two things. It remembers the
canvas (card titles and fact summaries, for the last 16 blocks in canvas order): "now
the same for Uganda" works after a Kenya card. It also remembers the
recent conversation (the newest 10 exchanges: what you asked, what it
answered, which cards resulted): "show life expectancy for the country I
mentioned" resolves to the country you actually named earlier, even when
that earlier turn made no card. For references beyond those windows the
agent can look up older turns and full card details on its own. All of
this is server-side; the client sends only the normal turn.request.
The server owns the composition. Hold no layout state on the client.
Re-render from every composition.updated frame and from every
/composition response.
The manifest: {composition_id, revision, actor, blocks[], counts, layout_hints?}. Each block: {component_id, revision, type, title, pinned?, meta?}. counts is {all, stats, charts, tables, notes}, the numbers for the
filter chips. actor says who made the revision: model or user.
Auto-add: with auto_add: true (default), each created card joins the
canvas at once. With auto_add: false, cards stream as component.created
but do not join the canvas. Add one later with the add operation. Use
this for the Auto-add toggle and the "Added" chip.
Removal by the agent: when the user asks in chat to remove cards or
clear the canvas, the agent takes the blocks off, and a
composition.updated frame carries the manifest without them. The removed
components stay readable. A model turn's manifest can therefore shrink or
be empty.
Card meta (set_card_meta): meta = {subtitle?, description?, x_label?, y_label?, style?}. The server stores these fields and returns
them on the block. The server never interprets them, and the platform never
sees them. style is a free object for client-defined settings, for
example {"show_area_fill": true}.
type is one of: stat_card, line_chart, bar_chart, scatter_chart,
table, note_user, note_narration, source_card. This set is closed
in v1; a new type arrives only with a new contract version. Render an
unknown type as an unsupported-block state so that day is safe.
catalog_version names the platform catalog release the payload comes
from (min_catalog_version currently equals it).
suggestions.itemsis always empty.- Some searches refuse with
capability_unavailable("identity does not reproduce spec_id"). This is a known upstream data gap. The search step showsfailedand the agent answers without that data; a retry will not help. - Cross-turn memory is bounded: the newest 16 canvas blocks and the newest 10 exchanges (older context is looked up on demand, which can miss). Very long conversations keep working from that window.
- On a deployment running more than one server instance, editing or following up on a card is reliable only when the request lands on the same instance that created the card. The conversation's other state (plan approvals, elicitation answers, plain data turns) works from any instance.
Each row is a real prompt and the behavior it causes. Every frame
validates against the models in services/harness/src/harness/wire.py,
and services/harness/tests/wire_fixtures/studio/ holds one static
example of each frame and request.
| Prompt or request | Behavior |
|---|---|
| "life expectancy in Nigeria" | Direct data turn: steps → card → message → done |
| "give me a comprehensive overview of health spending in Kenya" | Plan pause → approve on /interrupt → card |
Same plan, decision: "amend", payload "focus on the change since 2015" |
plan.versioned → new plan.proposed → approve → card |
Same plan, decision: "reject" |
message.complete → done; nothing computed |
| Turn 1 "life expectancy in Kenya"; turn 2 "now the same for Uganda"; turn 3 "what's on my canvas?" | Canvas memory across turns; a canvas question with no data search |
component_ref edit: "rename it to …", then "turn it into a bar chart" |
component.revised, then a replacement component.created |
| "average annual rainfall in Middle Earth" | No data: honest message.complete → done, no card |
Any turn, then POST /cancel |
Stream ends with the cancelled error frame |
"quickly, life expectancy in Japan" with auto_add: false, then an add operation |
Cards stream without joining the canvas; the add response holds the manifest |
| An ambiguous question that resolves to several readings | elicitation.request pause; answer, free text, or skip |
A real turn takes 15 to 60 seconds and several model calls. Card meta, pin, reorder, remove, edit conflict and malformed requests are plain HTTP request → response cycles; build them from sections 4, 7, and 9.