# @nice-code/action

> **Docs:** [nicecode.io](https://nicecode.io) — guides, integrations, and the full API surface.
> **Stability:** [nicecode.io/production/stability](https://nicecode.io/production/stability/) — the pre-1.0 posture, lockstep versioning, and the wire-format skew promise.
> **Working with an AI assistant?** Point it at [nicecode.io/llms-action.txt](https://nicecode.io/llms-action.txt) (just this package) or [nicecode.io/llms.txt](https://nicecode.io/llms.txt) (the whole stack) — the complete, current docs flattened into plain text.

Typed, transport-agnostic action system for calling functions across runtime boundaries (client/server,
worker/worker, peer/peer) with full TypeScript inference — including **bi-directional** calls where the
acceptor pushes actions back over the same connection.

## Install

```bash
bun add @nice-code/action
```

Peer deps: `valibot` (or any [Standard Schema](https://github.com/standard-schema/standard-schema) library), `@tanstack/react-query` (for `@nice-code/action/react-query`).

### React Native / Expo

nice-action is platform-agnostic and uses only **standard** web interfaces — no browser-only APIs (e.g.
base64 goes through `@nice-code/util`'s runtime-agnostic `bytesToBase64`/`base64ToBytes`, not `btoa`/`atob`).
On React Native (Hermes) two standard interfaces are missing and must be polyfilled by the app:

- **WebCrypto** (`crypto.subtle`) — the secure handshake/encryption needs Ed25519 / X25519 / HKDF / AES-GCM.
- **`URL`** — Hermes' built-in `URL` is incomplete; carriers use it to build WS/HTTP urls.

Install the two RN polyfill packages and run them as the **very first** side-effect of your app entry,
before any `ActionRuntime` / secure session is constructed:

```bash
bun add react-native-quick-crypto react-native-url-polyfill
```

```ts
// polyfills.ts — import this first in your entry (e.g. before "expo-router/entry")
import "react-native-url-polyfill/auto";
import { install } from "react-native-quick-crypto";
install(); // patches global.crypto with OpenSSL-backed WebCrypto (SubtleCrypto)
```

`react-native-quick-crypto` needs the new architecture + a recent deployment target (it compiles OpenSSL via
Nitro/JSI). That's the whole requirement — nothing nice-action-specific to install. A complete, working
reference lives in [`packages/demo-expo-frontend`](../demo-expo-frontend) (`src/polyfills.ts` + `index.js`).

> If you hit a runtime failure that "can't happen" per the source (a provably-set value reads as
> `undefined`) only on Hermes, see [`docs/hermes-await-codegen.md`](../../docs/hermes-await-codegen.md) — a
> Hermes codegen gotcha, with reusable on-device crypto/transport diagnostics.

---

## Mental model

One sentence: **a `runtime` links to a `peer` over a `carrier`, and the routing between them is declared
once as a `channel`.** Identity, auth, and encryption work the same regardless of carrier — the only
distinctions that survive every carrier are:

- **role** — who dials (**connector**) vs who accepts and can push back (**acceptor**).
- **shape** — **duplex** (a WebSocket / WebRTC channel: push-capable, the return path for results and
  server pushes) vs **exchange** (HTTP: one request, one reply).

The pieces:

| Concept | What it is |
| --- | --- |
| **ActionDomain** | A named group of typed actions (your API surface) |
| **ActionSchema** | Input/output schema + declared error types for one action |
| **ActionRuntime** | One per runtime; identifies it and dispatches actions to handlers |
| **Channel** | The transport-agnostic routing contract + binary wire identity between two runtimes, declared *by role* (`toAcceptor` / `toConnector`) with `defineChannel`. Security is a per-transport choice, not a channel one |
| **Carrier** | How bytes actually move: `wsCarrier` / `httpCarrier` / `inMemoryCarrier` / `rtcCarrier` (connector side), `wsAcceptorCarrier` / `httpAcceptorCarrier` (acceptor side) |
| **Transport** | A carrier wrapped with a security policy (handshake + optional encryption, or plain). You don't build these directly — `connectChannel` / `serveChannel` apply the policy to each carrier for you |
| **RuntimeCoordinate** | Identifies an environment (frontend, backend, worker…) and is how actions are routed |

> **One runtime per client.** A client (a frontend, a backend, a worker) has a *single* `ActionRuntime`
> identifying it across every peer it talks to — not one per feature or per backend. Register your local
> handlers on it, then `connectChannel(...)` once per peer (or `serveChannel(...)` to accept). This keeps
> one identity (and one crypto identity, for secure channels) per client and avoids routing ambiguity.

The high-level entry points — `connectChannel` (dial out) and `serveChannel` (accept) — are what you
reach for 95% of the time. The lower-level handler/carrier/transport objects they desugar to are
documented at the end under [Lower-level building blocks](#lower-level-building-blocks).

> **Code blocks below are labelled by where they run:** `// shared.ts` (imported by both ends),
> `// server.ts` (the acceptor), `// client.ts` (the connector). A typical app is exactly these three
> files.

---

## Defining actions

### 1. Create a root domain (shared between both ends)

```ts
// shared.ts
import { createActionRootDomain, actionSchema } from "@nice-code/action";
import * as v from "valibot";

// Root domain — no actions, just a namespace anchor
export const appRoot = createActionRootDomain({ domain: "app_root" });

// Child domain with actions
export const userDomain = appRoot.createChildDomain({
  domain: "user",
  actions: {
    getUser: actionSchema()
      .input({ schema: v.object({ userId: v.string() }) })
      .output({ schema: v.object({ id: v.string(), name: v.string() }) })
      .throws(err_user, ["not_found"]),  // from @nice-code/error

    updateName: actionSchema()
      .input({ schema: v.object({ userId: v.string(), name: v.string() }) })
      .output({ schema: v.object({ success: v.boolean() }) }),
  },
});
```

Input schemas run twice — at `request()` and again on the receiving runtime before the handler —
so a schema must accept its own validated output (plain validators always do). Convert types with
the serialization arguments below, not with schema `transform`s.

### 2. Serialization for non-JSON-native types

```ts
createAt: actionSchema()
  .output(
    { schema: v.object({ createdAt: v.date() }) },
    ({ createdAt }) => ({ createdAt: createdAt.toISOString() }),  // serialize
    ({ createdAt }) => ({ createdAt: new Date(createdAt) }),       // deserialize
  ),
```

### 3. Declare thrown errors

```ts
import { defineNiceError, err } from "@nice-code/error";

const err_user = defineNiceError({
  domain: "err_user",
  schema: {
    not_found: err<{ userId: string }>({
      message: ({ userId }) => `User not found: ${userId}`,
      httpStatusCode: 404,
      context: { required: true },
    }),
  },
});

// Attach to an action schema
actionSchema()
  .throws(err_user)                        // any id from err_user
  .throws(err_user, ["not_found"])          // only specific ids
```

---

## Channels — the routing contract

A **channel** declares what flows in each direction between two runtimes, *by role*, so both ends derive
their routing from one shared definition instead of restating domain lists:

- **`toAcceptor`** — domains the connector *sends to* the acceptor (the classic "request").
- **`toConnector`** — domains the acceptor *pushes back to* the connector (the classic "push"). A domain
  can appear in both lists if it's bidirectional.

Define it once in code shared by both ends:

```ts
// shared.ts — both ends import this
import { defineChannel, RuntimeCoordinate } from "@nice-code/action";

export const appChannel = defineChannel({
  toAcceptor: [userDomain, lobbyDomain],  // client → server requests
  toConnector: [lobbyDomain],             // server → client pushes (lobbyDomain is bidirectional)
});

// Runtime coordinates name each side. Define them here, in shared code, so BOTH
// ends reference the same values
export const serverCoord = RuntimeCoordinate.env("backend");
export const frontendCoord = RuntimeCoordinate.env("frontend");
```

A channel carries both its routing (`toAcceptor` / `toConnector` domains) **and** its wire identity — the
positional binary wire dictionary and a version derived from the domains, so the per-connection codec and
version can never drift between the two ends. Whether a given transport actually runs encrypted is a
per-transport choice (`secure` on `connectChannel`'s transports and the acceptor's `securityLevel`), not a
property of the channel — so this one definition serves both plain and secure transports.

> The lists are **positional** for the binary wire dictionary — **add new domains to the end** of their
> list. Reordering shifts the version, and a stale peer is then cleanly rejected by the handshake instead
> of silently misrouting a frame.

---

## Runtimes & handlers

### Local execution (the acceptor's actual work)

A **local handler** runs actions in the current process. Build one and register it on the runtime; this
is what answers incoming requests.

```ts
// server.ts
import { ActionRuntime, createLocalHandler } from "@nice-code/action";
import { serverCoord } from "./shared"; // coordinates come from shared code, never defined here

// Map syntax (preferred)
const userHandler = createLocalHandler().forDomainActionCases(userDomain, {
  getUser: async (action) => {
    const user = await db.users.find(action.input.userId);
    if (!user) throw err_user.fromId("not_found", { userId: action.input.userId });
    return user;
  },
  updateName: async (action) => {
    await db.users.update(action.input.userId, { name: action.input.name });
    return { success: true };
  },
});

// Or one action at a time
const userHandler2 = createLocalHandler()
  .forAction(userDomain.action.getUser, async ({ input }) => db.users.find(input.userId));

// Or wrap an object directly off the domain
const userHandler3 = userDomain.wrapAsLocalHandler({
  getUser: async ({ userId }) => { /* ... */ },
  updateName: async ({ userId, name }) => { /* ... */ },
});
```

`wrapAsPartialLocalHandler` is the same but lets you implement only *some* of a domain's actions (useful
for local-first clients that resolve a few actions themselves and forward the rest).

### Accepting connections — `serveChannel`

`serveChannel` is the one call that stands up an acceptor: it builds the crypto identity **once**, fans it
across every carrier, registers your handlers, wires hibernation, and returns a server object whose
`fetch` / `receive` / `drop` / `pushToClient` you forward straight to the host.

```ts
// server.ts
import {
  ActionRuntime,
  serveChannel,
  wsAcceptorCarrier,
  httpAcceptorCarrier,
} from "@nice-code/action";
import { appChannel, serverCoord } from "./shared";

const runtime = new ActionRuntime(serverCoord);

const server = serveChannel(runtime, appChannel, {
  storage: storageAdapter,                       // backs identity + TOFU key pins (persistent)
  handlers: [userHandler],                        // your local execution
  carriers: [
    wsAcceptorCarrier({ send: (ws, frame) => ws.send(frame), /* upgrade, attachmentStore */ }),
    httpAcceptorCarrier(),                         // HTTP fallback (same channel)
  ],
});

// Wire the host's events to the server — `receive`/`drop` are the universal forwarding seam:
//   fetch(req)                     => server.fetch(req)
//   webSocketMessage(ws, msg)      => server.receive(ws, msg)
//   webSocketClose/Error(ws)       => server.drop(ws)
```

`serveChannel(runtime, channel, options)` returns `{ acceptors, fetch, receive, drop, pushToClient, broadcast }`:

- **`fetch`** — a web-standard handler that does the WS upgrade, the (secure or plain) HTTP action POST,
  CORS preflight, and a `404` fallback. Forward the host's `fetch` straight to it.
- **`receive(conn, frame)` / `drop(conn)`** — the universal connection lifecycle; forward your host's
  message and close/error events here. Routes to the sole duplex carrier (the common single-WebSocket
  case); with several duplex carriers, feed each carrier handle directly.
- **`pushToClient(target, request, opts?)`** / **`broadcast(makeRequest, opts?)`** — push a
  server-initiated action to one / every connected client (see
  [Bi-directional](#bi-directional-communication-acceptor--connector)).
- **`handlers`** — the acceptor handlers it built, one per duplex carrier (reach for these for per-handler
  `broadcast`).

Key options: `storage` (required only when a carrier is secure — the default), `carriers`, `handlers`,
`channelCases` (connection-aware cases — see below), plus `securityLevel` / `link` / `verifyKeyResolver` /
`defaultTimeout`. There's also an optional `clientEnv` you can almost always skip — see the footnote below.

> **Footnote — `clientEnv` (optional, rarely needed).** A result or push to a *connected* client always
> routes back over the carrier it actually connected on (the acceptor knows each client's exact coordinate
> from its handshake), so a normal request/response server needs nothing here. The one case it helps is a
> *scoring fallback for returning to a client that is no longer connected* (the offline-push case). Omitting
> it is also what lets a single `serveChannel` accept clients of *different* envs at once — e.g. a `wallet`
> role and a `partner` role on one bridge — instead of standing up a second `serveChannel` per role.

> On Cloudflare, [`@nice-code/action/platform/cloudflare`](#cloudflare-durable-objects) collapses the
> Durable Object carrier + storage + lifecycle boilerplate into a single `serveDurableObject` call.
>
> Serving in another environment (Bun, Node, …)? `serveChannel`'s carriers are environment-neutral, and
> `serveHost(runtime, channel, hostAdapter, options)` folds a reusable `{ carriers, storage, onServed }`
> host adapter into it — the same seam `serveDurableObject` is built on.

### Connecting — `connectChannel`

The connector has one runtime and `connectChannel`s to each acceptor it talks to. One call binds the
shared facts — the channel (its codec + routing), the runtime, and one crypto identity over `storage` —
into every transport, so you state them *once*. It routes the channel's `toAcceptor` domains out over the
transports (first = preferred, rest = fallback) and registers local handlers for the `toConnector` pushes
from `onPush` — all derived from the channel, no restated lists. It's the exact dial-out dual of
`serveChannel`.

```ts
// client.ts
import {
  ActionRuntime,
  ESecurityLevel,
  connectChannel,
  wsCarrier,
  httpCarrier,
} from "@nice-code/action";
import { appChannel, serverCoord, frontendCoord } from "./shared";

export const clientRuntime = new ActionRuntime(frontendCoord);

connectChannel(clientRuntime, appChannel, {
  peer: serverCoord,
  storage,                            // one crypto identity, fanned across every secure transport
  securityLevel: ESecurityLevel.encrypted,
  transports: [
    { carrier: wsCarrier(() => ({ url: "wss://api.example.com/resolve_action/ws" })) }, // secure WS, preferred
    { carrier: httpCarrier(() => ({ url: "https://api.example.com/resolve_action" })), secure: false }, // plain HTTP fallback
  ],
  // onPush: { ... }  // handlers for the channel's toConnector pushes (see below)
});
```

`connectChannel(runtime, channel, options)` returns the `ChannelConnector` so you can later
`handler.clearTransportCache()` (which also closes any live sockets) on teardown — plus the reliable-tier
surface: `handler.closeReliableStream(action, streamKey?)` and `handler.reliablePending(...)` (see
[Reliable delivery](#reliable-delivery--reliable)). Options:

- **`peer`** — the acceptor's `RuntimeCoordinate` this connection dials.
- **`transports`** — declared by *carrier* `{ carrier, secure? }`, in preference order; all carry the
  channel's `toAcceptor` domains and the manager falls through on failure. `secure` defaults to `true`.
- **`storage`** — one backing store for the connection's crypto identity, fanned across every secure
  transport. Required when any transport is secure; omit for a fully-plain connection. (`link` shares an
  existing identity instead.) **Must be durable, not just present**: the server pins this identity's
  verify key on first contact (TOFU, keyed `envId::perId`), so a store that forgets across reloads —
  a memory adapter — regenerates the key and every load after the first is rejected
  (`identity_pin_mismatch`). Browsers: `createWebLocalStorageAdapter`. Memory adapters are for tests.
- **`securityLevel`** — default level for secure transports (`authenticated` if omitted; override per
  transport with `securityLevel` on the descriptor).
- **`onPush`** — handlers for the channel's `toConnector` pushes (optional; omit for send-only).
- **`defaultTimeout`** — default per-action timeout.
- **`reliableActionTimeout`** — delivery deadline for `.reliable()` sends (default 60s); set it to your
  *longest* deadline class and timed-abort shorter classes down (see
  [Reliable delivery](#reliable-delivery--reliable)).
- **`onReliableEvent`** — handle-less observer for reliable-stream events (`abandoned` with the skipped seq
  range, `overflow` at the unacked cap).

### Several channels on one connection — `connectChannels` / `serveChannels`

When one backend exposes a few independent feature contracts (a core request/reply channel, a lobby push
channel, …) you don't need a connection — or a handshake, or a crypto identity — *per feature*.
`connectChannels` carries **several channels over one connection**, and `serveChannels` is its exact
acceptor dual. The wire dictionary is the union of the channels' domains, but the runtime still routes each
action by its own domain, so the channels stay independent contracts:

```ts
// shared.ts — two independent feature channels
export const coreChannel  = defineChannel({ toAcceptor: [userDomain],  toConnector: [] });
export const lobbyChannel = defineChannel({ toAcceptor: [lobbyDomain], toConnector: [lobbyDomain] });

// client.ts — one handshake + one identity carry both
connectChannels(clientRuntime, [coreChannel, lobbyChannel], {
  peer: serverCoord,
  storage,
  transports: [{ carrier: wsCarrier(() => ({ url: wsUrl })) }],
  onPush: { position_update: async (p) => { render(p); return { acknowledged: true }; } },
});

// server.ts — the exact dual (or `serveDurableObject(ctx, [coreChannel, lobbyChannel], …)` on a DO)
serveChannels(serverRuntime, [coreChannel, lobbyChannel], { storage, handlers, carriers });
```

Because the acceptor *registers* the set, a client connecting only a **subset** still works: each
connection advertises the channel tags it carries in the handshake and the acceptor composes the matching
codec + version for that connection. So an HTTP-only client can connect just `coreChannel` while a
WebSocket client next door connects both — against the *one* endpoint. (Keep separate `connectChannel`
calls only when you genuinely want a separate identity/transport per backend, e.g. an ephemeral
per-resource connection.) `combineChannels([...])` is the underlying primitive if you need the merged
channel value directly.

---

## Calling actions

Once a runtime is wired, calling an action looks the same on **any** side — client or server — and
regardless of where it resolves (locally or over a carrier):

```ts
// any runtime with the domain wired
// Run and get the output directly (throws on a declared/transport error)
const output = await userDomain.action.getUser
  .request({ userId: "u_123" })
  .runToOutput();
console.log(output); // { id: "u_123", name: "Alice" }

// Or keep the RunningAction handle for progress/abort, then await the full result payload
const running = await userDomain.runAction(userDomain.action.getUser.request({ userId: "u_123" }));
const result = await running.waitForResultPayload();
console.log(result.output);
```

---

## Reliable delivery — `.reliable()`

By default an action is **best-effort**: it rides one transport attempt, and a dropped frame or a
mid-flight disconnect surfaces as a transport error for you to handle. That's the right default for a
request/response call you'll just retry. For a **stream of updates that must all arrive, in order, exactly
once** — game moves, a chat feed, incremental sync — opt the action into the reliable tier:

```ts
export const sessionDomain = appRoot.createChildDomain({
  domain: "session",
  actions: {
    // Ordered, at-least-once, deduped for the life of a (resumable) connection.
    host_move: actionSchema().input({ schema: v.object({ n: v.number() }) }).reliable(),

    // Same, but the dedup state is *persisted* so it also survives a server eviction/restart.
    host_event: actionSchema().input({ schema: v.object({ n: v.number() }) }).reliable({ persist: true }),
  },
});
```

`reliable` is the **only** delivery knob, and `persist` its only parameter — there is no per-message policy.
Both ends read the tier from the shared `domain:id`, so nothing extra rides the wire beyond a small
per-frame sequence number (and a cumulative ack coming back). Best-effort actions are completely unaffected —
their frames are byte-identical to before.

### What you get, and the one obligation

- **Ordered** — the handler never sees frames out of order; an out-of-order frame is buffered until the gap
  before it fills.
- **At-least-once** — an unacked frame is resent when the transport reconnects, so a drop or a reconnect
  never loses an update.
- **Deduped within a session** — a resent frame the server already has is acknowledged but **not**
  re-delivered to your handler (for a reply-less `fireAndForget().reliable()` stream), or is re-run and its
  reply regenerated (for a reply-carrying one — see the timing note below).

The obligation is the flip side of *at-least-once*: **reliable handlers must be idempotent.** On a **socket
drop + reconnect to a still-running server**, the client resends its unacked frames and the server — its
dedup state intact — delivers each exactly once (a reply-carrying duplicate may re-run its handler to
regenerate its reply; a reply-less one is suppressed). Make handling an update twice a no-op (upsert by an
id, ignore an already-applied move) and that path is safe.

**The session tier across a server restart (auto re-sync):** if the **server itself restarts or is evicted
mid-stream** (a fresh process, or a hibernated Durable Object woken with its in-memory state gone), it has no
high-water for the stream. When a client that had already progressed resends a frame past seq 0, the fresh
receiver detects the reset and asks the client to **re-sync**: the client renumbers its still-unacked frames
from 0 and resends, so the **in-flight tail continues in order** rather than stalling on an unfillable gap.
What the session tier *can't* recover is frames the old server already acked (they were pruned from the
outbox — they were delivered before the restart). To also survive eviction with the **full** history intact,
use the **`persisted` tier** (it persists the high-water; see **Persisted dedup across eviction** below). A
plain **socket drop + reconnect to a still-running server** is unaffected either way. Use `.reliable()` for a
stream over a resumable connection; add `{ persist: true }` when the *server* must recover acked history
across an eviction.

> **Contract in one line:** *ordered + deduped over a live-or-reconnecting session; the in-flight tail
> re-syncs across a server restart, and the `persisted` tier also recovers acked history across eviction.*

> **Transport:** reliable delivery is a **duplex-carrier** feature (WebSocket / WebRTC / in-memory — a
> persistent connection with a binary codec). Over an **HTTP exchange** (one request, one reply) there's no
> standing connection to resend on and no reliability slot on the wire, so `.reliable()` there is simply
> best-effort. Point a reliable stream at a WebSocket carrier.

> **Direction:** reliable delivery engages when the **dialing side sends to the listening side**
> (connector → acceptor). A server push (`pushToClient` / `broadcast`) of a reliable-declared action is
> delivered **best-effort** — no outbox, no seq — with a one-time dev warning per route. For a push stream
> that must all arrive, build the persisted-log recipe (below); for one confirmed push, await
> `pushToClient(...).waitForResultPayload()` and retry. Two backends that each dial the other get
> reliability both ways — it follows the dialer.

> **Lifetime:** reliability spans **drops and reconnects, not reloads** — the outbox is memory, and a
> fresh boot is a fresh stream space (the runtime auto-generates a per-boot `insId`; that's a load-bearing
> contract, only set `insId` yourself if it's unique per boot). A send that must survive a tab close wants
> the persist-the-input-and-replay pattern: idempotent handlers make the replay safe.

### Resolution timing

Opting into `reliable` changes **when** a reliable action's promise settles, because the point is to keep
retrying rather than fail fast:

- A **reply-carrying** reliable action (`.output(...)`) resolves when its reply arrives — the same as
  best-effort, except a transport drop no longer rejects it; it's resent and resolves after the reconnect.
- A **reply-less** reliable action (`.fireAndForget().reliable()`) resolves **on send** (there's no reply to
  await) — it's handed to the outbox and its promise settles immediately; delivery continues in the
  background across reconnects.
- Either way, **every reliable send carries a delivery deadline** (`reliableActionTimeout`, default 60s,
  settable on `connectChannel`): if the peer hasn't *acknowledged* the frame by then — e.g. it's simply
  unreachable — the frame is **abandoned**. A pending (reply-carrying) action aborts with a
  `reliable_delivery_abandoned` transport error; an already-settled reply-less send surfaces through a
  one-time console warning (its caller was told "success on send" — that's the reply-less contract). So
  `runToOutput()` on a reliable action always terminates, and background delivery never retries forever.

### Abandoned frames — the stream continues

An abandoned frame (delivery deadline expired, or the action was explicitly aborted while unacked) is not
allowed to wedge its stream: the outbox drops it **and every older still-unacked frame with it** (they could
no longer be delivered in order; each fails loudly the same way), then tells the receiver via a small control
frame to **skip past** the abandoned sequence numbers. The receiver advances, delivers anything it had
buffered behind the gap, and the stream *continues* — later sends deliver normally. Ordering among
**delivered** frames is always preserved; an abandoned frame is a surfaced failure, never a silent hole.

### Observing delivery — `waitForAck` and stream events

A reply-less reliable send resolves on send, so its promise can't tell you what became of the frame. Two
surfaces do:

```ts
// Per send — hold the RunningAction:
const running = sessionDomain.action.host_move.request(move).run({ streamKey: runId });
await running.waitForAck(); // resolves when the peer's cumulative ack covers this frame;
                            // rejects with the abandon reason (deadline / abort / sweep / stream close)

// Per connection — no handles (app-level monitoring):
connectChannel(runtime, channel, {
  ...,
  onReliableEvent: (event) => {
    if (event.type === "abandoned") resendFromRecorder(event.fromSeq, event.toSeq); // "skipped seqs N..M"
    if (event.type === "overflow") shedCosmeticTraffic();
  },
});
```

For a best-effort action `waitForAck()` mirrors the action itself, so generic code composes without
branching on the tier. Observers can also listen for the `reliability` update on
`running.addUpdateListeners` (fires when `acked` flips — the devtools chip's `✓`).

The failure surface of a fire-and-forget reliable send, in one line each: `reliable_outbox_overflow`
rejects **at the call**; a connect failure does **not** reject (background retry until the deadline);
deadline abandonment happens **after** the promise resolved — surfaced via `waitForAck` / the `abandoned`
event (+ one console warning per route). A *rejected* settlement means the sender stopped trying, not that
the frame provably never arrived (the ack itself may have been lost) — idempotent handlers cover that.

### Handler-side facts — `action.context.reliability`

The executing handler of a reliable action can read the receiver-side facts for the frame it's handling —
the free idempotency key for exactly-once *effects*:

```ts
host_move: (action, conn) => {
  const rel = action.context.reliability; // { seq, streamKey?, redelivered } | undefined (best-effort)
  this.ctx.storage.sql.exec(
    `INSERT OR IGNORE INTO effects (stream_key, seq, payload) VALUES (?, ?, ?)`,
    rel?.streamKey ?? "", rel?.seq, JSON.stringify(action.input),
  );
},
```

Local-only (never serialized; a reply is byte-identical with or without it), also mirrored as
`conn.reliability` in `channelCases`. Tier nuance: `redelivered: true` appears **only on reply-carrying
re-runs** — a fire-and-forget duplicate is suppressed before dispatch and never reaches the handler at all.

### Backpressure — see it coming, and the overflow cliff

The client's outbox holds unacked frames until they're acknowledged. If a peer stays dead, that can't grow
without bound: each stream has a cap (default 1024 unacked). When a stream first exceeds it, the offending
reliable action fails with a `reliable_outbox_overflow` transport error instead of the outbox growing
forever — surface it as "connection lost, please retry" rather than silently dropping updates.

You don't have to wait for the cliff — the connector exposes read-only pressure stats:

```ts
connector.reliablePending();                                        // total unacked, all streams
const p = connector.reliablePending(sessionDomain.action.host_move, runId);
// p.unackedCount / p.oldestUnackedAgeMs / p.maxUnackedPerStream  →  headroom = max - count
```

### Persisted dedup across eviction — `persist`

For a backend that can be evicted between messages (a Cloudflare Durable Object hibernating, a redeploy), the
session-tier dedup state resets on wake — the session tier then re-syncs the *in-flight* tail (see the
boundary note above), but frames the old server already acked are gone. The `persisted` tier persists the
per-stream high-water so dedup + ordering **survive the eviction outright**: the stream continues exactly
where it left off, and a replayed frame (even one already acked) is deduped rather than redelivered. On
Cloudflare, back it with the DO's SQLite in one line:

```ts
import { serveDurableObject, cloudflareReliableLog } from "@nice-code/action/platform/cloudflare";

const server = serveDurableObject(this.ctx, sessionChannel, {
  runtime,
  clientEnv,
  reliableStore: cloudflareReliableLog(this.ctx), // persisted-tier streams dedup across eviction
  channelCases: { /* … */ },
});
```

`cloudflareReliableLog` needs a **SQLite-backed** Durable Object class (enable it in `wrangler`). Omit
`reliableStore` and `persisted` actions degrade gracefully to the in-memory (session) behavior. The store is a
generic `ReliableLog` over a small synchronous port, so a non-Cloudflare backend can supply its own — see
`@nice-code/action/advanced`.

### Independent streams of one action — `streamKey`

By default a reliable action is **one** ordered stream per `(peer, action)`: every frame shares one sequence.
That's right when the action *is* the stream. When one connection multiplexes several *independent* logical
streams through the **same** action — one per game room, per entity, per chat channel — pass a **`streamKey`**
so each is its own ordered, deduped, independently-resending stream. Per-key ordering is preserved; a gap in
one key never head-of-line-blocks another, and one key's backpressure can't overflow the others.

```ts
// One backend connection, many rooms, one action the client sends — each room id is its own stream.
function sendRoomEvent(roomId: string, event: TRoomEvent) {
  return room.action.host_event.request(event).runToOutput({ streamKey: roomId });
}
sendRoomEvent("alpha", a); // stream (peer, room:host_event#alpha)
sendRoomEvent("beta", b);  // stream (peer, room:host_event#beta) — separate seq space, isolated
```

`streamKey` is a per-request run option (`.run({ streamKey })` / `.runToOutput({ streamKey })`). Omitting it
is byte-for-byte identical to today's single stream. A keyed frame is a distinct wire length, so a peer that
predates keyed streams rejects it (a one-time dev warning surfaces the version skew) — keyed streams need both
ends on a version that supports them.

Because keys are client-chosen strings, the server caps distinct keyed streams per client
(`maxKeyedStreamsPerClient`, default 256) — past the cap, a new key's frames are served best-effort with a
one-time warning rather than minting more receiver state. A **closed** key (below) stops counting; an
idle-but-unclosed one keeps counting.

### Closing a stream — `closeReliableStream`

Keyed streams are created implicitly on first use; when a stream's real-world subject is *over* (the game
run ended, the room was left), close it:

```ts
const connector = connectChannel(runtime, channel, { ... });
// at run teardown:
connector.closeReliableStream(sessionDomain.action.host_move, runId);
```

One call: every still-unacked send on the stream is abandoned (pending actions reject with
`reliable_stream_closed`; settled fire-and-forget sends stop resending; the `abandoned` event fires with
the swept range), the receiver is told to skip past them, and **both sides release the stream's state** —
including the key's `maxKeyedStreamsPerClient` slot. It's synchronous on the sender's state (nothing can
resend after it returns), closing a never-used stream is a no-op, and a closed key can be reused safely.

### Multiplexed peers — many server instances behind one coordinate

> **The one sharp edge to know about.** A reliable stream is identified by
> `(peer coordinate + action route [+ streamKey])`, but frames are *delivered* over whatever transport
> currently dials that coordinate. On Cloudflare the natural shape is many DO instances behind **one**
> peer coordinate, selected by a mutable dial URL (`/session/ws?runId=X` → `idFromName(X)`). If the dial
> state changes while unacked frames sit in the outbox, **retries follow the connection — into a different
> physical instance.**
>
> Two lines close the window: (1) `closeReliableStream(action, runId)` at teardown, *before* changing the
> dial state; (2) defense-in-depth, stamp the entity id in reliable payloads and have the instance drop
> foreign ones. The drop is terminal by contract: **acks track inbox acceptance, not handler outcome** — a
> handler that discards a frame still settles it, so it never retries.

### What reliable delivery deliberately does **not** do

To keep the guarantee knob-free and the implementation maintainable, the following are **out of scope** by
design — don't file them as bugs. Each has a straightforward composition on the levers above (the full
worked recipes live in the reliable-delivery docs page):

- **No message priorities** — `streamKey` lanes (`"critical"` / `"bulk"`) are independently ordered,
  independently backpressured; read pressure per lane with `reliablePending`.
- **No per-message TTLs / deadlines** — set `reliableActionTimeout` to your *longest* class, then timed-abort
  the short class down (`setTimeout(() => running.abort(), ttl)`); an abort sweeps *older* unacked frames on
  the same stream with it, so give differing TTL classes their own `streamKey` lane. `waitForAck` stands the
  timer down early.
- **No exactly-once *effect*** — the wire is exactly-once *delivery within a session*; the durable *effect*
  is your idempotent handler's job, and `action.context.reliability` hands it the free `(seq, streamKey)`
  key (see [Handler-side facts](#handler-side-facts--actioncontextreliability)).
- **No causal ordering across different actions** — send one **envelope** action with a discriminated-union
  input (`v.variant("kind", [vStart, vEvents, vEnd])`) + `streamKey` per entity: one totally-ordered stream,
  the handler is a `switch (input.kind)`. If you're carrying arrival-order tolerance code across several
  actions, collapse to this instead.
- **No reliable fan-out / broadcast** — `broadcast(...)` stays best-effort; reliability is point-to-point on
  the originating connection. For a server→client stream that must all arrive, assemble it from the
  persisted log (recipe below) rather than expecting `broadcast` to buffer for absent clients.

### Recipe: reliable server→client push (built on the persisted log, no new API)

Reliable delivery is point-to-point over a *live* connection; to hold updates for a client that's currently
**offline** (or that must never miss one) and flush them on reconnect, compose the persisted `ReliableLog`
yourself — `append` each push to a per-client log (it assigns the seq; carry it in the payload so the
client can dedup), and on reconnect replay the gap-free prefix the client hasn't acked:

```ts
import { cloudflareReliableLog } from "@nice-code/action/platform/cloudflare";

// One log per recipient (namespaced by client id), living in the DO's SQLite.
const outboxFor = (clientId: string) => cloudflareReliableLog(this.ctx, `push_${clientId}`);

// Enqueue a push while the client may be offline — persisted, not lost. `append` assigns hw+1.
function enqueuePush(clientId: string, update: unknown) {
  const { seq } = outboxFor(clientId).append(streamIdFor(clientId), update);
  server.pushToClient(runtime, clientCoord, pushDomain.action.update.request({ seq, update })); // live path, best-effort
}

// On reconnect, replay everything the client hasn't confirmed, in order…
function flushOnReconnect(clientId: string) {
  for (const update of outboxFor(clientId).contiguousPrefix(streamIdFor(clientId))) {
    server.pushToClient(runtime, clientCoord, pushDomain.action.update.request(update));
  }
}
```

Client-side, dedup/order with your own `ReliableInbox` (exported from `@nice-code/action/advanced`) keyed
by the payload's `seq`, and confirm via a normal action; the server then prunes with the store's
`deleteThrough(streamId, upTo)` — or set `keepDelivered` retention on the log and skip explicit pruning.
`contiguousPrefix()` is the safe-to-replay run; *when* to ack and prune stays your app's policy, which is
exactly why this is a recipe rather than a core feature.

---

## Bi-directional communication (acceptor ⇆ connector)

Over a **duplex** carrier (a WebSocket) the acceptor can call the connector back on the *same* open
connection — no second channel, no polling. The shape:

1. **Declare the push domain in the channel's `toConnector`** (shared by both ends).
2. **On the connector**, handle those pushes with `connectChannel`'s `onPush` — keyed by action id,
   typed from the channel. The reply routes straight back over the same socket.
3. **On the acceptor**, use `server.pushToClient(...)` (one client) or a handler's `broadcast(...)`
   (everyone). The originating client is available on any inbound action as
   `action.context.originClient`.

### Shared channel

```ts
// shared.ts — bidirectional: client sends `start_feed`; server pushes `position_update` back.
export const lobbyDomain = appRoot.createChildDomain({
  domain: "lobby",
  actions: {
    start_feed: actionSchema()
      .input({ schema: v.object({ count: v.number() }) })
      .output({ schema: v.object({ delivered: v.number() }) }),
    position_update: actionSchema()
      .input({ schema: v.object({ player: v.string(), x: v.number(), y: v.number() }) })
      .output({ schema: v.object({ acknowledged: v.boolean() }) }),
  },
});

export const appChannel = defineChannel({
  toAcceptor: [userDomain, lobbyDomain],  // start_feed flows here
  toConnector: [lobbyDomain],             // position_update pushes back here
});
```

### Connector side — handle pushes with `onPush`

```ts
// client.ts
connectChannel(clientRuntime, appChannel, {
  peer: serverCoord,
  storage,
  transports: [{ carrier: wsCarrier(() => ({ url: wsUrl })) }, { carrier: httpCarrier(() => ({ url: httpUrl })), secure: false }],
  onPush: {
    // Keyed by the toConnector action id; input + output typed from the channel.
    position_update: async ({ player, x, y }) => {
      renderPlayer(player, x, y);
      return { acknowledged: true };
    },
  },
});
```

### Acceptor side — push back

The local handler reads `action.context.originClient` to know who asked, then pushes to them:

```ts
// server.ts
const lobbyHandler = createLocalHandler().forDomainActionCases(lobbyDomain, {
  start_feed: async (action) => {
    let delivered = 0;
    for (let seq = 0; seq < action.input.count; seq++) {
      const running = server.pushToClient(
        action.context.originClient,                // the requesting client's coordinate
        lobbyDomain.action.position_update.request({ player: "alice", x: 1, y: 2 }),
      );
      await running.waitForResultPayload(); // await the client's ack like any action
      delivered++;
    }
    return { delivered };
  },
});
```

Fan one out to everyone on the sole duplex carrier with `server.broadcast` (fire-and-forget; skip the
origin or filter by connection):

```ts
server.broadcast(
  () => lobbyDomain.action.position_update.request({ player: "system", x: 0, y: 0 }),
  { except: originWs, where: (ws) => server.connections.get(ws)?.role === "player" },
);
```

> **When a handler needs the originating connection itself** (to register it in a room, track per-socket
> state, etc.) rather than just the client coordinate, pass `channelCases` to `serveChannel` /
> `serveDurableObject` — each case receives the request *and* an `IConnectionContext` (`conn.state` /
> `conn.setState` / `conn.broadcast({ exceptSelf }) ` / `conn.pushBack` / `conn.connection`). With several
> duplex carriers (no sole acceptor), reach for a specific `server.acceptors[i].broadcast(...)` and
> `acceptChannelConnections(handler, channel, { ... })` directly (see
> [Lower-level building blocks](#lower-level-building-blocks)).

---

## Cloudflare Durable Objects

`@nice-code/action/platform/cloudflare` collapses the *entire* DO transport stack — the hibernatable
secure WebSocket, an HTTP fallback, the DO-storage crypto identity, and the `ping`/`pong` keepalive —
into a single `serveDurableObject` call, leaving the DO to forward its four socket lifecycle methods. The
core library stays platform-agnostic — nothing here is reachable from the main entry.

```ts
import { DurableObject } from "cloudflare:workers";
import { ActionRuntime } from "@nice-code/action";
import {
  serveDurableObject,
  type TDurableObjectChannelServer,
} from "@nice-code/action/platform/cloudflare";
import { appChannel, serverCoord } from "./shared"; // coordinates live in shared code

export class MyDurableObject extends DurableObject {
  private _server: TDurableObjectChannelServer | null = null;

  private getServer(): TDurableObjectChannelServer {
    if (this._server != null) return this._server;
    const runtime = new ActionRuntime(serverCoord);

    // Hibernatable secure WS + plain HTTP fallback + DO-storage crypto identity + keepalive, all folded in.
    this._server = serveDurableObject(this.ctx, appChannel, {
      runtime,
      keyPrefix: "ws:",
      handlers: [userHandler],
    });
    return this._server;
  }

  async fetch(request: Request): Promise<Response> {
    return this.getServer().fetch(request);
  }
  async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
    this.getServer().receive(ws, msg);
  }
  async webSocketClose(ws: WebSocket) { this.getServer().drop(ws); }
  async webSocketError(ws: WebSocket) { this.getServer().drop(ws); }
}
```

`serveDurableObject(ctx, channel, options)` takes the same `serveChannel` surface (`handlers`,
`channelCases`, `connectionState`, the optional `clientEnv`, …) plus the host knobs:

- **`runtime`** — this DO's runtime.
- **`keyPrefix`** — namespace for the DO-storage crypto-identity keys.
- **`storage`** — a prebuilt identity/TOFU `StorageAdapter` (overrides `keyPrefix`), including an
  intentionally untracked adapter when whole-object deletion owns cleanup.
- **`httpFallback`** — `"plain"` (default), `"secure"` (handshake-protected exchange sharing the WS
  identity), or `false` (WebSocket only).
- **`secure`** — whether the WebSocket itself runs the handshake (default `true`). Trusted-client
  verify-key pins (TOFU) default to **DO-storage-backed** — they survive eviction and restarts, no
  configuration needed; pass `verifyKeyResolver` only to override the trust policy itself.
- **`inboundLimits`** — exact UTF-8/binary frame bytes plus an optional fixed-window per-connection
  rate, applied before handshake/action parsing; `onExceeded` decides whether to close/quarantine.

The returned server's idempotent `dispose()` permanently quiesces the acceptors and detaches known
connections. Late frames are ignored, fetches return `503`, and push/broadcast reject—use it before
whole-object storage deletion.

For finer control the lower-level pieces are still exported: `cloudflareDurableObjectHost(ctx, opts)`
builds just the `{ carriers, storage, onServed }` host adapter (hand it to `serveHost`), and
`durableObjectWsCarrier` / `durableObjectStorage` build the individual carrier / storage adapter.

### The Worker front door — `serveWorker`, `actionRouter`, `forwardToDurableObject`

A real app's edge is a Worker that serves some channels *itself* and routes the rest on to the right
Durable Object. Three helpers cover the whole front door — and the request body stays **opaque** to the
edge the whole way (security terminates at the final runtime, never at the Worker).

#### Serve a channel from the Worker itself — `serveWorker`

`serveWorker` is the **stateless dual of `serveDurableObject`**. The secure exchange's handshake + session
ride sealed tokens, so any isolate serves it with no Durable Object. One call folds in everything a
hand-rolled stateless endpoint repeats: the crypto identity (with one-time provisioning for KV's eventual
consistency), the in-memory TOFU default, the HTTP-exchange carrier, and the lazy build the Workers global
scope forces — over a `StorageAdapter` you supply (e.g. `kvStorageAdapter` over a KV namespace). `runtime`
and `handlers` are **factories** — the Workers runtime forbids generating random ids / doing I/O at module
scope, so `serveWorker` builds and memoizes them on the first request.

This is the whole **"bridge create"** use-case: a verified action that provisions a fresh Durable Object
server-side and hands back its stable id, served statelessly with no creator DO of its own:

```ts
// worker.ts
import { ActionRuntime } from "@nice-code/action";
import { serveWorker, kvStorageAdapter } from "@nice-code/action/platform/cloudflare";

// create_bridge → mint a fresh bridge DO, seed it, return its id (a factory; constructing a handler
// generates a random id, which the Workers runtime forbids at module scope).
const createHandler = () =>
  bridgeCreateDomain.wrapAsLocalHandler({
    create_bridge: async ({ label }) => {
      const id = env.BRIDGE.newUniqueId();               // a fresh, unique bridge DO…
      await env.BRIDGE.get(id).initBridge({ label });    // …seeded before the client ever connects…
      return { bridgeId: id.toString() };                // …and addressed by this stable id.
    },
  });

const serveCreate = serveWorker(bridgeCreateChannel, {
  runtime: () => new ActionRuntime(creatorCoord),        // a factory — built lazily on first request
  storage: kvStorageAdapter({ kvNamespace: env.KV, keyPrefix: "bridge-create:" }),
  handlers: () => [createHandler()],
});
// it returns an { fetch }, so route it from any framework — or an actionRouter (below):
//   honoApi.on(["POST", "OPTIONS"], "/bridge/create/secure", (c) => serveCreate.fetch(c.req.raw));
```

Serve several channels on one stateless endpoint with `serveWorkers([a, b], …)` — the stateless dual of
`serveChannels` (and the multi-channel form of `serveWorker`). It composes the matching dictionary version
per connection from each client's advertised subset, so a client connecting just one of the channels via
`connectChannel` is accepted; the connect-side dual is `connectChannels`. (Don't reach for
`serveWorker(combineChannels([a, b]), …)` — that serves one fixed version over the whole union and rejects
every single-channel client with a dictionary-version mismatch.) For a *plain* (no-handshake) endpoint pass
`secure: false` and no `storage`.

#### Route the front door — `actionRouter` + `forwardToDurableObject`

Once a bridge has an id, the client talks to its specific Durable Object. The Worker routes by the
**URL** — a secure exchange body is opaque (handshake / encrypted frames), so the destination can't come
from inspecting the body — and `forwardToDurableObject` passes the request straight through, keeping
security end-to-end between the client and the DO. It answers the CORS `OPTIONS` preflight *at the edge*,
so a per-id DO is never woken (or billed) just to reply to a preflight.

`actionRouter` is the optional, framework-free multiplexer for a raw `export default { fetch }`: every
entry — a `serveWorker`, a `forwardToDurableObject`, even a nested router — is just an `{ fetch }`, so they
compose by mounting:

```ts
import { actionRouter } from "@nice-code/action";
import { forwardToDurableObject } from "@nice-code/action/platform/cloudflare";

const router = actionRouter()
  .route("/bridge/create/*", serveCreate)                          // stateless, served on the Worker
  .route("/bridge/:id/*", forwardToDurableObject(({ params }) =>   // per-id, E2E client ↔ DO
     env.BRIDGE.get(env.BRIDGE.idFromString(params.id))))
  .otherwise(restOfApp);                                           // anything unmatched → your existing app

export default { fetch: (request: Request) => router.fetch(request) };
```

Inside the bridge DO, serve the exchange (and the WS upgrade) exactly as the DO above — make the HTTP
fallback secure so the whole path is handshake-protected:

```ts
this._server = serveDurableObject(this.ctx, bridgeChannel, { runtime, httpFallback: "secure" });
// fetch(request) => this._server.fetch(request)
```

The matching connector points its `httpCarrier` (or `wsCarrier`) at `/bridge/:id/secure` (or `/ws`);
`pickStub` may be async (e.g. to resolve an id first) and receives `{ request, url, params }` (the matched
path params when routed through `actionRouter`). `forwardToDurableObject` is CF sugar over the generic,
platform-neutral **`forwardTo(pickTarget)`**, which forwards to **any** `{ fetch }` — a DO stub, a service
binding (`() => env.OTHER_WORKER`), or an external server
(`() => ({ fetch: (req) => fetch(UPSTREAM, req) })`, with an optional `rewrite` to strip a path prefix).

This routes to a per-id *durable* instance (one keeping a live WebSocket or per-instance state). If a
secure endpoint needs **neither**, it's a `serveWorker` (above) — or, framework-free, a bare `serveChannel`
over `[httpAcceptorCarrier()]` (see
[Serving without a Durable Object](#serving-without-a-durable-object--stateless-secure-http-exchange)).

> Need to handle the exchange yourself instead of handing the endpoint to `serveChannel`? The secure
> exchange acceptor and its plain `{k:"act",w}` envelope codec are exported from the **main** entry:
> `ExchangeAcceptor` (drive the handshake + token sessions + decrypt over your own `fetch`), and
> `encodeExchange` / `decodeExchangeRequest` / `decodeExchangeReply` (read/write the plain envelope when
> you must inspect or rewrite the wire before running it). Secure bodies are only decodable through an
> `ExchangeAcceptor` session — route-before-decode by URL, as above.

### Per-connection state + broadcast (stateful DOs)

A presence/room DO that tracks who's on each socket and fans messages out adds two knobs —
`connectionState` (typed per-socket app state, co-stored with the routing binding so both survive a wake)
and `channelCases`. Each case gets an **`IConnectionContext`** as its second argument — the originating
connection plus everything a case reaches for, so it never threads `ws` through `server.connections` /
`server.broadcast` by hand:

```ts
const server = serveDurableObject(this.ctx, lobbyChannel, {
  runtime,
  keyPrefix: "lobby-ws:",
  connectionState: { schema: vs_player },
  channelCases: {
    join: (action, conn) => {
      conn.setState(action.input); // typed by the schema; co-stored with the binding
      conn.broadcast(() => lobbyPush.action.player_joined.request(action.input), { exceptSelf: true });
      return { players: this.roster() };
    },
    move: (action, conn) => {
      const player = conn.state;          // this socket's typed app state (or null)
      if (!player) return;                // a move before join is dropped
      conn.broadcast(() => lobbyPush.action.player_moved.request({ id: player.id, ...action.input }), {
        exceptSelf: true,
      });
    },
  },
});

// Rebuild in-memory state from surviving sockets after a wake (binding replay is automatic):
for (const [, player] of server.connections.entries()) this.players.set(player.id, player);
```

The `IConnectionContext` (`conn`) gives a case: `conn.state` / `conn.setState(x)` / `conn.clearState()`
(the typed app state), `conn.broadcast(makeRequest, { exceptSelf })`, `conn.pushBack(request)` (push down
this same socket), `conn.connection` (the raw socket, `null` on the HTTP-exchange path), and
`conn.reliability` (the frame's receiver-side reliable facts — same value as
`action.context.reliability`). Passing
`connectionState` narrows the return so `server.connections` is non-optional. Because the WS carrier
persists each connection's binding on bind and replays it on construction, results and pushes still route
to the right socket after the object wakes from eviction.

---

## Security levels

`ESecurityLevel` (used by `connectChannel` and `serveChannel`):

- **`none`** — identity self-asserted, no handshake. Fastest; fine for dev/trusted networks.
- **`authenticated`** — the handshake verifies identity (sign/verify + trust-on-first-use key pin);
  frames are unencrypted.
- **`encrypted`** — authenticated *plus* every frame AES-GCM encrypted with the handshake-derived key.

The connector picks its level; an acceptor by default **negotiates any of the three per connection**, so
one endpoint serves all three. The client identifies itself with its runtime coordinate + a persisted
crypto identity; the server pins client keys trust-on-first-use. Persisting the server's binding (via the
hibernatable carrier) lets an `authenticated`/`encrypted` connection resume after eviction without
re-handshaking.

The whole thing rides one channel: the same secure `{ carrier: wsCarrier(...) }` transport works at any
level, and `httpCarrier` runs the *same* secure session over HTTP (handshake → token → encrypted frames),
with the request/reply correlation provided for free by the HTTP transaction. Pair a secure WS with a
plain HTTP fallback by giving the acceptor a `httpAcceptorCarrier({ secure: false })`.

---

## Serving without a Durable Object — stateless secure HTTP exchange

The secure HTTP exchange is **stateless**: its handshake and session ride sealed tokens (sealed under
the server's own crypto identity), so any request can be served by any isolate. You don't need a Durable
Object — or any sticky instance — just to keep a handshake's two POSTs together. A single secure-exchange
acceptor is therefore just `serveChannel` over an HTTP carrier, on any backend (a plain Worker route, a
Node handler):

```ts
// server.ts — a stateless secure-exchange endpoint (no Durable Object)
const server = serveChannel(runtime, appChannel, {
  storage,                          // backs the (read-mostly) crypto identity; sessions never touch it
  carriers: [httpAcceptorCarrier()],
  handlers: [appHandler],
});
// route any action POST straight to it:
//   app.post("/action", (req) => server.fetch(req))
```

The only thing the exchange reads from `storage` is the server's crypto identity. On a **strongly
consistent** store (Node memory, a DO's storage, D1) the default lazy identity is fork-safe. On an
**eventually consistent** store (Cloudflare KV) provision the identity once, up front, so a transient
read-miss can never mint a *second* identity (which trust-on-first-use-pinned clients would then reject):

```ts
import { ClientCryptoKeyLink } from "@nice-code/util";

// `required` never mints on the request path — an unprovisioned link throws IdentityNotProvisionedError
// instead of forking a fresh identity on a storage hiccup.
const link = new ClientCryptoKeyLink({ storageAdapter: storage, identityMode: "required" });
await link.provisionIdentity();     // once, out-of-band (a deploy step / first boot)

const server = serveChannel(runtime, appChannel, {
  storage, link,                    // pass the provisioned, required-mode identity
  carriers: [httpAcceptorCarrier()],
  handlers: [appHandler],
});
```

> **On Cloudflare Workers**, build the server lazily on the first request (memoized), not at module
> scope — the runtime forbids generating random ids and doing I/O in the global scope. A live WebSocket
> still wants a Durable Object (a hibernatable socket needs a stable instance); this statelessness is for
> the request/reply HTTP exchange.
>
> [`serveWorker`](#serve-a-channel-from-the-worker-itself--serveworker) is exactly this, folded into one
> call: it does the lazy build, the in-memory TOFU default, and the one-time KV identity provisioning for
> you. Reach for the hand-rolled `serveChannel` form above only when you're not on the Cloudflare adapter.

---

## Multiple carriers on one runtime

`serveChannel` accepts **any number of duplex carriers** (e.g. WebSocket + WebRTC) plus at most one
exchange carrier. They all share one crypto identity and one runtime, and each result/push routes back
over the carrier its client actually connected on (connection-aware return routing). With several duplex
carriers, `server.receive`/`server.drop` throw (they can't pick a carrier) — feed each carrier handle's
own `receive`/`drop` directly.

```ts
const ws  = wsAcceptorCarrier({ send: wsSend, upgrade, attachmentStore });
const rtc = rtcCarrier(/* ... */);
const server = serveChannel(runtime, appChannel, {
  storage,
  carriers: [ws, rtc, httpAcceptorCarrier()],
  handlers: [appHandler],
});
// route each host's events to the matching handle:
//   onWsMessage(c, m)  => ws.receive(c, m)
//   onRtcMessage(c, m) => rtc.receive(c, m)
```

On the connector side, list several transports in `connectChannel`'s `transports` (preference order) to
get automatic fallback across carriers (e.g. secure WS, then plain HTTP).

### Gating a transport until a precondition holds — `available`

A transport can declare an `available` predicate to opt **out** of selection until a runtime precondition
is met. While it returns `false`, the manager treats that transport as `unsupported` and falls through to
the next one in preference order — **without** opening its carrier or even computing its cache key. The
predicate is re-evaluated per action dispatch, so the transport switches on the moment the precondition
holds, with no reconnect.

```ts
// client.ts — prefer the secure socket, but only once a session id exists; fall back to HTTP meanwhile.
connectChannel(runtime, appChannel, {
  peer: serverCoord,
  storage,
  transports: [
    {
      carrier: wsCarrier(() => ({ url: `${url}/ws` }), {
        // Only ever called once `available` passes, so no need for a placeholder cache key.
        getTransportCacheKey: () => [sessionId],
      }),
      available: () => sessionId != null, // ws preferred; HTTP serves while ws is gated off
    },
    { carrier: httpCarrier(() => ({ url: httpUrl })), secure: false },
  ],
});
```

Flipping `available` back to `false` only **skips selection** — it does not tear down a live carrier
(socket closure stays owned by the carrier's own disconnect handling), and a still-cached connection is
reused with no reconnect once it becomes available again. Omit `available` for an always-available
transport (the default).

---

## React Query integration

```ts
import { useActionQuery, useActionMutation } from "@nice-code/action/react-query";

// Query
function UserProfile({ userId }: { userId: string }) {
  const { data } = useActionQuery(
    userDomain.action.getUser,
    { userId },
    { queryKey: ["user", userId] },
  );
  return <div>{data?.name}</div>;
}

// Mutation
function RenameUser() {
  const { mutate } = useActionMutation(userDomain.action.updateName);
  return <button onClick={() => mutate({ userId: "u_1", name: "Bob" })}>Rename</button>;
}
```

---

## Devtools

### Browser panel — `@nice-code/action/devtools/browser`

A panel showing every action run: status, timing, input/output, routing, errors, and call stacks. It renders in the standalone devtools window, not inside your app — your app builds the core and streams it.

```ts
import { ActionDevtoolsCore, actionBridgeScope } from "@nice-code/action/devtools/browser";

const devtoolsCore = new ActionDevtoolsCore();
devtoolsCore.attachToDomain(appRoot);

host.contribute(actionBridgeScope(devtoolsCore));
```

### Server devtools — `@nice-code/action/devtools/server`

A backend plugs its action domain into the shared server devtools host as a neutral scope. The default `console` sink logs action lifecycle (started / success / error / aborted) with timings — pretty lines or newline-delimited JSON.

```ts
import { createServerDevtoolsHost } from "@nice-code/devtools-core";
import { actionDomainScope } from "@nice-code/action/devtools/server";

createServerDevtoolsHost({ name: "api", format: "json" })
  .contribute(actionDomainScope(appRoot, { logPayloads: false }))
  .start();
```

Add `sinks: ["console", "relay"]` + a `relayUrl` and the same host streams live to a standalone devtools window, alongside any realm engines contributed with `realmEngineScope`. Inert when `NODE_ENV === "production"`.

---

## RuntimeCoordinate

Identifies a runtime environment and is used to route actions to the right handler.

```ts
RuntimeCoordinate.env("backend")                                // named env
RuntimeCoordinate.env("backend").specify({ perId: "worker-1" }) // env + instance
RuntimeCoordinate.env("backend").withPersistentId(id.toString()) // env + persistent instance id
RuntimeCoordinate.unknown                                        // unspecified
```

> **`withPersistentId` is a trust decision, not just a name.** On a secure connection the server's
> trust-on-first-use pin is keyed by `envId::perId` — the first verify key seen for that id is pinned,
> a different key is rejected forever after (`identity_pin_mismatch`). So the connection's `storage`
> (which holds the verify key) must persist **at least as long as the persistent id does**: an id kept
> in localStorage + an identity in a memory adapter works exactly once, then bricks on reload.
> `connectChannel` warns when it sees this pairing.

---

## Error handling in actions

Every action resolves to a deterministic outcome — it never rejects with a raw throw. Use
`runToResult()` and branch on `expected` (was this error one the action **declared** via `.throws()`?):

```ts
const result = await userDomain.action.getUser.request({ userId }).runToResult();

if (result.ok) {
  use(result.output);
} else if (result.expected) {
  // result.error is the declared union — fully typed
  matchFirst(result.error, {
    not_found: ({ userId }) => show404(userId),
    forbidden: () => showForbidden(),
  });
} else {
  // Not declared by this action. Refine with the error's own flag if you care:
  if (result.error.isUnhandled) alertOncall(result.error); // foreign throw / bug / infra
  else report(result.error);                                // a real NiceError you didn't .throws()
}
```

Two cleanly-separated axes:

| Axis | Member | Question |
|---|---|---|
| Relational (on the result) | `result.expected` | Did the action declare this error via `.throws()`? |
| Intrinsic (on the error) | `error.isUnhandled` | Was it a wrapped foreign throw we never accounted for? |

### Throw-style with a typed guard

If you prefer `runToOutput()` (which rethrows on failure), narrow caught errors with `isExpectedError`:

```ts
import { castNiceError, matchFirst } from "@nice-code/error";

try {
  const output = await userDomain.action.getUser.request({ userId }).runToOutput();
} catch (e) {
  if (userDomain.action.getUser.isExpectedError(e)) {
    matchFirst(e, { not_found: ({ userId }) => show404(userId), forbidden: () => showForbidden() });
  } else {
    report(castNiceError(e).toStructuredLog());
  }
}
```

---

## Lower-level building blocks

`connectChannel` and `serveChannel` are the supported entry points — they bind the channel, runtime, and
crypto identity for you. The pieces below are what they're built on; reach for them only for the rare
routing that isn't a single channel.

> Most of these live under the **`@nice-code/action/advanced`** subpath, not the main entry — the default
> `@nice-code/action` export stays focused on the high-level API (`acceptChannel` /
> `acceptChannelConnections` and the carriers remain on the main entry). Import the raw handler classes
> (`ChannelAcceptor`, `ChannelConnector`, …), the transport classes/codec, and the handshake primitives
> from `@nice-code/action/advanced`.

- **Carriers vs transports.** A *carrier* (`wsCarrier`, `httpCarrier`, `inMemoryCarrier`, `rtcCarrier`) is
  raw byte movement; a *transport* wraps one with a security policy. You name carriers in
  `connectChannel`'s `transports` (the `secure` flag picks the policy) and in `serveChannel`'s `carriers`;
  the transport wrapping happens internally, so there's no separate transport-builder to call.

- **`acceptChannel(runtime, channel, { storage, send, clientEnv?, ... })`** — build the secure
  `ChannelAcceptor` for a channel by hand (the accept-in counterpart to a single transport), when you're
  not using `serveChannel`. `clientEnv` is optional here too (one acceptor serves several client envs — a
  live connection always wins the return path). Pair it with **`acceptChannelConnections(handler, channel, cases)`** to
  register connection-aware execution — each case receives the request *and* the originating connection:

  ```ts
  const acceptor = acceptChannel(runtime, appChannel, { clientEnv, storage, send });
  const cases = acceptChannelConnections(acceptor, appChannel, {
    join: ({ input }, conn) => { if (conn != null) rooms.add(input.roomId, conn); return { ok: true }; },
  });
  runtime.addHandlers([cases, acceptor]).apply();
  ```

- **`createActionFetchHandler(runtime, options)`** — the web-standard `fetch` handler (CORS, action POST,
  optional WS upgrade, 404) on its own, when you want the HTTP entry without `serveChannel`.

- **`createHibernatableWsServerAdapter({ handler, getConnections, getAttachment, setAttachment })`** —
  the hibernation persistence layer `serveChannel` wires automatically; use it directly with a hand-built
  `ChannelAcceptor`.

- **`createBinaryWireAdapter(domains)`** / **`createBinaryWireSessionFactory(domains)`** — the positional
  binary codecs `defineChannel` builds for you; useful for custom carriers.

- **`createInMemoryChannelPair()` / `inMemoryCarrier`** — wire two runtimes together in-process (tests,
  same-process peers) with no network.

- **Custom carriers** — for any channel nice-action doesn't model natively, implement an
  `IDuplexCarrierSource` / `IExchangeCarrierSource` and name it in `connectChannel`'s `transports`
  (connector) or build an acceptor carrier for `serveChannel`.
