Tools your client executes
Some tools cannot run on the platform. Highlighting an element, scrolling a page, filling a form: only the browser the person is looking at can do that. Cobre calls these client-owned tools. The platform never executes one. When the model calls it, the run waits for you, you execute the call, and you send back what happened. The model reads your answer as the tool’s result, in the same run, and carries on.
The cycle has four steps: submit, stream, suspend, resume.
1. Declare the tool
Section titled “1. Declare the tool”A client-owned tool is an ordinary tool with client_owned: true. Its description and
input_schema are what the model sees, so write them for the model. Nothing in config is
ever called; {"schema_version": 1} is enough.
curl -X POST https://your-deployment.example.com/tools \ -H "X-Api-Key: $COBRE_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "highlight", "kind": "http", "client_owned": true, "effect": "read", "description": "Highlight an element of the page the visitor is looking at.", "input_schema": { "type": "object", "properties": {"selector": {"type": "string"}}, "required": ["selector"] }, "config": {"schema_version": 1} }'Then list its slug in the agent revision’s tools, like any other tool.
2. Submit with client_tools: "suspend"
Section titled “2. Submit with client_tools: "suspend"”The behaviour is opt-in, per run, because only you know whether the caller of this run can
execute the calls. Without it nothing changes: the model is not shown client-owned tools, and a
call to one ends the run with the calls in output.result.client_tool_calls.
curl -X POST https://your-deployment.example.com/runs \ -H "X-Api-Key: $COBRE_KEY" \ -H "Content-Type: application/json" \ -d '{ "agent_slug": "site-guide", "session_key": "visitor-42", "input": {"message": "where is the price?"}, "client_tools": "suspend", "client_tool_timeout_s": 60 }'| Field | Default | |
|---|---|---|
client_tools | "end" | "suspend" shows the model your client-owned tools and waits for you when it calls one. |
client_tool_timeout_s | 120 | How long each wait lasts, from 5 to 900 seconds. Only valid with "suspend". |
The default wait is short on purpose: on the other side there is usually a person looking at a
page. The wait also never outlasts the run’s own deadline_at: when that cuts it short,
the timeout_s announced in the suspend frame is the shorter wait you actually have.
3. Stream, and watch for suspend
Section titled “3. Stream, and watch for suspend”Open the run’s stream as usual (Streaming a run). When the model calls one
of your tools, the run suspends and the stream carries a suspend frame with everything you
need to execute the calls:
{"protocol": 2, "seq": 4, "run_id": "01a0ecab-41c9-7000-b55c-0ee91f1108d8", "type": "suspend", "segment_id": null, "data": { "kind": "external_wait", "resume_contract": "result", "deadline_at": "2026-09-29T10:18:22.041154+00:00", "prompt": { "reason": "client_tools", "timeout_s": 60, "tool_calls": [ {"id": "call-1", "name": "highlight", "arguments": {"selector": "#price"}} ] } }}The stream stays open. A suspended run is not finished, so there is no run.end yet, and the
frames of the rest of the run arrive on the same connection after you resume.
arguments is already an object. If you are not streaming, the same frame is in
GET /runs/{run_id}/events?type=suspend, and the pending suspension is under suspension in
GET /runs/{run_id}/state.
4. Execute, then resume with the results
Section titled “4. Execute, then resume with the results”Run each call and send back one result per call, naming the call by its id:
curl -X POST https://your-deployment.example.com/runs/01a0ecab-41c9-7000-b55c-0ee91f1108d8/resume \ -H "X-Api-Key: $COBRE_KEY" \ -H "Content-Type: application/json" \ -d '{ "contract": "result", "value": { "tool_results": [ {"tool_call_id": "call-1", "content": "highlighted 1 element", "is_error": false} ] } }'content is a string or a list of text blocks ([{"type": "text", "text": "…"}]), up to
100 000 characters per result and 64 results per resume. Set is_error: true when the call ran
and failed (the element did not exist, the visitor navigated away). The model then reads your
text with error: in front of it, the way it reads any failed tool call, and can try
something else. If your text already starts with error, nothing is added.
The answer is 202 with the run back in queued. On the stream you get a resume frame,
then the model’s next turn, and eventually run.end:
{"protocol": 2, "seq": 6, "type": "resume", "data": {"kind": "external_wait", "resolved_by": "human"}}resolved_by says human for every resume that arrives through /resume, including one sent
by your code.
What resume refuses
Section titled “What resume refuses”| Response | Code | When |
|---|---|---|
422 | tool_results_mismatch | The results do not name exactly the pending calls. details.errors lists each missing_tool_result, unexpected_tool_result and duplicate_tool_result, and details.pending_tool_call_ids lists what is expected. |
422 | validation_failed | value is not {"tool_results": [...]}, or breaks a limit (more than 64 results, an empty list). |
409 | resume_contract_mismatch | The contract is not result. |
409 | suspension_expired | The deadline has passed. |
409 | not_suspended | The run is not waiting, and this is not a repeat of the answer that resumed it. |
A rejected resume changes nothing: the run is still suspended and you can send the right answer.
Each entry of details.errors has a field, a code and a value or message. The field
is written two ways today, depending on which check refused the value:
- a shape error (
validation_failed) joins the path with dots:value.tool_results.0.tool_call_id; - a mismatch (
tool_results_mismatch) indexes with brackets,value.tool_results[0].tool_call_id, and a missing result names the list itself,value.tool_results, with the missing id invalue.
Resuming again with the same results is safe. If a network error hides the 202 from you,
send exactly the same results again. The platform checks it against every client-tools wait of
the run that has already been answered, and a repeat of one of those answers is a 202 that
does nothing. That holds even if the run has moved on by then and is waiting for something else
(new tool calls, or an approval of a platform tool from the same turn). Two cases are not
repeats:
- a different answer to a wait that is already answered is refused like any resume the run
cannot take (
409 not_suspendedwhen the run is not waiting, otherwise the error of whatever it is waiting for); - if the run is waiting right now for a call with the same
tool_call_idand your results are a valid answer to it, they are taken as the answer to that call. Some model providers number calls per turn (call_0,call_1), so the same id can come back in a later turn as a new call. Keep track of which calls you already answered, not only of their ids.
When nobody answers
Section titled “When nobody answers”If no resume arrives before deadline_at, the run ends timed_out with
failure_class: "timeout" and failure_detail: "client_tool_deadline". The model is not
handed an invented answer. The run.end frame and your callback_url, if you set one, both
report it.
A turn with both kinds of tools
Section titled “A turn with both kinds of tools”The model may call one of your tools and a platform tool in the same turn. The run suspends first, before anything executes, and only asks you about your calls. Once you resume, the platform tools run, and the model gets every result in the order it asked for them.
Approval rules (a write tool, an interrupt rule on a tool, a require_confirmation
guardrail) apply to platform tools only. A call to one of your tools is never held for an
approval: the platform does not execute it, so there is nothing for it to approve. If your
side needs a person to confirm, ask them before you answer.
Replaying a run
Section titled “Replaying a run”POST /runs/{run_id}/replay makes a new run from the original’s input, and that new run
always runs with client_tools: "end". A replay is usually made long after the fact, by
somebody other than the client that could answer, so it does not wait for one. Submit a new
run with "client_tools": "suspend" if you want the cycle again.
Conversations
Section titled “Conversations”Results are stored in the session like any other tool result. The next run you submit with the
same session_key sees the call and your answer in its history, exactly as they happened. Send
client_tools: "suspend" on every run of the conversation in which you can answer.