Apps on OZ

TypeScript SDK

@oz0/client, the typed TypeScript client of the runtime API for Node 24, with typed agents per tenant.

For: application developers.

Use @oz0/client from Node 24 to start agents and flows, wait for answers, watch jobs, answer questions, decide approvals, read the catalog and inspect cost.

The SDK calls the runtime API through connect-es, using messages generated from runtime.proto by protoc-gen-es. Its dependencies are @connectrpc/connect, @connectrpc/connect-web and @bufbuild/protobuf.

The package is not on npm yet: the name is reserved for the first public release. Until then, install it from a checkout of the repository with npm install ./sdk/typescript.

Connect

app.ts
import { Client } from "@oz0/client";

const oz0 = new Client({ baseUrl: "https://edge.example.com:7443", apiKey: process.env.OZ0_API_KEY, tenant: "acme", caFile: "ca.crt" });
  • The key is a tenant API key. In Node, clientCert: { cert, key } takes an operator's certificate instead.
  • The CA: caFile or caCert (PEM) trusts the cluster's CA for this client only, in Node, without changing the process's TLS defaults.
  • In Node, without options, the client reads OZ0_URL, OZ0_API_KEY, OZ0_TENANT and OZ0_CA_FILE.
  • timeoutMs is each call's (30 s by default); streams have none. fetch takes a fetch of your own, interceptors more Connect interceptors.
Keep tenant API keys on your backend. Their permissions and scopes can allow starting agents and reading jobs; never put them in code a browser downloads. The SDK's Connect RPC routes have no CORS support. Browser code needs a same-origin backend or proxy that supplies its own authentication.The edge does support tenant-configured CORS on its browser-facing stream and ticket routes and AG-UI routes. Use those routes with their documented browser credentials and allowed origins.

Run an agent

app.ts
import { outputOf } from "@oz0/client";

const job = await oz0.run("acme-reviewer", { repo: "acme/api", pr: 42 }, { timeoutMs: 900_000, budgetUsd: 0.5, tags: ["ci"] });
console.log(outputOf(job), job.cost?.usd);

run throws JobFailed (with the job in .job) when the job did not complete; check: false returns it instead, and waitMs limits the wait. start, wait(id, { waitMs, signal }) and get(id) are the steps. The input goes as JSON.stringify writes it: a property that is undefined is left out and a Date is its ISO string. outputOf(job) is the answer as JSON. Errors are connect-es's ConnectError, with a Code such as Code.InvalidArgument for an input that does not fit the agent's schema.

Conversations

continueFrom: <job> makes the job the next turn of that job's conversation, and conversation: <id> the next turn of the conversation with that ID (its first, when there is none yet): the agent gets the earlier turns (Conversations). The job says which turn it is.

app.ts
const first = await oz0.run("acme-assistant", "Our release is on Friday.");
const second = await oz0.run("acme-assistant", "When is the release?", { continueFrom: first.id });
console.log(second.turn, second.conversation, outputOf(second));

start and run retry a start that meets unavailable twice, with the same key: without id, each start sends an Idempotency-Key of its own, so the server gives the retries the same job, and with an ID the retries repeat it. A start again under an ID with the same request returns that job, running or ended; another request under it rejects with Code.AlreadyExists (job IDs and retries).

candidate: <id> starts a job on a private candidate instead of the installed version. Every RPC of the API, getConversation and listConversations included, is typed on oz0.runtime.

Watch, questions and approvals

watch.ts
const job = await oz0.start("acme-asker", "Pick a colour for the report");
for await (const ev of oz0.watch(job.id)) {
  if (ev.event.case === "approvalRequested" && ev.event.value.kind === "question") await oz0.answer(ev.event.value, "blue");
  if (ev.event.case === "text") process.stdout.write(ev.event.value);
  if (ev.event.case === "finished") console.log(outputOf(ev.event.value));
}

Leaving the loop early stops the stream. approvals({ job, tree }) and questions(...) list what waits for a person, approve, deny and answer decide it, and cancel, jobs, catalog, cost and openapi({ etag }) do what the Python SDK's methods of the same names do. Every other method is on oz0.runtime, typed, and the messages and enums are exported from the package.

watch resumes a broken stream by itself after the last event (sinceSeq and the resume token), so no event comes twice; an event whose case is resync says a gap could not be resumed over, and the job is fetched again. watch(id, { sinceSeq, resumeToken }) resumes a watch an earlier process made.

Feeds

