/v1 response carries Link: </v1/openapi.json>; rel="service-desc".
When a field on this page is not enough, that document is the exact truth about
every endpoint, parameter and shape, and you already have the URL in a response
you made.
1. Preflight
Do this before anything else. Each check has one call, one expected answer, and one thing to do when it is not that.1.1 Does the token work, and what may it do
/v1/health answers for any valid token whatever its scopes, so this is how you
learn what you hold rather than guessing.
A
401 also carries WWW-Authenticate: Bearer realm="gobare". Treat 401 as
terminal: nothing about waiting makes a rejected token accepted.
1.2 Do you hold the scopes your plan needs
There are six:sessions:read, sessions:write, tools:respond,
artifacts:read, credentials:write, and cli — which is the Console’s
default kind and is refused by every /v1 route. Which endpoint needs which is
one table in
production-integration.md, and it is the only copy
on purpose; each operation in GET /v1/openapi.json also carries its own.
What you have to decide here is not the mapping but the combination, and
there are three worth naming:
- Drives sessions end to end — read, write, respond and artifacts together.
It still cannot connect a model provider; that needs
credentials:write. - Only answers the agent’s calls — read plus
tools:respond. It cannot send a message, cancel a turn or delete anything, which is what you want a fleet of tool handlers to hold. - Only watches and collects — read plus
artifacts:read, and it really is read-only: nothing it holds changes state, including deleting an artifact.
403 permission_denied and the message names the scope it wanted, so a mistake
here costs one call, not an investigation.
1.3 Is there a model to run
model from here. Do not invent a model name. Asking for one this
organization has not connected is refused by name.
An empty list means nothing can run yet. If you hold credentials:write you can
fix it yourself:
provider as well. Full
rules in model-credentials.md.
If you do not hold credentials:write, stop and say so. It is deliberately not
included in a session-creating token: a session spends sandbox minutes, a
provider key decides whose bill every future turn lands on.
1.4 The two preconditions that are not visible from here
Check these only if §2 says your plan needs them, and check them before you build, because both fail late and confusingly:- A repository needs the organization to have a GitHub connection. Without
one, a session naming
environment.repois refused up front. - A public URL needs the deployment to have a base domain. Without one,
POST /v1/sessions/{id}/previewanswersnot_foundsaying so.
2. Choosing what to switch on
This is the only section that depends on your task. Read the requirement you were given, find its rows, and switch on exactly those. Everything else stays off — an unused capability is a thing that can fail.
Write down the rows you chose. §11 turns that list into the self-test you have
to pass before you are finished.
3. The five nouns, and the one state machine that matters
Ids are ours and opaque: sessions are UUIDs, turns areturn_…, artifacts are
art_…, subscriptions are whsub_…. Do not parse them. Times are milliseconds
since the epoch, as numbers.
3.1 The mistake that costs the most
A turn has four statuses and no fifth one.working covers everything before it settles — including a turn that is
parked, waiting for you to answer something. There is no waiting. Polling
turn.status for a fifth value is an infinite loop that ends when the workspace
is reclaimed two hours later.
What changes when the agent needs you is the session:
So the rule is: poll the session to learn that you are needed; poll the turn to
learn that the work is over.
You do not have to wait for
environment.state: "running" before sending work. A
message queues against a session in any state and the workspace comes up to
serve it.
4. Configure the session
One call, and everything is optional except having a model available.201 and carries an id. Read it back with
GET /v1/sessions/{id} and confirm agent.model, agent.approval_mode and
agent.permission_rules are what you sent. Everything you can set, you can read
back, so nothing about what a session enforces is something you have to remember
having sent.
4.1 Field notes that are not guessable
metadatais your only durable handle. Put your own job id there at creation. After a restart,GET /v1/sessions?metadata=job:8842finds the session and?status=requires_actionfinds everything owing you an answer — each listed session carries its ownrequired_actions, so one call is enough.instructionsare not a permission system. They shape behaviour; they do not constrain it.approval_modeandpermission_rulesconstrain it. Where your instructions and the product’s own rules disagree, the product’s win.instructionsapply from the session’s next workspace, not mid-turn. APATCHduring a running turn is not ignored — it takes effect on the turn after.- An unknown field is refused, never ignored, and the refusal names the
nearest accepted field and lists the rest.
modelCredentialIdfindsmodel_credential_id;read-onlyfindsread_only. Take the suggestion, but read the list beside it — the suggestion travels with the full vocabulary precisely so a wrong guess is recoverable from one sentence. environment.branchdoes not exist. The clone reports which branch it landed on inenvironment.repo.branch; it cannot be told which to use.inputat creation is all or nothing. A201means the session exists and the opening message was accepted. A non-2xx normally means nothing exists — except when the message failed and we could not clean up, in which case the error names the session id you now have to delete. Read the message.
4.2 What you can change afterwards
PATCH /v1/sessions/{id} takes title, metadata, and — nested under
agent — instructions, approval_mode and permission_rules:
{"approval_mode":"per_step"} is invalid_request naming the field. A
PATCH with no recognised field at all is also refused rather than accepted as a
no-op.
4.3 Reusing one configuration
If more than one session in your integration starts the same way, save it once:name is a slug you choose; posting the same name again replaces that agent in
place and keeps its id. A replacement really replaces — a field you leave out is
removed, not carried over.
Inline fields beat the agent, and how depends on the field: model,
model_credential_id and instructions override per field, so overriding the
model keeps the agent’s instructions. tools and text are replaced whole,
never merged.
Do not read an agent back and post it straight in again. A read-back carries
redacted, naming the secrets withheld, and redacted is not a request field —
you get a 400 rather than a success that quietly blanked your credentials.
Build the body from your own source, or re-send the secrets. Full rules in
saved-agents.md.
5. Give it tools
PUT, not POST: send the complete list every time. At most 32 entries, each
name 1–64 characters. You can pass the same list as agent.tools at creation.
GET …/tools returns the same vocabulary you sent — one tools
array, each entry carrying its type. Count it. A tool you believe you declared
and did not is the cause of an agent that “ignores” your function.
Everything else the agent can do — shell, reading and writing files, starting
services, publishing a preview, committing — is always present and is not
configured here. You are choosing what it can reach outside the workspace.
5.1 Function tools
The agent calls it; the turn parks; you answer. That loop is §7. Two halves, both required: the declaration is what the model is told exists, and your handler is what your process answers with. Declaring without a handler parks the turn on a call nobody answers. Handling without a declaration means the call never happens. Settimeout_seconds on every function in production. The default is no
deadline at all: a process that dies mid-answer holds a workspace until its
two-hour cap with nothing anywhere saying so. Past the deadline the call fails
and the turn carries on — so guessing too short costs you one failed call, not a
lost turn. Accepted range is 1 to 7200.
5.2 MCP servers
Exactly one ofurl (a server the sandbox connects out to) or command (a
process started inside the sandbox, spoken to over stdio). The connection is made
from the sandbox, not from the control plane.
Decide required deliberately. With required: false — the default — a
server that will not connect is skipped, the agent silently has fewer tools, and
you get an mcp_unavailable item and an mcp.unavailable event. With
required: true the session refuses to run and the error names the server and
the reason. It arrives as invalid_request on the first call that needs the
workspace, deliberately not a retryable code: waiting will not make the server
reachable.
If a command server reads a file, do not put that file in
environment.files. Seeded files are written when the workspace comes up,
which can be after the MCP client has already tried to start the process. Pass
the program inline as an argument, or write it with POST /files and declare the
tool afterwards.
5.3 read_only refuses MCP tools that are not annotated
This is the interaction that looks like a broken model and is not.
read_only allows an MCP tool whose server declares readOnlyHint in
tools/list, and refuses one that does not — “no annotation” is not “harmless”,
and an organization’s private plugin has been reviewed by nobody. The obvious
shape, a read-only investigation against a read-only runbook server, therefore
fails unless the server annotates.
Two ways out, and a rule beats the mode in both directions:
approval.resolved with approved: false, the tool’s name, a code of
denied_read_only or denied_by_rule, and the sentence the agent was given.
Match on code; it is the difference between a tool the platform blocked and one
the model never tried.
6. Send work
All input goes to one endpoint, one event per request, and the answer is202 —
the agent has the work, not the answer.
Full field lists are in input.md.
6.1 Finding the turn your message produced
The reply carries noturn_id, because when it is written there is no turn yet.
If you stream or take a webhook, the id arrives with them. If you poll, this is
a trap with a specific shape:
6.2 Queueing is not steering
A message sent while a turn is running is accepted and queued — the reply says"queued": true with a position. To change what a running turn is doing you
must say so by name with input.steer, which is a conflict when nothing is
running rather than quietly becoming a new turn.
Five queued messages per session is the ceiling; past it, queue_full.
7. Watch, and answer what it asks
Pick exactly one primary mechanism. Mixing them is fine, but one of them owns the decision “is this done”.7.1 The event stream
GET /v1/events is the same thing for every session the organization owns.
- Subscribe before you send. The other order loses the opening frames whenever the agent starts quickly, which is to say intermittently and never on your machine.
- Only persisted events carry an
id:line. Reconnect withLast-Event-ID: <seq>and you get everything persisted after that point, then live frames. Transient events —agent.text,agent.thinking, progress — carryseq: null, are not replayed, and are decoration. Build on the persisted ones. - No cursor and
0are different requests. No cursor is live frames only, which is what “subscribe first, then send” wants.0is everything, because zero is a real cursor. - Honour the
retry:hint rather than reconnecting instantly. It is deliberately longer than the time it takes us to notice your last connection went away, so a client following it is not racing us for its own stream slot. - A
: pingcomment every 15 seconds keeps the connection open. Ignore it.
turn.started, turn.ended,
agent.message, agent.tool_call, agent.tool_result, agent.compaction,
agent.error, agent.todos, user.message, message.queued,
message.dequeued, file.changed, approval.requested, approval.resolved,
question.asked, question.answered, tool.required, tool.resolved,
mcp.unavailable, sandbox.created, sandbox.paused, sandbox.resumed,
preview.ready, workspace.recovery_failed, artifact.created.
turn.ended is deliberately neutral: the run finished, and whether it succeeded
is a property of the turn it names. Read the turn.
7.2 Webhooks
session.created, session.action_required,
session.working, session.idle, session.failed, turn.completed,
turn.failed.
The secret comes back once. Store it from this response; it is never shown
again.
Verification is HMAC-SHA256(secret, "{timestamp}.{body}"), hex, compared
against x-gobare-signature. Three rules, and each has a failure that looks like
something else:
- Verify the raw body, before any parse and re-serialise. A round trip changes key order and will not match.
x-gobare-timestampis milliseconds, the same units asDate.now(). Dividing by 1000 rejects every delivery and fails looking exactly like a bad signature.- Reject a timestamp far from your clock — a few minutes is reasonable.
data names the object; it never embeds it. Read the object afterwards and you
see current truth. Delivery is at least once and can arrive out of order, so make
the handler idempotent. Answer inside 10 seconds and do the work afterwards.
Subscribing the same URL to exactly the same event set twice is 409 conflict
naming the subscription already doing it — a duplicate buys you every delivery
twice, permanently. More in webhooks.md.
7.3 Answering a required action
When the session reportsrequires_action, read required_actions. Every entry
carries type, turn_id, call_id, and expires_at (or null).
type:
approval and question can also be resolved by a person in the Console, which
is often what you want. What matters is that an API-only integration is not stuck
waiting for one.
output must be a string. Serialise it yourself; nothing will guess whether
your object was meant as JSON text.
Never put a thrown exception’s message in error. It goes into the model’s
context, and a stack trace is an efficient way to put your database host in a
prompt. Send a fixed string and log the real one.
The reply’s outcome is the thing to branch on:
not_delivered is the one that needs handling rather than logging. The
sandbox was not holding that call when your answer arrived — usually because it
was restarted underneath the turn. Nothing fails, nothing times out: the session
goes back to reading working with an empty required_actions, which is
indistinguishable from healthy on every surface including webhooks. Treat it as
“this turn lost my answer”: start a fresh turn with the same information, or fail
the job and say why. Do not wait.
8. Collect the result
8.1 Artifacts
Anything written under/workspace/outputs is published when the turn settles,
and outlives the workspace. status: "completed" is not yet a promise that the
artifact list is filled in — publication runs after the turn settles so that a
storage problem can never delay your work.
Wait on turn.artifacts, not on turn.status:
With webhooks you can skip this:
turn.completed is sent once publication has
settled. If it has not settled after 30 seconds the notification is sent anyway
with the turn still reading pending, so read the field rather than assuming.
?turn_id= the entries carry the workspace’s
own paths; without it you get the whole session and each entry is prefixed with
the turn that published it, because two turns writing report.md are two files
and a flat archive would extract as one.
8.2 The workspace itself
Artifacts only ever cover/workspace/outputs. To answer “the agent said it
wrote that — did it?”, read the workspace:
captured_at says
which moment; state: "missing" means none has been taken, which is not the same
as an empty workspace. refresh takes a fresh one — a separate call, and a write
scope, because it wakes a paused sandbox.
GET /files reads no query parameters at all. Sending ?limit= is a 400
rather than a silent success, which is the general rule: an ignored filter hands
back everything looking exactly like a filter that matched everything.
8.3 The transcript
GET /v1/sessions/{id}/items is the durable record and it outlives the
workspace. Item types: message, tool_call, command_execution,
file_change, approval, question, mcp_unavailable, error.
error is the one to look for when a session says failed and there are no
turns at all. A run that could not start leaves no turn behind; this item says
why, in detail.message. A seeded file that could not be written appears here
too.
8.4 Publishing what it built
Publishing needs the agent to have started a server and calledpreview(port) —
ask for it in the same sentence as the work. preview.port on the session is the
signal that there is something to publish; until it is set, publishing is refused
rather than answered with an address that returns errors.
subdomain and one is derived from the session id, which is what you want
when the address is for a machine rather than a person. Publishing twice to the
same address answers 200 with the same URL; unpublishing twice answers 200
too. Both are retry-safe on purpose. Details and every refusal in
preview.md.
A published address is a stable route, not a permanently running computer: an
idle workspace is still paused, and the first public request wakes it. A visitor
waits a few seconds and then sees the site.
9. Rules you must not break
Each one prevents a plausible wrong outcome rather than an error. That is why they are worth the lines.- Poll the session for
requires_action; poll the turn for completion. There is nowaitingturn status. (§3.1) - Remember the previous turn id before you send, or you will read the previous turn’s result. (§6.1)
- Subscribe before you send. (§7.1)
- Wait for
turn.artifacts, notturn.status. (§8.1) - Branch on
error.code, never on the HTTP status. Three codes share429and one of them never clears. (§10) - Honour
Retry-After, and give up when there is none. Its absence is the signal, not an omission. (§10) outputin a tool result is a string, and never a thrown exception’s message. (§7.3)PUT /toolsreplaces, and a read-back cannot be posted straight in —redactedis refused. (§5)- If nothing in your integration can answer a question, tell the agent not to
ask one. One line in
instructions: “Never ask the user a clarifying question: if something is ambiguous, state your assumption and continue.” Otherwise it parks on aquestionand waits. (§7.3) - Delete every session you create. Twenty-five concurrent per organization, a fork spends one too, and a paused sandbox still holds its slot. (§10.2)
- Send
Idempotency-Keyon every write, and a fresh one per logical operation. It is not scoped to the body: reusing a key with different content replays the first answer rather than doing the second thing. (§6) - Never put a
gbr_pat_token in browser JavaScript. There is no CORS on/v1, deliberately; call it from your backend.
10. When something is refused
Every failure has one shape:request_id is also on x-request-id, on every response including the
successful ones. Log it next to whatever you record about the call.
10.1 Code to action
Everything marked retryable carries
Retry-After; the rest carry none. A
client that honours the header and gives up without one is doing the right thing
on every row above without knowing any of them.
The three 429s are the reason rule 5 exists: a client that retries all of them
identically spins forever on project_limit_exceeded.
If you have a symptom rather than a code, troubleshooting.md
is indexed by what you are looking at; the full table with statuses is
errors.md.
10.2 Budget, and the ledger you must keep
Pace yourself from the headers rather than discovering the wall. Every authenticated response carries them:resource names which bucket: sessions (10 per minute, for
POST /v1/sessions) or general (120 per minute, everything else). The same
token legitimately holds 9 of one and 119 of the other at once, so without the
name one of those numbers reads as a bug.
The ceilings you are most likely to meet:
Every number, including the ones not here, is in limits.md.
Keep a ledger. Before you finish, every one of these must be accounted for:
project_limit_exceeded on somebody else’s work.
11. Prove it, then stop
Do not report the integration as working until this passes. Run the rows you chose in §2 and nothing else; a check you skipped is not a check that passed.
Two rules about reporting:
- A check that cannot pass is a result, not a reason to narrow the scope. Say
which one, what you saw, and what you think it means. Quote the
request_id. - Distinguish “the API refused me” from “my request was wrong”. The refusal message names the field, the scope or the ceiling. If it named one, it was yours.
12. Where the truth is
This page is a build order. When you need a field rather than a sequence:
If this page and the OpenAPI document disagree, the OpenAPI document is right and
this page has a bug. Say so in your report.