Client

Calling

createClient forms, REST vs RPC, options, result envelopes, store resources, and remote types.

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

The one rule

Treat every call as a result envelope: { data, error }. Switch on error.code. Only transport / protocol problems use code: "TransportError".

Smallest Example

Create the client from the app

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") ?? "");

Confirm a booking and narrow the result

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:

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

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:

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(),
});
RuntimeBase URL for createClient
Browser / Bun (create-oke web)vault.env("PUBLIC_API_URL") ?? "" — empty = same-origin proxy
Node / worker / CLIvault.env.required("PUBLIC_API_URL")
Separate storefront after oke client addSame 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.

Progressive Patterns

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

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

createClient forms

FormTypes fromWire
createClient(app, url, opts?)typeof appREST from app.$routes; untriggered flows → RPC
createClient<App>(url, opts?)Explicit App typeRPC 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.

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

SituationRequest
HTTP trigger with method + path on $routesThat 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 triggerPOST {base}/_oke/{unit}/{flow} with JSON body
Incomplete proxy path (api.bookings() with no flow)Result error: Incomplete path: api.bookings(…)
// 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.

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

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

Resources

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

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 for the list query language. Handwritten lists use fx.json.withQuery(rows, input) for the same envelope. Auth posture: Gate. Live mounts: Live.

Options

OptionTypeDefaultMeaning
fetch(input, init?) => Promise<Response>globalThis.fetchInject a fetch implementation
headersRecord<string, string> | pairs | () => …Static headers, or a getter per request
timeoutnumber (ms)Abort after this many milliseconds
retry.retriesnumber0Extra attempts after the first (network / 5xx)
retry.delaynumber (ms)50Initial backoff delay
retry.backoffnumber2Multiplier after each retry
auth.getToken() => string | null | …Bearer access token (or null) — see Auth
auth.refresh() => Promise<string | null | …>Runs once on HTTP 401, then the request retries
$routesClientRouteMapRuntime map from app.$routes (REST when method+path present)
routesRecord<"unit.flow", { method, path }>Flat REST table; wins over flattening $routes

Result envelopes

Success may include optional top-level meta (for example pagination). Declared flow errors and transport failures share the failure arm:

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

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

if (isOk(result)) {
  result.data.confirmationCode;
} else if (result.error.code === "FlightFull") {
  result.error.data.seatsLeft;
} else if (isTransportError(result.error)) {
  result.error.data.message;
}

isFail(result); // true when error !== null

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):

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

create-oke starters ship web/: Vite proxies Flow paths plus /auth and /_oke to the app. Leave PUBLIC_API_URL unset in local web so createClient("") stays same-origin.

CLI details: CLI Reference.

Exports

ExportKindRole
createClientfunctionTyped proxy api.unit.flow(input?) plus api.live
flattenRoutesfunction$routes → flat unit.flow REST table
createTransportfunctionLow-level HTTP transport (timeout / retry / auth)
isOk / isFailfunctionEnvelope predicates
isErrorCode / isTransportErrorfunctionError narrowing
Client, ClientCall, ClientResult, …typesContracts, page.next() / for await of list()
RegisterinterfaceModule-augmentation slot for ambient App types
AppOftypeBrand a bare route map as an App

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

Troubleshooting

Learn more

  • Overview — ClientLoop, Flows only
  • Auth — Bearer, refresh, denials
  • Liveapi.live and live queries
  • React — hooks package
  • Vault · ConfigPUBLIC_API_URL, vault.env
  • Routing$routes stamps
  • HTTP — verbs and envelopes on the server
  • Errors — framework codes vs failure values
  • CLIoke client add, oke dev

Next

On this page