---
title: "Calling"
description: "createClient forms, REST vs RPC, match/explain for unknown errors, options, envelopes, and remote types."
icon: "Braces"
source: "docs/spec/unified-theory.md"
---

`createClient` builds a typed proxy over your adopted Flows. HTTP triggers go out as REST from
`$routes`; untriggered Flows fall back to RPC. Every call returns `{ data, error }` — never a
thrown domain failure.

For developers wiring a storefront, mobile app, or partner service to an okengine backend (port
**6530**).

<Callout title="The one rule">
  Treat every call as `{ data, error }`. Use `match` from `okengine/client/explain` for success plus
  unknown failures. Name a code only when this screen has special recovery.
</Callout>

## Smallest Example

<Steps>

<Step>
### Create the client from the app

```typescript title="web/src/api.ts"
import { createClient } from "okengine/client";
import { vault } from "okengine/vault";
import { app } from "../../src/app";

export const api = createClient(app, vault.env("PUBLIC_API_URL") ?? "");
```

</Step>

<Step>
### Confirm a booking and narrow the result

```typescript
const { data, error } = await api.bookings.get({ id: "bkg_7f3a" });

if (error) {
  // show “booking not found” or a network banner
  return;
}

renderConfirmation(data.confirmationCode, data.seats);
```

Envelope:

```json
{
  "data": {
    "id": "bkg_7f3a",
    "confirmationCode": "SK-4812",
    "seats": 2,
    "status": "confirmed"
  },
  "error": null
}
```

</Step>

</Steps>

## Base URL (Vault + env)

Do not paste origins into source. Declare the public API origin as Vault config, then read it from
env in each runtime:

```typescript title="src/core/vault.ts"
import { vault } from "okengine/vault";
import { z } from "zod";

export const publicApiUrl = vault.config("PUBLIC_API_URL", {
  description: "Browser-facing API origin",
  schema: z.string().url(),
});
```

| Runtime                                    | Base URL for `createClient`                               |
| ------------------------------------------ | --------------------------------------------------------- |
| Browser / Bun                              | `vault.env("PUBLIC_API_URL") ?? ""` — empty = same origin |
| Node / worker / CLI                        | `vault.env.required("PUBLIC_API_URL")`                    |
| Separate storefront after `oke client add` | Same `vault.env("PUBLIC_API_URL") ?? ""`                  |

Set `PUBLIC_API_URL` via environment or `oke vault set`. Prefer `vault.env` over raw
`process.env` — same helpers as the rest of the measure. See
[Vault · Config](/docs/elements/vault/config).

## Progressive Patterns

From a simple read to typed domain failures and transport errors:

<Tabs items={["Minimal", "Narrow", "Failures", "Transport", "Binary"]}>

<Tab value="Minimal">

```typescript
const { data, error } = await api.bookings.get({ id: "bkg_7f3a" });
if (error) return;
showSeatMap(data.seats);
```

</Tab>

<Tab value="Narrow">

```typescript
import { isOk } from "okengine/client";

const result = await api.orders.get({ id: "ord_9c2e" });

if (isOk(result)) {
  result.data.trackingNumber;
  result.data.eta;
}
```

</Tab>

<Tab value="Failures">

```typescript
import { match } from "okengine/client/explain";

const result = await api.bookings.create({
  flightId: "SK481",
  seats: 2,
  cabin: "economy",
});

match(result, {
  ok: (data) => show(data.confirmationCode),
  FlightFull: (data) => offerWaitlist(data.seatsLeft),
  auth: () => goSignIn(),
  invalid: (e) => setForm(e.fields),
  _: (e) => toast(e.message),
});
```

