A chat in your own app
For: application developers.
Use examples/chat-app in your checkout as a starting point for a product or intranet chat. It runs agents on Orchestrator Zero and lets you supply your own colours, sign-in and users. Copy the app or use its implementation as a guide.
The example currently requires access to the private repository. Public source downloads are coming soon.
- A backend for the frontend (Node 24, no framework) signs people in with your company's OpenID Connect provider, keeps the app's key, and exchanges each person's ID token for a token that acts as that person (apps that act as their people). Every job it starts and every approval it decides is the person's own.
- A browser UI in React on TanStack AI's
useChat: the person's chats, a thread whose answers stream in, approvals and questions answered inline, and the AI label in every answer and every question the agent asks. - AG-UI all the way: the browser POSTs an AG-UI
RunAgentInputand reads AG-UI events over Server-Sent Events. Orchestrator Zero speaks AG-UI itself on its AG-UI route; the backend passes each run there as the person and adds only what TanStack AI's client needs beyond the protocol. Any AG-UI client can take the UI's place.
What the browser holds
Only an HttpOnly session cookie, which its scripts cannot read, and the text of its chats. The app's key, the person's ID token, the person's app token (oz0at_...) and stream tickets (oz0st_...) stay in the backend. The browser talks only to the backend, on the backend's own origin, so the tenant's CORS list stays empty.
Set it up
- At your identity provider, register a client for the chat (separate from the console's), with the redirect URI
https://<your chat's address>/auth/callback. - Register the app as a tenant owner, with the provider (one of the cluster's) and that client ID:Terminal
oz0 app create company-chat --tenant acme --provider company --client-id chat-web --content \ --permission jobs.start --permission jobs.read.own --permission jobs.cancel.own \ --permission questions.answer.own --permission approvals.decide --permission feeds.read
It prints the app's key once: put it in a file only the backend reads.jobs.read.ownkeeps each person to their own chats;--contentlets people who see what was said see it through the app, which continuing a chat needs;approvals.decidelets them decide, through the app, what they could decide in the console. - Each person signs in to the cluster once through the same provider, in the console or with
oz0 login: the edge finds people by the provider's subject and makes no accounts. - Run the backend with the settings its README lists (
OZ0_URL,OZ0_TENANT,OZ0_APP_KEY_FILE,OIDC_ISSUER,OIDC_CLIENT_ID,CHAT_PUBLIC_URL,CHAT_AGENTS), behind your TLS proxy. The edge takes a person's ID token for an hour after its issue, so the backend refreshes it with the refresh token the provider gave: addoffline_accesstoOIDC_SCOPESwhere your provider gives refresh tokens only for that scope, or people sign in again after an hour.
Your brand is CSS variables: a copy of the example's web/brand.css with your colours, font and corner radius for both themes, named by CHAT_BRAND_CSS. Serve the chat from the same site as your product (a path or a subdomain), so its session cookie is sent; CHAT_FRAME_ANCESTORS names the pages that may frame it.
What goes over the wire
The backend forwards the browser's RunAgentInput to POST /v1/agui/agents/{agent} with the person's app token and passes the events back, each with its ID. The AG-UI page says what the route sends: the agent's messages with the AI label at their start, tool calls and their results, steps, the job tree's board as the agent's state, cards as oz0.card, and a turn that waits as an interrupt (tool_call, input_required or confirmation).
The next run's resume entries are the person's answers, decided with their own token: the route takes them only from a person, so the app's key alone decides nothing. A dropped stream resumes from Last-Event-ID on any edge (and on any backend instance once the sessions live in a shared store; the example keeps them in one process's memory). The job keeps waiting durably while nobody answers; reloading the page brings the question back: the backend follows the waiting turn on the route and hands its interrupts to the page.
A stream checks the person's token when it opens, so a run that streams for longer than the token's 15 minutes goes on; when the route ends a stream after half an hour, the browser opens it again from its last event, with a fresh token.
Try it
make e2e-chat-app
It runs a dev server, a node, a fake identity provider and a fake model, and drives the chat in a browser: sign-in, a streamed answer with its label, an approval, and a question answered across a reload.