Apps on OZ

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 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. 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 supplies the agent state.

The chat app example is a complete app on this route.

The route

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 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 reaches the agents its scopes name.

The body is a RunAgentInput, at most 4 MiB:

FieldWhat OZ does with it
threadIdThe conversation: letters, digits and ._:@-, 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 403 permission_denied.
runIdThe client's own. The same run sent again follows the same turn: a retry never starts a second one.
messagesThe 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 The history.
resumeThe answers to the interrupts of the run before: see Resuming.
toolsRefused with 400: the agent's tools run in OZ, under the tenant's tool policy.
stateIgnored: the agent's state is the job tree's board.
context, forwardedProps, parentRunIdIgnored.

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

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

OZAG-UI
The run startsRUN_STARTED, then STATE_SNAPSHOT of the board (when the caller may read it)
The agent writes textTEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END: 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 AI label
The agent calls a tool, and its resultTOOL_CALL_START (with the message it belongs to as parentMessageId), TOOL_CALL_ARGS with the arguments as OZ records them, TOOL_CALL_END, then TOOL_CALL_RESULT
A note is pinned to the boardSTATE_DELTA, right after the result of the oz.board_write that pinned it
A job the turn delegated to, or a flow's stepSTEP_STARTED and STEP_FINISHED, named by the step's job ID (up to 32 steps open at once; a step past that is not shown)
A card drawn from an answer that matches its schemaCUSTOM named oz0.card, with {"job": ..., "card": ...}: 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 wall from the caller sends none)
The turn waits for the callerRUN_FINISHED with outcome: {type: "interrupt", interrupts: [...]}
The turn waits only for other people's decisionsCUSTOM named oz0.waiting, with {"waiting": [...]}; the run goes on once they decide
The turn endsRUN_FINISHED with outcome: {type: "success"}, or {type: "cancelled"} for a stopped job
The turn fails, or the run is refusedRUN_ERROR with a message and a code

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), interrupts_open, interrupt_expired, interrupt_changed, not_a_decider and invalid_resume (see 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).

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 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, 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.

What waitsreasonWhat the interrupt carries
A tool call's approvaltool_calltoolCallId, the call's; message; responseSchema for {"approved": true or false, "comment": "..."} (and confirm, for a call that asks a person to type an argument's value)
An agent's questioninput_requiredmessage, the agent's question with the AI label at its start; responseSchema for {"answer": "..."} with the question's choices as an enum, or a typed question's fields in the standard field set
Anything else that waits: a hook's escalation, a changeset, a restart, a budget waitconfirmationmessage, and the approval's responseSchema; a budget wait's is empty, since an admin's budget decision ends it

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:

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, 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 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 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 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), 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.

Copyright © 2026