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.

BUILT WITH ❤️ FROM CANADA AND SPAIN