One import. Named codes narrow `data`. Kind arms (`auth`, `invalid`) cover chrome. `_` is required
— store auto-map, transport, and unknown domain codes land there. Full table: [`match` /
`explain`](#match--explain).

</Tab>

<Tab value="Transport">

```typescript
import { isTransportError } from "okengine/client";

const result = await api.orders.list({ limit: 20, status: "shipped" });

if (result.error && isTransportError(result.error)) {
  showOfflineBanner(result.error.message); // === error.data.message
  // optional: result.error.data.status
}
```

Network failure, abort (`timeout`), non-JSON body, or HTTP status without a `{ data, error }`
envelope — never a declared Flow code. Prefer `error.message`; it always matches `error.data.message`.

</Tab>

<Tab value="Binary">

```typescript
const { data, error } = await api.files.download({ id: "file_1" }, { response: "blob" });
if (error || !data) return;
a.href = URL.createObjectURL(data);
a.download = "report.pdf";
```

Same `{ data, error }` envelope; `{ response: "arrayBuffer" }` for raw bytes. Default remains
JSON envelopes.

</Tab>

</Tabs>

## `match` / `explain`

A `create` can return `ValidationError`, store auto-map (`Conflict`, `DatabaseError`), gates,
`TransportError`, plus any domain code. Do not switch on every `error.code`.

```typescript
import { match } from "okengine/client/explain";

const result = await api.bookings.create({ flightId: "SK481", seats: 2 });

match(result, {
  ok: (data) => show(data.confirmationCode),
  FlightFull: (data) => offerWaitlist(data.seatsLeft),
  auth: () => goSignIn(),
  invalid: (e) => setForm(e.fields),
  _: (e) => toast(e.message),
});
```

<Callout title="Dispatch order">
  `ok` → named code (`FlightFull`) → UX kind (`auth`, `invalid`) → required `_`. Kind names are
  reserved — do not use `auth` or `failed` as domain codes.
</Callout>

Import from `okengine/client/explain` so the 5 kB `okengine/client` graph stays small. `isOk` /
`isFail` stay on `okengine/client` when you only need a predicate.

| Helper                        | When                      |
| ----------------------------- | ------------------------- |
| `match(result, { ok, …, _ })` | Whole envelope — one call |
| `matchError(error, { …, _ })` | You already have `error`  |
| `explain(error)`              | Chrome only — no matcher  |

### Kinds

| `kind`        | Codes                                                                                  | Typical UI                  |
| ------------- | -------------------------------------------------------------------------------------- | --------------------------- |
| `auth`        | `Unauthorized`, `AuthFailed`                                                           | Sign-in                     |
| `permission`  | `Forbidden`                                                                            | “You can’t do that”         |
| `missing`     | `NotFound`; `TransportError` HTTP 404                                                  | Empty state                 |
| `conflict`    | `Conflict`, `ForeignKey`                                                               | Retry or pick another value |
| `invalid`     | `ValidationError`, `InvalidQuery`, `UnsupportedMediaType`; `DatabaseError` 422 reasons | Form fields                 |
| `limited`     | `RateLimited`, `AuthRateLimited`                                                       | Wait / retry (`retryable`)  |
| `unavailable` | `ServiceUnavailable`; `DatabaseError` `retryable`; other `TransportError`              | Offline / retry             |
| `failed`      | `InternalError`, unknown `DatabaseError`, unnamed domain codes                         | Toast `message`             |

Named code arms receive typed `data`. Kind arms and `_` receive `{ kind, code, message, retryable,
fields? }`. `_` is required even if you listed every kind.

`explain(error).fields` maps `ValidationError` issues to `{ email: "Required" }` (`.`-joined
path; empty path is `_`). `retryable` is true for `limited` and `unavailable`; `retryAfterMs` is
set when the payload has `retryAfterMs` or `retryAfter` (seconds).

```typescript
import { explain, matchError } from "okengine/client/explain";

if (result.error) {
  matchError(result.error, {
    FlightFull: (data) => offerWaitlist(data.seatsLeft),
    _: (e) => toast(e.message),
  });
}

explain(result.error!); // { kind, code, message, retryable, fields? }
```

`error.code === "FlightFull"` still narrows `error.data` if you prefer an `if`. HTTP status is on
the response, not on `error`.

Domain codes with no `{ message }` toast the code name (`OutOfStock`). Pass
`fx.fail("OutOfStock", data, { message })` on the Flow, or name that code in `match`.

See [Errors](/docs/reference/errors) for `fx.fail` helpers and store auto-map.

## `createClient` forms

| Form                            | Types from                             | Wire                                             |
| ------------------------------- | -------------------------------------- | ------------------------------------------------ |
| `createClient(app, url, opts?)` | `typeof app`                           | REST from `app.$routes`; untriggered flows → RPC |
| `createClient<App>(url, opts?)` | Explicit `App` type                    | RPC unless `opts.$routes` or `opts.routes`       |
| `createClient(url, opts?)`      | Ambient `Register` (`oke-client.d.ts`) | Same — pass routes for REST                      |

`url` is the app origin from `vault.env("PUBLIC_API_URL")` — empty string means same-origin.
See [Base URL](#base-url-vault--env).

**Consequence:** `createClient<App>(url)` alone types the proxy but still posts
`POST /_oke/{unit}/{flow}` until you pass the app value, `$routes`, or `routes`.

## REST vs RPC

| Situation                                             | Request                                                                                                                                                        |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP trigger with method + path on `$routes`          | That method and path (`:id` filled from input; leftover fields → query on GET/HEAD, JSON body otherwise). QUERY always sends JSON (`{}` when only path params) |
| Adopted flow with no HTTP trigger                     | `POST {base}/_oke/{unit}/{flow}` with JSON body                                                                                                                |
| Incomplete proxy path (`api.bookings()` with no flow) | Result error: `Incomplete path: api.bookings(…)`                                                                                                               |

```typescript
// REST — method/path from the bound HTTP trigger (file tree or explicit)
await api.bookings.get({ id: "bkg_7f3a" }); // GET /bookings/bkg_7f3a

// RPC — untriggered flow named bookings.manifest (internal tally)
await api.bookings.manifest({ flightId: "SK481" }); // POST /_oke/bookings/manifest
```

Full file-tree rules: [Routing](/docs/understand/routing).

## How input becomes a request

Before the wire call, the client maps `input` onto the REST template or the RPC body:

1. **Path params** — `:id` (and friends) filled from matching input keys.
2. **Leftover fields** — query string on GET/HEAD; JSON body otherwise.
3. **QUERY** — always JSON (`{}` when only path params remain).
4. **RPC** — whole input as JSON on `POST /_oke/{unit}/{flow}`.

```typescript
await api.orders.get({ id: "ord_9c2e" });
// GET /orders/ord_9c2e

await api.orders.list({ limit: 20, q: "stockholm" });
// GET /orders?limit=20&q=stockholm  (when list is GET on $routes)

await api.bookings.create({ flightId: "SK481", seats: 2 });
// POST /bookings  with JSON body
```

## Call shapes

<Tabs items={["unit.flow", "List pager", "for await", "Empty 204"]}>

<Tab value="unit.flow">

```typescript
const { data, error } = await api.orders.get({ id: "ord_9c2e" });
if (error) return;
data.trackingNumber;
```

</Tab>

<Tab value="List pager">

```typescript
const page = await api.orders.list({ limit: 20, status: "open" });
const more = await page.next();

// meta.next / meta.prev are { cursor } bags — spread them, or use page.next()
```

TanStack `useInfiniteQuery` takes the bag, not the methods:

```typescript
useInfiniteQuery({
  queryKey: ["orders", status],
  queryFn: ({ pageParam }) => api.orders.list({ limit: 20, status, ...pageParam }),
  initialPageParam: {} as { cursor?: string },
  getNextPageParam: (last) => last.meta.next ?? undefined,
  getPreviousPageParam: (last) => last.meta.prev ?? undefined,
});
```

</Tab>

<Tab value="for await">

```typescript
for await (const page of api.orders.list({ limit: 20, status: "shipped" })) {
  for (const order of page.data) renderRow(order);
}
```

</Tab>

<Tab value="Empty 204">

```typescript
await api.orders.remove({ id: "ord_9c2e" }); // DELETE → 204, data undefined
```

</Tab>

</Tabs>

## Resources

Mount a resource, adopt the returned ops, then call the five Flows like any other:

```typescript
const ordersR = store.resource(db, orders, {/* in, out, list */});
const mounted = on(http.resource("/orders", ordersR.all()).gate(member));
// .adopt({ orders: mounted }) →
const page = await api.orders.list({ limit: 20, status: "open" });
await api.orders.get({ id: "ord_9c2e" }); // GET /orders/:id — NotFound when missing
await api.orders.remove({ id: "ord_9c2e" }); // DELETE → 204
```

See [Store](/docs/elements/store) for the list query language. Handwritten lists use
`fx.json.withQuery(rows, input)` for the same envelope. Auth posture:
[Gate](/docs/elements/gate). Live mounts: [Live](/docs/client/live).

## Options

| Option           | Type                                           | Default            | Meaning                                                                                                                 |
| ---------------- | ---------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `fetch`          | `(input, init?) => Promise<Response>`          | `globalThis.fetch` | Inject a fetch implementation                                                                                           |
| `headers`        | `Record<string, string>` \| pairs \| `() => …` | —                  | Static headers, or a getter per request                                                                                 |
| `timeout`        | `number` (ms)                                  | —                  | Abort after this many milliseconds                                                                                      |
| `retry.retries`  | `number`                                       | `0`                | Extra attempts after the first. With `retry` set, a call retries when it is GET/QUERY or it carries an idempotency key. |
| `retry.delay`    | `number` (ms)                                  | `50`               | Initial backoff delay                                                                                                   |
| `retry.backoff`  | `number`                                       | `2`                | Multiplier after each retry                                                                                             |
| `auth.getToken`  | `() => string \| null \| …`                    | —                  | Bearer access token (or null) — see [Auth](/docs/client/auth)                                                           |
| `auth.refresh`   | `() => Promise<string \| null \| …>`           | —                  | Runs once on HTTP 401, then the request retries                                                                         |
| `$routes`        | `ClientRouteMap`                               | —                  | Runtime map from `app.$routes` (REST when method+path present)                                                          |
| `routes`         | `Record<"unit.flow", { method, path }>`        | —                  | Flat REST table; wins over flattening `$routes`                                                                         |
| `idempotencyKey` | `string` \| `false`                            | 21-character key   | `Idempotency-Key` on this call. `false` sends no header. Omitted on GET.                                                |

With `retry` configured, a call retries when it is GET/QUERY or it carries an
idempotency key (automatic on every non-GET call). `idempotencyKey: false` runs
once unless that call passes `{ retry: true }`.

A `409` whose code is `IdempotencyInProgress` waits for `Retry-After` and retries
within those attempts. Other error envelopes are returned as-is.

## Result envelopes

Success may include optional top-level `meta` (for example pagination). Declared flow errors and
transport failures share the failure arm. Handle them with [`match`](#match--explain).
A replay sets `meta.idempotentReplayed` when the server sends `Idempotent-Replayed: true`.

```typescript
import { isOk, isFail, isTransportError } from "okengine/client";

isFail(result); // true when error !== null
isOk(result); // narrows to success `data`
isTransportError(result.error); // network / non-envelope HTTP
```

`error.code === "FlightFull"` still narrows `error.data`. HTTP status is on the response, not on
`error`.

## Remote types

`oke dev` regenerates `oke-client.d.ts` from `GET /_oke/client.json`. A separate storefront repo —
pass the origin from Vault / env (never a hardcoded host in source):

```bash
oke client add "$PUBLIC_API_URL"
oke client add "$PUBLIC_API_URL" --out ./types/oke-client.d.ts
# also writes oke-client.routes.ts (or types/oke-client.routes.ts)
```

```typescript
import { createClient } from "okengine/client";
import { routes } from "./oke-client.routes.ts";

const api = createClient(import.meta.env.PUBLIC_API_URL ?? "", { $routes: routes });
```

Ambient `.d.ts` types `in` / `out` / live / stream stamps. The routes module supplies wire REST.

Point `createClient` at your app origin (`PUBLIC_API_URL`), or leave it empty when the
browser page is same-origin with the API.

CLI details: [CLI Reference](/docs/reference/cli).

## Exports

| Export                                    | Kind      | Role                                                |
| ----------------------------------------- | --------- | --------------------------------------------------- |
| `createClient`                            | function  | Typed proxy `api.unit.flow(input?)` plus `api.live` |
| `flattenRoutes`                           | function  | `$routes` → flat `unit.flow` REST table             |
| `createTransport`                         | function  | Low-level HTTP transport (timeout / retry / auth)   |
| `isOk` / `isFail`                         | function  | Envelope predicates                                 |
| `isErrorCode` / `isTransportError`        | function  | Error narrowing                                     |
| `match` / `matchError` / `explain`        | function  | `okengine/client/explain` — `ok`, codes, kinds, `_` |
| `Client`, `ClientCall`, `ClientResult`, … | types     | Contracts, `page.next()` / `for await` of `list()`  |
| `Register`                                | interface | Module-augmentation slot for ambient App types      |
| `AppOf`                                   | type      | Brand a bare route map as an App                    |

Budget: the `./client` export stays under the measured client-runtime cap (hard gate in CI).

## Troubleshooting

<Accordions>

<Accordion title="api.bookings.get is not a function / type error">
  Confirm the Flow is `export`ed from a generated unit (`import "@/flows"` then
  `oke({ name })`), or from a module you still `.adopt({ bookings })`.

Type `createClient` with that `App` (or ambient `Register` after `oke-client.d.ts` regenerates).
Restart `oke dev` after renaming exports.

</Accordion>

<Accordion title="Calls hit /_oke/… instead of my HTTP path">
  Types alone do not choose REST. Pass `createClient(app, url)`, or
  `createClient(url, { $routes: app.$routes })`, or an explicit `routes` map.
</Accordion>

<Accordion title='error.code is "TransportError"'>
  Network failure, abort, non-JSON, or HTTP without a `{ data, error }` envelope. Flow codes never
  use this code. Prefer `error.message` — it equals `error.data.message`. `explain` maps HTTP 404
  transport to kind `missing`.
</Accordion>

<Accordion title="Why import from okengine/client/explain?">
  `match` / `explain` stay off the measured `okengine/client` gzip cap. Predicates (`isOk`,
  `isTransportError`) stay on `okengine/client`.
</Accordion>

<Accordion title="Toast showed OutOfStock / FlightFull">
  Domain codes with no `{message}` use the code name as `explain.message`. Handle the named arm, or
  pass `fx.fail("OutOfStock", data, {message})` on the Flow.
</Accordion>

<Accordion title="404 — route miss or NotFound?">
  Bare body `Not Found` means no HTTP route — `TransportError` (kind `missing`). JSON
  `{ error: { code: "NotFound" } }` is `fx.fail.notFound`. Distinguish by envelope, not status.
</Accordion>

<Accordion title="Failed to fetch …/_oke/client.json">
  `oke client add` needs a running app that serves the descriptor. Start the app (`oke dev` /
  `oke start`), check the URL, then retry. Usage when the URL is missing:
  `Usage: oke client add <url> [--out oke-client.d.ts]`.
</Accordion>

<Accordion title="Incomplete path: api.bookings(…)">
  The proxy path stopped at a unit. Call a Flow: `api.bookings.get({id})`, not `api.bookings()`.
</Accordion>

</Accordions>

## Learn more

- [Overview](/docs/client) — ClientLoop, Flows only
- [Auth](/docs/client/auth) — Bearer, refresh, denials
- [Live](/docs/client/live) — `api.live` and live queries
- [React](/docs/client/react) — hooks package
- [Vault · Config](/docs/elements/vault/config) — `PUBLIC_API_URL`, `vault.env`
- [Routing](/docs/understand/routing) — `$routes` stamps
- [HTTP](/docs/elements/flow/http) — verbs and envelopes on the server
- [Errors](/docs/reference/errors) — `fx.fail` helpers, store auto-map, `match` on the client
- [CLI](/docs/reference/cli) — `oke client add`, `oke dev`

## Next

<Cards>
  <Card
    title="Auth"
    description="Sessions and gate denials on the client."
    href="/docs/client/auth"
  />
  <Card
    title="Live"
    description="SSE subscribe and live resource queries."
    href="/docs/client/live"
  />
  <Card
    title="Routing"
    description="Folders stamp HTTP paths and client units."
    href="/docs/understand/routing"
  />
</Cards>
