Signal is how your backend **moves data when the producer should not wait**. An email job, a cache bust across instances, and a browser status feed share one handle shape — only the helper changes: `signal.once`, `signal.broadcast`, or `signal.live`.

For developers wiring async work on okengine — declare the physics, emit with `fx.emit`, bind workers with `on(handle, flow("name", { do }))`.

<Callout title="The one rule">
  Declare every signal with `signal.once`, `signal.broadcast`, or `signal.live` as an
  exported const. Bind with `on(handle, flow("name", { do }))`. Physics and the **emit**
  `schema` live on the Signal; the worker inherits the payload type, not `flow.in`.
</Callout>

<SignalDelivery />

## Smallest Example

<Callout title="One handle, three independent uses">
  `orderPlaced` is a shared const. Declare it, bind a worker, and emit from a producer — different
  files, different people, any order. None of these is a prerequisite step for the others.
</Callout>

### Declare

```typescript title="src/signals/orders.ts"
import { signal } from "okengine";
import { z } from "zod";

export const orderPlaced = signal.once("orders.placed", {
  schema: z.object({
    orderId: z.string(),
    amount: z.number(),
    userId: z.string(),
  }),
  retries: 5,
  deadLetter: true,
});
```

### Bind a worker

```typescript title="src/flows/orders/fulfill.ts"
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const fulfill = on(
  orderPlaced,
  flow("orders.fulfill", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeAndShip, { orderId });
    },
  }),
);
```

### Emit

```typescript
// Inside any Flow — need not live next to the worker:
await fx.emit(orderPlaced, {
  orderId: "ord_99",
  amount: 150,
  userId: "usr_1",
});
```

The compiler records `emits: ["orders.placed"]` on the producer. The HTTP request does not wait
for the worker.

