---
title: "AG-UI for your apps"
description: "Run OZ's agents from an app built on any AG-UI client, with approvals and questions as the protocol's interrupts that only the person resumes, and the job tree's board as the agent's state."
canonical_url: "https://docs.oz0.ai/build/apps/ag-ui"
---
# AG-UI for your apps

> Run OZ's agents from an app built on any AG-UI client, with approvals and questions as the protocol's interrupts that only the person resumes, and the job tree's board as the agent's state.

For: application developers.

Connect an [AG-UI](https://docs.ag-ui.com) client, such as TanStack AI or CopilotKit, to run agents through the edge. Orchestrator Zero implements AG-UI 1.0, so the app does not need an Orchestrator Zero client.

Each run is one turn of a [conversation](https://docs.oz0.ai/agents/conversations). Text, tool calls, steps and cards stream as AG-UI events. A turn that needs a person ends with an interrupt, and the job tree's [board](https://docs.oz0.ai/agents/colleagues#the-board) supplies the agent state.

The [chat app example](https://docs.oz0.ai/build/apps/chat-app) is a complete app on this route.

## The route

```http
POST /v1/agui/agents/<agent>?tenant=<tenant>
Content-Type: application/json
Authorization: Bearer <app token, or API key>
Accept: text/event-stream
```

It is on every edge's runtime API, and on the management port for people signed in to the console. It takes the runtime API's callers: an [app token](https://docs.oz0.ai/build/apps/runtime-api/authentication#apps-that-act-as-their-people) that acts as a person, a person's session, or a tenant API key. The caller is checked before the request is read. The caller needs `feeds.read`, the content permission (a thread carries what was said), and `jobs.start.granted` for the agent to start a turn. A [scoped key](https://docs.oz0.ai/build/apps/runtime-api/authentication#keys-scoped-to-agents-and-flows) reaches the agents its scopes name.

The body is a `RunAgentInput`, at most 4 MiB:

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      What OZ does with it
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        threadId
      </code>
    </td>
    
    <td>
      The conversation: letters, digits and <code>
        ._:@-
      </code>
      
      , at most 200. The first run creates it, owned by the caller when the caller is a person; a thread started with an API key belongs to the key's tenant and no person owns it. A thread the caller may not see, such as someone else's private chat, is refused with <code>
        403
      </code>
      
       <code>
        permission_denied
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        runId
      </code>
    </td>
    
    <td>
      The client's own. The same run sent again follows the same turn: a retry never starts a second one.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        messages
      </code>
    </td>
    
    <td>
      The newest user message is the turn (text, or text parts; images and files are refused). The history before it is OZ's own and is checked, never taken: see <a href="https://docs.oz0.ai/build/apps/ag-ui#the-history">
        The history
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        resume
      </code>
    </td>
    
    <td>
      The answers to the interrupts of the run before: see <a href="https://docs.oz0.ai/build/apps/ag-ui#resuming">
        Resuming
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        tools
      </code>
    </td>
    
    <td>
      Refused with <code>
        400
      </code>
      
      : the agent's tools run in OZ, under the tenant's <a href="https://docs.oz0.ai/govern/tool-policy">
        tool policy
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        state
      </code>
    </td>
    
    <td>
      Ignored: the agent's state is the job tree's board.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        context
      </code>
      
      , <code>
        forwardedProps
      </code>
      
      , <code>
        parentRunId
      </code>
    </td>
    
    <td>
      Ignored.
    </td>
  </tr>
</tbody>
</table>

The answer is `text/event-stream`: one AG-UI event per `data:` line, each with an `id:`.

```bash [Terminal]
curl -N --cacert ca.crt "https://edge.example.com:7443/v1/agui/agents/helper?tenant=acme" \
  -H "Authorization: Bearer $APP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"threadId": "chat-1", "runId": "run-1", "messages": [{"id": "u1", "role": "user", "content": "Please greet the team"}]}'
```

## What a run streams

<table>
<thead>
  <tr>
    <th>
      OZ
    </th>
    
    <th>
      AG-UI
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      The run starts
    </td>
    
    <td>
      <code>
        RUN_STARTED
      </code>
      
      , then <code>
        STATE_SNAPSHOT
      </code>
      
       of the board (when the caller may read it)
    </td>
  </tr>
  
  <tr>
    <td>
      The agent writes text
    </td>
    
    <td>
      <code>
        TEXT_MESSAGE_START
      </code>
      
      , <code>
        TEXT_MESSAGE_CONTENT
      </code>
      
      , <code>
        TEXT_MESSAGE_END
      </code>
      
      : a message for each answer the agent gives (a new one after a tool call, or where the model starts a new block of text), its first content starting with the tenant's <a href="https://docs.oz0.ai/govern/ai-labels">
        AI label
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      The agent calls a tool, and its result
    </td>
    
    <td>
      <code>
        TOOL_CALL_START
      </code>
      
       (with the message it belongs to as <code>
        parentMessageId
      </code>
      
      ), <code>
        TOOL_CALL_ARGS
      </code>
      
       with the arguments as OZ records them, <code>
        TOOL_CALL_END
      </code>
      
      , then <code>
        TOOL_CALL_RESULT
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      A note is pinned to the board
    </td>
    
    <td>
      <code>
        STATE_DELTA
      </code>
      
      , right after the result of the <code>
        oz.board_write
      </code>
      
       that pinned it
    </td>
  </tr>
  
  <tr>
    <td>
      A job the turn delegated to, or a flow's step
    </td>
    
    <td>
      <code>
        STEP_STARTED
      </code>
      
       and <code>
        STEP_FINISHED
      </code>
      
      , named by the step's job ID (up to 32 steps open at once; a step past that is not shown)
    </td>
  </tr>
  
  <tr>
    <td>
      A card drawn from an answer that matches its schema
    </td>
    
    <td>
      <code>
        CUSTOM
      </code>
      
       named <code>
        oz0.card
      </code>
      
      , with <code>
        {"job": ..., "card": ...}
      </code>
      
      : the turn's answer before the run ends, and a step's when it finishes, for a caller who may see that step's content (a card is the answer it shows, so a step behind a <a href="https://docs.oz0.ai/govern/taint-and-labels">
        wall
      </a>
      
       from the caller sends none)
    </td>
  </tr>
  
  <tr>
    <td>
      The turn waits for the caller
    </td>
    
    <td>
      <code>
        RUN_FINISHED
      </code>
      
       with <code>
        outcome: {type: "interrupt", interrupts: [...]}
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      The turn waits only for other people's decisions
    </td>
    
    <td>
      <code>
        CUSTOM
      </code>
      
       named <code>
        oz0.waiting
      </code>
      
      , with <code>
        {"waiting": [...]}
      </code>
      
      ; the run goes on once they decide
    </td>
  </tr>
  
  <tr>
    <td>
      The turn ends
    </td>
    
    <td>
      <code>
        RUN_FINISHED
      </code>
      
       with <code>
        outcome: {type: "success"}
      </code>
      
      , or <code>
        {type: "cancelled"}
      </code>
      
       for a stopped job
    </td>
  </tr>
  
  <tr>
    <td>
      The turn fails, or the run is refused
    </td>
    
    <td>
      <code>
        RUN_ERROR
      </code>
      
       with a <code>
        message
      </code>
      
       and a <code>
        code
      </code>
    </td>
  </tr>
</tbody>
</table>

Messages and tool calls are the turn's own. The text and calls of the jobs it delegated to stay inside their steps; their approvals and questions come as interrupts whose `metadata` names the step's job (`oz0/job`). Steps are AG-UI steps, not subagent runs, so no interrupt carries a `subagentRunId`. A tool call's ID is the model's own, so the call, its result and its approval's interrupt name the same `toolCallId`. Credentials are never in a call's arguments: a tool call that needs one carries a placeholder, and that is what the stream shows.

`RUN_ERROR`'s codes: `history_mismatch`, `interrupts_pending` and `turn_waiting` (see [The history](https://docs.oz0.ai/build/apps/ag-ui#the-history)), `interrupts_open`, `interrupt_expired`, `interrupt_changed`, `not_a_decider` and `invalid_resume` (see [Resuming](https://docs.oz0.ai/build/apps/ag-ui#resuming)), `revoked` (the caller may no longer read the thread), `failed` or `timed_out` (the job's end), and the runtime API's own codes, such as `permission_denied`.

A budget, a quota or a tree's limit that refuses the start, or ends the job, makes `code` the refusal's own type, such as `oz0_quota_exceeded` or `oz0_tree_limit`, instead of `resource_exhausted` or `failed`, and puts its numbers in the event's `rawEvent.oz0`: the scope, when it resets, whether the job could wait, and who can raise it, with another person's or team's figures only when you may read them (see [Spend refusals](https://docs.oz0.ai/reference/spend-refusals)).

## Reconnecting

The job runs on when the stream drops, and the run's events go on from where the client was: send the same request again with `Last-Event-ID` set to the last event's `id`. Any edge takes it, with nothing missed and nothing twice, even from the middle of a burst of events that one step of the job made. A stream ends after half an hour, as the runtime API's [Server-Sent Events](https://docs.oz0.ai/build/apps/live-feeds/browser-and-access#server-sent-events) do, and the client reconnects the same way.

When the job's stream itself skips some of the turn's events (the run fell too far behind the job, or the job continued as new and left them behind), the message it skipped into ends there, the rest of that part of the text is left out, and the run ends with the turn's whole answer as a message of its own, never a cut one.

A run sent again without `Last-Event-ID` (the same `threadId` and `runId`) follows its turn again from the start. A run whose messages hold the thread's latest turn as their newest user message, as a page loaded afresh sends, follows that turn from its start too.

## Interrupts

A turn that waits ends its run with every wait of its job tree the caller answers, oldest first: for a person, the approvals and questions they may decide, checked as a decision in OZ checks a yes (their role, the approval's [deciders](https://docs.oz0.ai/agents/approvals/configure#who-decides), four eyes); for a caller that is no person, every wait, which only a person resumes. A step only someone else may approve, such as one under four eyes in the caller's own job, is no interrupt of the caller's.

What other people decide is listed beside the interrupts in the event's `metadata` as `oz0/waiting-for-others` (each with its job, what waits, who decides, and the console's link), and is left to them. When the tree waits only for others, the run does not end: a `CUSTOM` `oz0.waiting` event lists the same, and the run streams on once they decide. The job waits on in OZ; the stream's end is not the job's. OZ reads what waits from each job's own state, so an interrupt is shown as soon as the step asks, before Temporal's visibility lists it.

<table>
<thead>
  <tr>
    <th>
      What waits
    </th>
    
    <th>
      <code>
        reason
      </code>
    </th>
    
    <th>
      What the interrupt carries
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      A tool call's <a href="https://docs.oz0.ai/agents/approvals">
        approval
      </a>
    </td>
    
    <td>
      <code>
        tool_call
      </code>
    </td>
    
    <td>
      <code>
        toolCallId
      </code>
      
      , the call's; <code>
        message
      </code>
      
      ; <code>
        responseSchema
      </code>
      
       for <code>
        {"approved": true or false, "comment": "..."}
      </code>
      
       (and <code>
        confirm
      </code>
      
      , for a call that asks a person to type an argument's value)
    </td>
  </tr>
  
  <tr>
    <td>
      An agent's <a href="https://docs.oz0.ai/agents/asking-people">
        question
      </a>
    </td>
    
    <td>
      <code>
        input_required
      </code>
    </td>
    
    <td>
      <code>
        message
      </code>
      
      , the agent's question with the AI label at its start; <code>
        responseSchema
      </code>
      
       for <code>
        {"answer": "..."}
      </code>
      
       with the question's choices as an <code>
        enum
      </code>
      
      , or a typed question's fields in the <a href="https://docs.oz0.ai/agents/approvals/edits-and-repeat-calls#the-field-set">
        standard field set
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Anything else that waits: a hook's escalation, a changeset, a <a href="https://docs.oz0.ai/agents/approvals/recovery#restart-waits">
        restart
      </a>
      
      , a <a href="https://docs.oz0.ai/agents/approvals/recovery#loop-and-budget-waits">
        budget wait
      </a>
    </td>
    
    <td>
      <code>
        confirmation
      </code>
    </td>
    
    <td>
      <code>
        message
      </code>
      
      , and the approval's <code>
        responseSchema
      </code>
      
      ; a budget wait's is empty, since an admin's budget decision ends it
    </td>
  </tr>
</tbody>
</table>

Every interrupt has an `expiresAt`, its deadline: when nobody answers by then, OZ denies it. Its `metadata` holds `oz0/kind`, `oz0/what`, `oz0/job`, `oz0/details` (a call's arguments as the card shows them, for callers with the content permission), `oz0/choices`, `oz0/deciders` (who may decide, in words), `oz0/typed` for a typed question, `oz0/ai-label`, and `oz0/console`: `{"label": "Decide in OZ", "url": ...}`, the job's page in the console, where the operator set a public URL.

## Resuming

The next run of the same thread answers the interrupts with its `resume` entries:

```json [RunAgentInput]
{"threadId": "chat-1", "runId": "run-2", "messages": [],
 "resume": [{"interruptId": "ozi1.eyJy...", "status": "resolved", "payload": {"approved": true, "comment": "go ahead"}}]}
```

- Only a person resumes: an app token acting as its person, or a person's session. The person decides under every rule a decision in OZ meets: the approval's [deciders](https://docs.oz0.ai/agents/approvals/configure#who-decides), four eyes, the re-check after a long wait, and the content permission for an answer. A tenant runtime API key or an operator's certificate may start and follow runs, but a resume with either is refused with `403`, with the console's link where people decide. A registered app's key is accepted only for token exchange; exchange it with a person's token to act as that person.

### Every open interrupt at once

A resume that leaves out an interrupt of the caller's that still waits ends with `RUN_ERROR` `interrupts_open`. A new user message on a thread whose turn waits for the caller ends with `interrupts_pending`, and on one that waits only for others with `turn_waiting`, which names who decides: the thread goes on once they do. A turn whose waits the server can read only in part (more than it reads at once, or written by a newer runtime than the server's) also ends a new message with `turn_waiting`, since it may still wait.

### All or nothing

Every check a decision in OZ meets before it is made runs for every entry before any is decided. An answer past its deadline ends the run with `RUN_ERROR` `interrupt_expired`, one for a call that changed since it was shown with `interrupt_changed`, one the person may not decide that way (someone else's interrupt, or approving under four eyes) with `not_a_decider`, and a payload the schema does not take with `invalid_resume`.

The other checks (a typed confirmation that must match, an edit, the re-check after a long wait, and, for a routine's outputs, that the approval shows the outputs that wait) end it with `RUN_ERROR` `permission_denied` or `failed_precondition`, with the entry and the reason. Then none of the entries is decided. A yes the re-check refuses is the one exception to "none": as in OZ, the platform denies that step, with the reason, and decides none of the others.

- What can fail only while deciding (the job does not take its update, or someone else decided the same outputs a moment before) leaves the entries decided before it decided, since a decision a job has taken cannot be taken back. The `RUN_ERROR` then names the entries decided before it, which stand, and those not decided, and the `oz0.resume` events before it say the same for each: answer the rest in the next run.
- `cancelled` denies, and declines a question.
- The same resume twice decides once. The run says how it took each entry with a `CUSTOM` event named `oz0.resume`: `{"interruptId": ..., "outcome": "decided" | "already_decided" | "acknowledged", "approved": ...}`, with `approved` only for a decision this resume made. Then the turn streams on from where it waited: its messages get IDs of their own, and the steps still open start again, so each finishes, with its card, in the run where it ends.

Each decision is the person's in the audit trail, with `via: app:<id>` for an app.

## The history

OZ holds the thread's turns, so it checks the messages a client sends against them and never trusts them: the user messages count the turns (one more for a new turn), and the last assistant message before the newest user message, when the client has one, must be one OZ sent in the thread's latest turn (its `messageId` names that turn's job).

A run whose messages count as many turns as the thread follows its latest turn only when its newest user message is that turn's: its `id` is `<job>:in` (the turn's job, as a thread loaded from OZ names it) or its words are the turn's. A mismatch ends the run with `RUN_ERROR` `history_mismatch`: load the thread again. A client that lost a turn and sends a new question is refused, never answered with the turn before's answer. System and developer messages are the client's own and reach no agent: the agent's instructions are its own.

## The agent's state

The run's state is the job tree's board, as `{"board": {"notes": [...]}}`. Each note has `id` (its number in the tree, as a string), `kind`, `tags`, `text`, `job` and `agent` (who wrote it), `createdAt`, `supersedes` (the note it replaces), `tainted` (written by a job that held outside input) and its `labels`. A `STATE_DELTA` is an [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902) patch: `add` at `/board/notes/-` for a new note, `replace` at a note's index for a note that supersedes it. A note another job of the tree writes shows with the next board write the run sees.

The board's rules hold. A caller who may not read the tree's content gets no state events at all. A note behind a [wall](https://docs.oz0.ai/govern/taint-and-labels) from the caller goes as `{"redacted": true, "label": ...}` with its ID, kind, writer and time, never its text or tags. Showing notes' text is recorded in the audit trail (`job.board.read`), as reading the board is.

## Taint, policy and audit

An AG-UI turn starts untrusted, with the reason `agui`, as a call through the MCP server or A2A does: the text came from an app, so a tool that can send data out [asks a person](https://docs.oz0.ai/govern/taint-and-labels) first. Budgets, quotas, tool policy, data rules and works-council mode apply as they do to every start. The run's `run.start` entry in the audit trail records `door: agui` beside how the caller signed in (`via`).

## From a browser

A page on another origin reads the route only when the tenant's admins list that origin (`oz0 tenant cors <tenant> --allow-origin`; see [Other origins](https://docs.oz0.ai/build/apps/live-feeds/browser-and-access#other-origins-cors)), and its preflight allows `Content-Type` and `Last-Event-ID`. A browser never sends a session across origins, and an app token belongs in your backend, not the page: the chat app example keeps it in its backend and passes the run through.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