oz0.feed(conversation(id), job(id), myActivity()) follows many chats and jobs on one stream; iterate feed.events(), which resumes by itself on any edge, change what it follows with subscribe and unsubscribe, and resume it in another process with state() and resumeFrom(state). See Live feeds.

Server-Sent Events in a browser

A browser's EventSource cannot send your API key, and the key must not reach the browser. Ask for a one-time stream ticket where the key lives, and open the stream in the browser with eventStream, which asks for a new ticket and resumes after the last event whenever the stream breaks (a used ticket is refused):

// Your backend, with the key:
const ticket = await oz0.streamTicket({ job: jobId }); // or { feed: feed.id } from oz0.createFeed(...)

// The browser, with only tickets your backend hands it:
import { eventStream } from "@oz0/client";
const stop = eventStream(
  { ticket: () => fetch(`/my-backend/oz0-ticket?job=${jobId}`).then((r) => r.json()) },
  {
    baseUrl: "https://edge.example.com:7443",
    onEvent: (name, data) => name === "text" && append((data as { text: string }).text),
    onEnd: (why) => console.log("ended:", why), // finished, revoked, or an error
  },
);

oz0.createFeed(...subscriptions) makes a feed for SSE and answers its id (for its events' URL) and its token (for runtime.updateFeed; never put it in a URL). Pass names: feedEventNames to eventStream for a feed. A job's stream ends on finished, revoked or error; a feed's only on error, since a feed's revoked is one subscription's and the others go on (endOn chooses other names). A page on another origin needs the tenant to allow it: see Live feeds.

An app that acts as each person

A backend whose people sign in with your identity provider exchanges each person's ID token for an app token that acts as them (apps that act as their people), and makes its calls with it. exchangeToken posts the exchange with the app's key, and follows no redirect, since fetch would carry the person's token along it; a Client with a token function asks it for the current token on every call:

import { Client, exchangeToken, TokenExchangeError } from "@oz0/client";

const appKey = process.env.OZ0_APP_KEY!;
let current = await exchangeToken({ baseUrl, appKey, subjectToken: idToken, caFile: "ca.crt" });
const asThePerson = new Client({
  baseUrl, tenant: "acme", caFile: "ca.crt",
  token: async () => {
    if (current.expiresAt.getTime() - Date.now() < 60_000) current = await exchangeToken({ baseUrl, appKey, subjectToken: idToken, caFile: "ca.crt" });
    return current.accessToken;
  },
});
await asThePerson.start("helper", "hello", { conversation: "chat-1" }); // the person's own turn

A refused exchange throws TokenExchangeError with OAuth's code (invalid_grant when the person must sign in again). The key and the tokens stay in the backend. The chat app example is a whole backend and browser UI built this way. tlsFetch({ caFile }) is the fetch that trusts the cluster's CA, for the routes you call yourself, and toJson turns a message into the JSON the runtime API's HTTP routes use.

Lists, page after page

The runtime API's lists that can grow come a page at a time. allJobs(), allApprovals(), allConversations(), allConnections() and allRoutineFires() are async iterables over every row that read the next page only when the loop reaches it, so a loop that stops early reads no further; jobs(limit) is the first limit rows of allJobs() (20 when it is 0 or less), read in pages of that size up to 100, and approvals() reads every page. The exported pages() does the same for any other list method of client.runtime.

for await (const job of client.allJobs({ mine: true })) console.log(job.id, job.status);

A person's chats

myChats() yields the caller's own chats and those shared with them, page after page; updateChat() pins, archives, marks read, renames (the owner) or removes one from the caller's list, which erases nothing. Both need a person's credentials: an app's key or an operator's certificate has no chats.

import { ChatAction } from "@oz0/client";

for await (const chat of client.myChats({ agent: "helper" })) console.log(chat.id, chat.title, chat.unread);
await client.updateChat("conv-1", ChatAction.PIN);

Typed agents

Terminal
npx oz0-client generate --tenant acme --ca-file ca.crt --out src/agents.ts
app.ts
import { Agents } from "./agents.js";

const agents = new Agents(oz0);
const review = await agents.runAcmeReviewer({ repo: "acme/api", pr: 42 }); // an AcmeReviewerOutput

The module has an interface per object of the agents' schemas, and per agent and flow a method that starts it and one that runs it and returns its typed answer. It records the catalog's ETag and the generator's version, and is left alone until the catalog changes or the package is upgraded; oz0 client generate --lang ts writes the same file.

Copyright © 2026