---
title: "TypeScript SDK"
description: "@oz0/client, the typed TypeScript client of the runtime API for Node 24, with typed agents per tenant."
canonical_url: "https://docs.oz0.ai/build/apps/sdk-typescript"
---
# 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](https://docs.oz0.ai/build/apps/runtime-api) through [connect-es](https://connectrpc.com/docs/web/), using messages generated from `runtime.proto` by `protoc-gen-es`. Its dependencies are `@connectrpc/connect`, `@connectrpc/connect-web` and `@bufbuild/protobuf`.

<callout icon="i-ph-info">

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

</callout>

## Connect

```ts [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](https://docs.oz0.ai/build/apps/runtime-api/authentication#authentication). 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.

<callout icon="i-ph-warning">

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](https://docs.oz0.ai/build/apps/live-feeds/browser-and-access) and [AG-UI routes](https://docs.oz0.ai/build/apps/ag-ui). Use those routes with their documented browser credentials and allowed origins.

</callout>

## Run an agent

```ts [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](https://docs.oz0.ai/agents/conversations)). The job says which turn it is.

```ts [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](https://docs.oz0.ai/build/apps/runtime-api#job-ids-and-retries)).

`candidate: <id>` starts a job on a [private candidate](https://docs.oz0.ai/build/plugins/develop-and-test/candidate-checks#try-it-on-a-real-cluster) instead of the installed version. Every RPC of the API, `getConversation` and `listConversations` included, is typed on `oz0.runtime`.

## Watch, questions and approvals

```ts [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](https://docs.oz0.ai/build/apps/sdk-python#watch-questions-and-approvals) 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](https://docs.oz0.ai/build/apps/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):

```ts
// 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](https://docs.oz0.ai/build/apps/live-feeds/browser-and-access#other-origins-cors).

## 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](https://docs.oz0.ai/build/apps/runtime-api/authentication#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:

```ts
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](https://docs.oz0.ai/build/apps/chat-app) 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](https://docs.oz0.ai/reference/list-pages). `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`.

```ts
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](https://docs.oz0.ai/agents/conversations/access-and-history#chats-yours-private-by-default) 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.

```ts
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

```bash [Terminal]
npx oz0-client generate --tenant acme --ca-file ca.crt --out src/agents.ts
```

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


## Sitemap

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