Tools and shared state
A turn can stop for a human to approve a tool call, hand a tool to your frontend to run, or publish state your UI renders as it changes.
Tool approvals
An agent can pause a turn on a sensitive tool call until a human decides.
When a session has a decision pending, the next chat call against it
returns 409 before doing any work, so you never stream fresh input into a
paused thread:
{
"error": {
"message": "This conversation is waiting on a human decision. Resolve it before sending a new message.",
"type": "invalid_request_error",
"code": "approval_pending",
},
}A pause needs a durable session. Send x-stackbone-session (see
Session keys): a stateless turn has
no thread an approval could wake, so the agent runs the tool instead of
pausing.
The pending tool call itself already streamed to you as a normal
tool_calls (OpenAI) or tool_use (Anthropic) block before the turn
stopped. A caller that already inspects tool calls sees nothing unusual
until it tries to send the next message. You make the decision out of band,
not over this wire: from Stackbone Studio, or from the CLI with
stackbone hitl list and stackbone hitl approve <id> --yes /
stackbone hitl reject <id> --yes.
Tool approvals over AG-UI
AG-UI models the same pause as a first-class interrupt, and lets the
client resolve it instead of going through Stackbone Studio. A paused run
closes with a RUN_FINISHED carrying an interrupt outcome instead of a plain
success:
{
"type": "RUN_FINISHED",
"threadId": "thread-1",
"runId": "run-1",
"outcome": {
"type": "interrupt",
"interrupts": [
{
"id": "int_abc",
"reason": "tool_call",
"toolCallId": "call_1",
"description": "Refund order ord_42 for 19.99",
"allowedDecisions": ["approve", "reject"],
"responseSchema": { "…": "…" },
},
],
},
}allowedDecisions tells you which of approve, reject and edit this pause
accepts, and responseSchema is the JSON Schema of the payload it expects
back, so a generic client can render the pause without knowing the tool.
A pause that carried no description omits the field.
You resolve it by sending the decision back in the next request's
top-level resume array, addressed to the interrupt's id:
{
"threadId": "thread-1",
"runId": "run-2",
"messages": [],
"resume": [{ "interruptId": "int_abc", "status": "resolved", "payload": { "approved": true } }],
}Reject a call with { "approved": false }, or edit its arguments before it
runs with { "editedArgs": { "...": "..." } }. A status: "cancelled"
entry always rejects. Sending a fresh message to a thread with an
unresolved interrupt still fails, but as a protocol event on the stream
(a RUN_ERROR) rather than a bare 409. AG-UI clients watch the
event stream, never a response status code.
Frontend-executed tools
RunAgentInput.tools declares tools that only make sense in the browser: an
API only reachable from the client, reading local state, or opening a
modal. When the agent calls one of these, the run finishes normally
(outcome: { "type": "success" }), not as an interrupt: there's nothing to
approve, so your frontend runs the tool itself.
You send the result back as a trailing role: "tool" message on the same
threadId, and the agent picks up where it left off:
{
"threadId": "thread-1",
"runId": "run-3",
"messages": [{ "role": "tool", "toolCallId": "call_2", "content": "{\"opened\":true}" }],
}This requires a durable thread, the same as tool approvals: a stateless
run (no threadId) has nothing to resume against.
Shared state and generative UI
Beyond messages, an agent can publish a shared state object for your
UI to render live, for example a task list or a work-in-progress draft.
A run opens with a STATE_SNAPSHOT (the full object) and closes with a
STATE_DELTA when something changed, an RFC 6902
JSON Patch you apply with any off-the-shelf patch library:
{ "type": "STATE_DELTA", "delta": [{ "op": "replace", "path": "/todos/0/done", "value": true }] }You can seed that state from the UI too, by sending state on a request.
The agent's own code decides which keys a client is allowed to write, with
editableState on defineDeepAgent(...):
export default defineDeepAgent({
name: 'planner',
model: 'openai/gpt-4o-mini',
editableState: {
draft: { type: 'string' },
},
});The agent drops any key not listed there from an incoming state payload. Like
frontend tools, seeding requires a durable thread: there's no graph state
to write to on a stateless run.
If you reopen an existing thread, the run also opens with a
MESSAGES_SNAPSHOT, the full prior conversation, so your UI doesn't have
to replay history itself. Whenever the agent hands off to a subagent,
the run brackets that stretch of work with STEP_STARTED /
STEP_FINISHED, so you can show which phase is running.