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
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:
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 (create-oke web) | vault.env("PUBLIC_API_URL") ?? "" — empty = same-origin proxy |
| 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.
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
| 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.
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(…) |
// 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/manifestFull 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:
- Path params —
:id(and friends) filled from matching input keys. - Leftover fields — query string on GET/HEAD; JSON body otherwise.
- QUERY — always JSON (
{}when only path params remain). - 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 bodyCall 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 → 204See 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
| 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 (network / 5xx) |
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 |
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 |
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 !== nullRemote 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
| 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 |
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
Confirm the Flow is exported from a generated unit (import "@/flows/generated" 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.
Types alone do not choose REST. Pass createClient(app, url), or
createClient(url, { $routes: app.$routes }), or an explicit routes map.
Network failure, abort (timeout), non-JSON body, empty error response, or HTTP status without a
{ data, error } envelope. Declared flow codes (NotFound, FlightFull, …) never use this code.
Message text lives in error.data.message; HTTP status may appear as error.data.status.
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].
The proxy path stopped at a unit. Call a Flow: api.bookings.get({id}), not api.bookings().
Learn more
- Overview — ClientLoop, Flows only
- Auth — Bearer, refresh, denials
- Live —
api.liveand live queries - React — hooks package
- Vault · Config —
PUBLIC_API_URL,vault.env - Routing —
$routesstamps - HTTP — verbs and envelopes on the server
- Errors — framework codes vs failure values
- CLI —
oke client add,oke dev