<Callout title="Live is not a worker">
  `signal.live` is an HTTP SSE tape. Expose it with
  [`http.live`](/docs/elements/flow/http#live-streams) — do not bind `on(liveSignal, flow)` as a
  competing consumer.
</Callout>

A string `fx.emit("name", payload)` still runs (runtime `schema` still applies) but does not
type-check. Import the exported const.

## Progressive Patterns

Same helpers + `fx.emit` from a queue job to a fan-out and a browser feed:

<Tabs items={["Once", "Broadcast", "Live", "Optional"]}>

<Tab value="Once">

Competing workers — exactly one claims each message. Failed attempts retry, then dead-letter
when `deadLetter` is true (default):

```typescript title="src/signals/email.ts"
import { signal } from "okengine";
import { z } from "zod";

export const emailTask = signal.once("tasks.email", {
  schema: z.object({ to: z.string().email(), body: z.string() }),
  retries: 3,
  deadLetter: true,
});
```

```typescript title="src/flows/workers/email.ts"
import { on, flow } from "okengine";
import { emailTask } from "@/signals/email";
import { rawEmail } from "@/channels/email";

export const processEmail = on(
  emailTask,
  flow("workers.email", {
    do: async ({ to, body }, fx) => {
      await fx.send(rawEmail, { to, body });
    },
  }),
);
```

</Tab>

<Tab value="Broadcast">

Every subscribed Flow gets a copy. Offline listeners miss the event — there is no replay tape:

```typescript title="src/signals/cache.ts"
import { signal } from "okengine";
import { z } from "zod";

export const catalogChanged = signal.broadcast("catalog.changed", {
  schema: z.object({ sku: z.string() }),
});
```

```typescript title="src/flows/cache/invalidate.ts"
import { on, flow } from "okengine";
import { catalogChanged } from "@/signals/cache";

export const invalidate = on(
  catalogChanged,
  flow("cache.invalidate", {
    do: async ({ sku }, fx) => {
      await fx.store.kv.delete(`sku:${sku}`);
    },
  }),
);
```

</Tab>

<Tab value="Live">

Retained tape for browsers. Mount with `http.live` (or a gated GET path):

```typescript title="src/signals/orders.ts"
import { signal } from "okengine";
import { z } from "zod";

export const orderStatus = signal.live("order-status", {
  optional: true,
  schema: z.object({
    orderId: z.string(),
    status: z.enum(["placed", "fulfilling", "shipped"]),
  }),
});
```

```typescript title="src/flows/orders/firehose.ts"
import { on, http } from "okengine";
import { member } from "@/core/gate";
import { orderStatus } from "@/signals/orders";

export const firehose = on(http.live(orderStatus).gate(member));
```

```bash
curl -N http://localhost:6530/_oke/live/order-status \
  -H "accept: text/event-stream" \
  -H "authorization: Bearer …"
```

</Tab>

<Tab value="Optional">

Emit with zero subscribers throws **OKE1240** unless `optional: true`. Use that for live
firehoses and hooks that may have no worker yet:

```typescript
export const webhook = signal.once("hooks.inbound", {
  optional: true,
  schema: z.object({ id: z.string() }),
});
```

**OKE1240** cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
Fix: add `on(signal, …)` or mark `{ optional: true }`.

</Tab>

</Tabs>

## Delivery Physics

| Helper             | Pattern    | Consumer                      | Replay                 | Typical use                 |
| ------------------ | ---------- | ----------------------------- | ---------------------- | --------------------------- |
| `signal.once`      | Work queue | Competing workers, lease lock | Retries + DLQ          | Emails, fulfillment, sync   |
| `signal.broadcast` | Pub/sub    | Every active subscriber       | None (miss if offline) | Cache invalidation, fan-out |
| `signal.live`      | SSE tape   | HTTP clients via `http.live`  | `Last-Event-ID` resume | Status feeds, progress      |

## Competing consumers (once vs broadcast)

This is the most common mix-up. `signal.once` is a work queue: **exactly one** worker claims each
message. It is not fan-out.

Three differently-named Flows bound to the same `once` signal, then one emit:

```typescript title="src/flows/orders/side-effects.ts"
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";

export const charge = on(
  orderPlaced,
  flow("orders.charge", {
    do: async ({ orderId }, fx) => {
      await fx.call(chargeOrder, { orderId });
    },
  }),
);
export const ship = on(
  orderPlaced,
  flow("orders.ship", {
    do: async ({ orderId }, fx) => {
      await fx.call(shipOrder, { orderId });
    },
  }),
);
export const notify = on(
  orderPlaced,
  flow("orders.notify", {
    do: async ({ orderId }, fx) => {
      await fx.call(notifyOrder, { orderId });
    },
  }),
);
```

```typescript
await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });
```

| Expectation                         | Real result                                                          |
| ----------------------------------- | -------------------------------------------------------------------- |
| All three Flows run                 | **No.** Exactly one of the three runs                                |
| Always `orders.charge` (first `on`) | **No.** The winner is whichever claim lands first — not a fixed Flow |
| A sticky assignment to one Flow     | **No.** There is no owner — only an exclusive claim per message      |

**Consequence:** if every bound Flow should independently receive its own copy, that is
[`signal.broadcast`](/docs/elements/signal/broadcast) — switch the declaration. That is the correct
fix for this exact mistake.

Two or more **different** Flow definitions on the same `once` signal fail **OKE1071**.

Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).`

Load-balancing competing consumers is the **same** Flow on many process replicas — still one
`on()` in source.

Fix: `Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.`

## The Capabilities of Signal

<Cards>
  <Card
    title="Once"
    description="Competing workers, visibility leases, retries, dead letters, and per-key ordering."
    href="/docs/elements/signal/once"
  />
  <Card
    title="Broadcast"
    description="Ephemeral fan-out across subscribed Flows — no retained tape."
    href="/docs/elements/signal/broadcast"
  />
  <Card
    title="Live"
    description="Retained event tapes over HTTP SSE with Last-Event-ID resume."
    href="/docs/elements/signal/live"
  />
</Cards>

## Options Reference

Optional second argument to `signal.once` / `signal.broadcast` / `signal.live`. Delivery is the helper name — not an option.

| Option        | Type                     | Default   | Meaning                                                                                                                        |
| ------------- | ------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `schema`      | Standard Schema          | omitted   | **Emit** contract — enforced at `fx.emit` (**OKE1250** on mismatch). Workers inherit the payload; do not put `in` on `flow()`. |
| `retries`     | `number`                 | `3`       | Extra attempts after the first (`retries + 1` total) — `once` path                                                             |
| `deadLetter`  | `boolean`                | `true`    | Keep exhausted `once` messages; `false` marks them delivered                                                                   |
| `optional`    | `boolean`                | `false`   | Allow emit with zero subscribers                                                                                               |
| `retention`   | `{ maxAge?, maxCount? }` | unbounded | **`signal.live` only** — type error on `once` / `broadcast`                                                                    |
| `description` | `string`                 | the name  | Console / docs blurb                                                                                                           |

**Consequence:** `deadLetter` is a boolean flag, not a queue name string.

## Emit through fx

<Callout title="Detailed section">
  If you only need `fx.emit(signal, payload)`, jump to the table. Emit commits the outbox when the
  call resolves. The producer run id is stamped as `parentRunId` for Console trace chains.
</Callout>

| Call                                  | Records                 | Use                                          |
| ------------------------------------- | ----------------------- | -------------------------------------------- |
| `fx.emit(signal, payload?, { key? })` | `emits`                 | Publish; `{ key }` serializes `once` per key |
| `fx.deadLetters(signal)`              | `reads` `signal:<name>` | Inspect exhausted `once` messages            |
| `fx.live(signal, { match? })`         | `reads` `signal:<name>` | Server SSE body for a live tape              |

Invalid `schema` payloads fail at emit with **OKE1250** (`"{resource}": {detail}`) before any
consumer runs. Cross-signal `fx.deadLetters` / `fx.live` without a declared read throws
**OKE1001**.

```typescript
await fx.emit(orderPlaced, { orderId: "ord_1", userId: "usr_1" }, { key: "usr_1" });
```

Omit `key` for a pure competing pool with no ordering.

## Per-environment drivers

Protocol ids: `memory` · `redis` · `postgres` · `nats`. Defaults (when `oke.config.ts` omits
`drivers.signal`):

| Env    | Default  | Boot today                                                              |
| ------ | -------- | ----------------------------------------------------------------------- |
| `dev`  | `redis`  | Emit relays to Redis; consume / live / drain use a process-local outbox |
| `test` | `memory` | In-process bus                                                          |
| `prod` | `redis`  | Same redis honesty as `dev`                                             |

```typescript title="oke.config.ts"
export default defineConfig({
  drivers: {
    signal: { test: "memory", prod: "redis" },
  },
});
```

`postgres` and `nats` fail loud at boot until a native bind ships — never silently fall back to
`memory`. Prefer `memory` for tests; pin `redis` when Compose provides Redis.

## Troubleshooting

<Accordions>

<Accordion title="TypeError: retention is only valid with signal.live">
  `retention: { maxAge, maxCount }` is live-only. Drop it on queue / pub-sub Signals, or switch
  to `signal.live`.
</Accordion>

<Accordion title="OKE1240 — emit with no subscriber">
  Cause: `Flow "{flow}" emits signal "{resource}" with no subscriber.`
  Fix: bind `on(signal, flow)` or set `{ optional: true }`.
</Accordion>

<Accordion title="OKE1250 — emit payload failed schema">
  Cause: `"{resource}": {detail}` from the Standard Schema issues. Fix the payload or the `schema`
  on the Signal. The consumer never ran.
</Accordion>

<Accordion title="OKE1070 — flow name defined twice">
  Cause: `Flow "{flow}" is defined twice.` Two `flow("…")` strings collide. Give at least one a
  distinct name.
</Accordion>

<Accordion title="OKE1072 — Signal flow unnamed">
  Cause: `A signal flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(handle, flow("orders.fulfill", { do }))`.
</Accordion>

<Accordion title="OKE1071 — once signal bound to more than one Flow">
  Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
  if each Flow should independently receive this event, or bind only one Flow. See [Competing
  consumers](#competing-consumers-once-vs-broadcast).
</Accordion>

<Accordion title='oke boot: signal driver "postgres" / "nats"'>
  Those ids are reserved but not bound for production yet. Use `"memory"` or `"redis"`, or inject a
  custom `elements.signal` runtime.
</Accordion>

<Accordion title="redis Signal — process-local consume">
  Boot warns that redis emit relays to Redis while consume / live / drain stay process-local.
  Multi-instance competing consumers need a shared durable outbox path (or a single consumer
  instance) until Redis Streams consume ships.
</Accordion>

</Accordions>

## Learn more

- [Once](/docs/elements/signal/once) — leases, retries, partition keys, DLQ
- [Broadcast](/docs/elements/signal/broadcast) — ephemeral fan-out
- [Live](/docs/elements/signal/live) — SSE tapes and retention
- [Consumers](/docs/elements/flow/consumers) — `on(signal)` workers next to Clock / CDC
- [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `http.live` exposure
- [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.live`
- [Client](/docs/client/live) — `api.live` for browsers
- [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1210

## Next

<Cards>
  <Card
    title="Once"
    description="Competing workers, leases, retries, and dead letters."
    href="/docs/elements/signal/once"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC — one Flow species."
    href="/docs/elements/flow/consumers"
  />
  <Card
    title="The Model"
    description="Eight elements overview."
    href="/docs/understand/the-architecture"
  />
</Cards>
