Skip to content

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.

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.

Terminal window
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.

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.

Terminal window
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
}'
FieldDefault
client_tools"end""suspend" shows the model your client-owned tools and waits for you when it calls one.
client_tool_timeout_s120How 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.

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.

Run each call and send back one result per call, naming the call by its id:

Terminal window
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.

ResponseCodeWhen
422tool_results_mismatchThe 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.
422validation_failedvalue is not {"tool_results": [...]}, or breaks a limit (more than 64 results, an empty list).
409resume_contract_mismatchThe contract is not result.
409suspension_expiredThe deadline has passed.
409not_suspendedThe 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 in value.

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_suspended when 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_id and 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.

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.

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.

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.

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.