Reference

Client

Typed caller for your flows — createClient from okengine/client, zero codegen, errors as values.

okengine/client is how a browser, CLI, or another service calls your app's flows. Adopt the unit, take typeof app, and api.notes.get({ id }) is fully typed — same contracts the server already has, no separate schema project.

The one rule

Treat every call as a result envelope: { data, error }. Flow failures are values you switch on (error.code); they are never thrown. Only transport / protocol problems use code: "TransportError".

Quick start

Adopt flows and export App

src/app.ts
import { oke } from "okengine";
import * as main from "./flows/main";

export const app = oke({ name: "standard" }).adopt({ main });
export type App = typeof app;

Create the client

Same repo — pass the app value so HTTP triggers hit REST (method + path from adopt):

client
import { createClient } from "okengine/client";
import { app } from "./app";

const api = createClient(app, "http://localhost:6530");

Or type-only with createClient<App>(url) and pass $routes: app.$routes when you want REST instead of RPC.

Call a flow and narrow the result

const { data, error } = await api.main.health();

if (error) {
  // TransportError or a declared flow code
  return;
}

// data inferred from the flow's `out`
console.log(data.ok);

With the starter, that is GET /health on port 6530 when $routes are wired.

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

oke dev regenerates oke-client.d.ts from GET /_oke/client.json. A separate frontend repo runs oke client add <url> (default out: oke-client.d.ts).

oke client add http://localhost:6530
oke client add https://api.example.com --out ./types/oke-client.d.ts

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

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)
Adopted flow with no HTTP triggerPOST {base}/_oke/{unit}/{flow} with JSON body
Incomplete proxy path (api.notes() with no flow)Result error: Incomplete path: api.notes(…)
// REST — createClient(app, url) saw method/path from on(http.get("/notes/:id"), …)
await api.notes.get({ id: "n_1" }); // GET /notes/n_1

// RPC — untriggered flow named notes.stats
await api.notes.stats({ id: "n_1" }); // POST /_oke/notes/stats

Result envelope and helpers

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: "SK1", seats: 9 });

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

isFail(result); // true when error !== null
isErrorCode(result.error, "FlightFull"); // type predicate helper

Prefer error?.code === "FlightFull" for inference; use isErrorCode in shared helpers.

Auth on the client

One client: createClient. With gate.auth, the app exposes /auth/* Flows (sign-in, refresh, me). Helpers under okengine/client/auth store tokens — they are not a second factory.

import { createClient } from "okengine/client";
import { memorySession } from "okengine/client/auth";
import { app } from "./app";

const session = memorySession();

const api = createClient(app, "http://localhost:6530", {
  auth: {
    getToken: () => session.getToken(),
    refresh: () => session.refresh(api),
  },
});

const { data } = await api.auth.signInEmail({ email, password });
if (data) session.set(data);

React: useSession(api, session) from okengine/client-react.

StepWhat happens
Every requestgetToken()Authorization: Bearer … when a token is present
HTTP 401refresh() runs once, then the same call retries
HTTP 403 / 429No refresh — decode the failure envelope as usual

Consequence: refresh must mutate whatever getToken reads. Returning a new string alone does nothing if storage was not updated.

After a gated call, switch on the denial codes (values, not throws):

CodeHTTPerror.dataTypical fix
Unauthorized401{}Sign in, or let auth.refresh run; re-login if still denied
Forbidden403{ gate, reason }Wrong scopes / policy — show denied
RateLimited429{ retryAfterMs }Wait retryAfterMs before retrying

These gate codes are not listed in each Flow’s errors map — they can appear on any gated route.

A 401 with no { data, error } body becomes TransportError with data.status: 401.

HelperPackageRole
memorySessionokengine/client/authIn-memory access/refresh bag for auth.getToken
AUTH_ERROR_CODESokengine/client/authCommon auth Flow / gate codes
useSession(api, session?)okengine/client-reactReact status + auth.me

Core okengine/client stays under the size budget — helpers are separate exports. Not in core today: cookie jars or plugin .client() decorations. Browser apps: also see CORS and CSRF.

Elements from the client

Flows only

The client calls Flows. Every other element runs on the server through fx. You reach its outcome by calling a Flow that uses it — or by handling a gate denial on that call.

ElementOn the clientHow
FlowDirectapi.unit.flow(input) — the only public surface
GateIndirectBearer via auth; denials as Unauthorized / Forbidden / RateLimited
StoreVia Flowsfx.store inside Flows; store.resource + on(http.resource…) → five Flows you adopt
SignalVia FlowsEmit/consume server-side; no subscribe API on okengine/client yet
ClockVia FlowsSchedules fire on the server — the client never ticks a clock
VaultVia FlowsSecrets stay server-side; never ship them to the browser package
ChannelVia Flowsfx.send in a Flow — the client does not send email/SMS/push
AIVia Flowsfx.ask / fx.run inside a Flow; the client gets that Flow’s out

Store resources

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

const notesR = store.resource(db, notes, {/* in, out, list, unit: "notes" */});
const mounted = on(http.resource("/notes", notesR.all()));
// .adopt({ notes: mounted }) →
await api.notes.list({ limit: 20 }); // GET /notes?limit=20 — meta may carry nextCursor
await api.notes.get({ id }); // GET /notes/:id — NotFound when missing
await api.notes.remove({ id }); // DELETE → 204, data undefined

See Store for the list query language and schemas. Auth posture for HTTP triggers is covered under Gate.

Signal and live queries

delivery: "live" and http.get(…).live() are Manifest / driver flags today. okengine/client does not expose WebSocket, SSE, or api.*.subscribe. Until that ships, poll or call an HTTP Flow that returns the current state.

Exports

ExportKindRole
createClientfunctionTyped proxy api.unit.flow(input?)
flattenRoutesfunction$routes → flat unit.flow REST table
createTransportfunctionLow-level HTTP transport (timeout / retry / auth)
isOk / isFailfunctionEnvelope predicates
isErrorCode / isTransportErrorfunctionError narrowing
Client, ClientOptions, ClientResult, …typesContracts and options
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

  • Basic usage — adopt → client → test loop
  • Gate — policies, gate.public, denials
  • Storestore.resource and list query language
  • Flowin / out / errors and fx.fail
  • Errors — framework codes vs failure values
  • CORS · CSRF — browser callers
  • CLI Referenceoke client add, oke dev

Next

On this page