ElementsSignal

Live

Retained live event tapes streamed to clients over HTTP SSE with Last-Event-ID resume.

signal.live keeps a retained event tape and exposes it over Server-Sent Events. Emit with fx.emit, and mount the feed with http.live (or a gated GET).

For developers shipping status feeds and progress UIs — declare the Signal, expose SSE, subscribe from the typed client.

The one rule

signal.live is an HTTP SSE tape — not a competing worker. Bind with http.live. Do not use on(liveSignal, flow) as a queue consumer. Prefer { optional: true } so emit succeeds when no client is connected yet.

live — retain, then replay

bus.live("order-status", …)
placed
full history · unbounded retention today

retained stream

  • placed
  • fulfilling
  • shipped

late subscriber · not connected

Emits land in the retained stream even with no subscriber. A late bus.live() call replays every retained payload in order — then continues with new ones. Console monitor shows the newest 50 for display only.

Smallest Example

One handle, three independent uses

orderStatus is a shared const. Declare it, expose SSE, and emit — different files, any order. The firehose is not "step 2"; emit is not "step 3".

Declare

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"]),
  }),
});

Expose SSE

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

Emit and subscribe

// Inside any Flow:
await fx.emit(orderStatus, { orderId: "ord_1", status: "shipped" });
curl -N http://localhost:6530/_oke/live/order-status \
  -H "accept: text/event-stream" \
  -H "authorization: Bearer …"

Response Content-Type is text/event-stream. Frames are JSON data: lines (optional id: for resume), then data: [DONE].

Pathless firehose

on(http.live(signal)) always mounts GET /_oke/live/{name} — there is no pathless file-tree stamp for live. Custom paths use http.get(path).live(signal). See Exposure.

Progressive Patterns

From a default firehose to filtered paths, retention, and the typed client:

on(http.live(signal)) mounts GET /_oke/live/{name}. Chain .gate(...) like any GET:

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

Signal names in the path are encodeURIComponent'd (chat.message stays readable).

Options Reference

Optional second argument to signal.live(name, options?). Delivery is the helper name — not an option.

OptionTypeDefaultMeaning
schemaStandard SchemaomittedEmit contract — validated at fx.emit (OKE1250)
optionalbooleanfalseAllow emit with zero SSE clients / subscribers
retention{ maxAge?, maxCount? }unboundedPrune the tape (live-only)
descriptionstringthe nameConsole / docs blurb
retriesnumber3Declared; live uses the tape, not once DLQ
deadLetterbooleantrueDeclared; live does not use once DLQ

Consequence: retention on signal.once / signal.broadcast is a type error — switch to signal.live or drop the option.

Exposure

Detailed section

If you only need the default firehose, jump to the example below. .live(…) is GET-only — on(http.post("/x").live(signal)) throws on(http.*.live(signal)): live exposure must be GET.

http.live(signal) is one-arg on() — the engine synthesizes the stream Flow (fx.live + effects.reads: ["signal:<name>"]). Chain .gate(...) like any GET.

src/flows/orders/firehose.ts
import { on, http, signal } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";

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

export const firehose = on(http.live(orderStatus).gate(member));
curl -N http://localhost:6530/_oke/live/order-status \
  -H "accept: text/event-stream" \
  -H "authorization: Bearer …"

Three GET shapes expose a live SSE body. Pick the physics first, then the path.

DeclarationPathPhysics
on(http.live(signal))GET /_oke/live/{name}Signal tape — every event
on(http.get(path).live(signal))Your pathSignal tape — auto-match on :params
on(http.get(path).live(table), flow)Your pathLive query — classified CDC rows
store.resource({ live: true }) + http.resourceGET <path>/liveSame live-query physics

Signal firehoses and resource live queries are different physics — see Live Queries below.

Emit through fx

Detailed section

If you only need fx.emit(signal, payload), jump to the table. Emit appends to the retained tape when the call resolves. The producer run id is stamped as parentRunId for Console trace chains.

CallRecordsUse
fx.emit(signal, payload?)emitsAppend to the live tape
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 client receives the frame. Cross-signal fx.live without a declared read throws OKE1001.

src/flows/orders/[id]/ship.ts
import { on, flow, http } from "okengine";
import { z } from "zod";
import { orderStatus } from "@/signals/orders";

export const ship = on(
  http.post({
    in: z.object({ id: z.string() }),
  }),
  flow({
    do: async ({ id }, fx) => {
      await fx.emit(orderStatus, { orderId: id, status: "shipped" });
      return { id, status: "shipped" };
    },
  }),
);

Zero SSE clients + optional: falseOKE1240. Cause: Flow "{flow}" emits signal "{resource}" with no subscriber. Fix: mount http.live (or a path .live) before emitting, or set { optional: true }.

Retention

Detailed section

If you only need an unbounded tape, skip this section. retention is live-only — omit both fields (or omit retention) for unlimited history.

FieldTypeMeaning
maxAgeduration string ("24h", "30s", …)Drop events older than this
maxCountinteger ≥ 1Keep only the newest N events
src/signals/chat.ts
import { signal } from "okengine";
import { z } from "zod";

export const chatMessage = signal.live("chat.message", {
  optional: true,
  retention: { maxAge: "7d", maxCount: 10_000 },
  schema: z.object({
    room: z.string(),
    text: z.string(),
    author: z.string(),
  }),
});

Invalid values throw at declare:

signal.live("…"): retention.maxAge must be a duration like "24h" or "30s"
signal.live("…"): retention.maxCount must be an integer ≥ 1

Consequence: a pruned id: becomes a resume gap — reconnects that still send that Last-Event-ID hit OKE1210 / 410 LiveResumeGap. Prefer autoResubscribe: true on flaky networks, or raise maxCount / maxAge if clients need longer catch-up.

Resume and gaps

Detailed section

Resume is exclusive: replay events after Last-Event-ID, then continue live. Unknown or pruned ids throw OKE1210 before the SSE body — HTTP maps that to 410 LiveResumeGap.

SymptomMeaningFix
OKE1210 / 410 LiveResumeGapCursor gone from the tapeDrop Last-Event-ID; replay remaining
autoResubscribe: trueClient clears gap after backoffPrefer for flaky networks
Custom fx.live(signal, { match })Server-side filterDo not wrap with fx.json.stream
Cursor "{afterId}" missing on "{signal}".

SSE frames carry optional id: lines. Clients that reconnect send Last-Event-ID from the last id: they actually received. A 410 means that cursor is gone — drop it and replay the remaining tape.

Client subscription

Detailed section

signal.live is HTTP SSE. for await stays on the server; the browser uses a callback. Prefer api.live or useLive — not raw EventSource on an invented path.

The client picks the unique exposure whose matchKey fields are a subset of the input, preferring the largest match ({ orderId } beats firehose). A tie needs via: "unit.flow".

const stop = api.live(
  orderStatus,
  { orderId: "ord_1" },
  {
    onEvent: (event) => {
      /* { orderId, status } */
    },
    onError: (err) => {
      /* 4xx, envelope, or drop */
    },
    onOpen: () => {
      /* HTTP 200, including reconnects */
    },
    autoResubscribe: false,
  },
);
stop();

api.orders.events({ orderId }, { onEvent }) is the same shape on the exposing Flow. Reconnects send Last-Event-ID from the last id: received.

OptionDefaultMeaning
onEvent(required)Each JSON frame from the tape
onErroromitted4xx, envelope errors, or network drop
onOpenomittedAfter a successful SSE open (incl. reconnects)
autoResubscribefalseRe-open after a drop (500ms…30s backoff)
viaomittedDisambiguate when two exposures share a match shape
signalomittedAbortSignal to cancel the subscribe

React:

import { useLive } from "okengine/client-react";

const { events, latest, error, isConnected } = useLive(
  api,
  orderStatus,
  { orderId: "ord_1" },
  { autoResubscribe: true },
);

Resource live queries use useLiveQuery (snapshot + classified events), not api.live. See Client · Live.

What live is not

Detailed section

Pick physics from the guarantee you need. Live is the wrong tool when work must be claimed once, or when every in-process listener should react with no retained history.

NeedUse instead
Exactly one worker processes the jobonce
Every active Flow gets a copy, no tapebroadcast
Classified CDC rows for a list windowhttp.resource live / useLiveQuery
Durable multi-step work with journalDurable Workflows

Live does not use the once visibility lease or dead-letter queue. retries / deadLetter may appear on the declare options bag, but the live path is the retained tape + SSE resume — not competing-consumer physics.

Troubleshooting

Learn more

  • HTTP · Live Streams — exposure, uniqueness, match
  • Signal Overview — delivery matrix and drivers
  • Once — when you need competing workers instead
  • Broadcast — ephemeral fan-out without a tape
  • Clientapi.live, useLive, useLiveQuery
  • fxfx.emit, fx.live
  • Gate.gate(...) / .public() on triggers
  • Errors — OKE1050 · OKE1210 · OKE1240 · OKE1250

Next

On this page