This is the abridged developer documentation for reactor-effect
# reactor-effect
> Build on Reactor's real-time video models, H3 today, from Node, Bun and the browser. Keep a channel on air across session caps, read decoded frames without a browser, and run the whole application offline before it spends a cent.
The video above is H3’s own output, decoded in-process and recorded by the hosted `showreel` check on October 1, 2026: one `Playout`, five scenes on one session, seams of 89–120 ms with no dark frame. [The whole reel, with sound](https://github.com/mannyc2/reactor-effect-client/releases/download/media/showreel.mp4). ## What it does [Section titled “What it does”](#what-it-does) Stay on air `Playout` keeps H3 playing across capped sessions. It opens the next session before the current one ends and switches at a clip boundary. In paid runs of the 0.8.0 playout on hosted H3 (2026-09-28 and 09-29), clip-to-clip seams measured 46–169 ms with no dark frame, and a planned switch between sessions 420–432 ms. Frames in Node, no browser `reactor-effect-native` binds Reactor’s `reactor-webrtc` crate (libwebrtc) through Node-API: owned BGRA frames and PCM in Node and Bun, with no browser in between. In paid runs on hosted H3 (2026-09-24 to 09-28, under Bun): 1344×768 at about 24 fps, no frame lost. Build offline, for free `ReactorTest` is Reactor in memory: the coordinator’s HTTP API and an H3 model speaking the real wire protocol, on the Effect clock, with timing measured on paid runs and injectable faults. Under `TestClock`, an hour of programme with six renewals runs in about 20 seconds. Know what reached Reactor Every failed command says whether it was never sent, answered, or unknown. An enqueue whose reply was lost is never sent again, so a lost reply never doubles a clip. Sessions outlive tokens and processes Tokens bound to the session refresh before they expire, a dropped connection recovers on the same session, and a crashed owner’s session is adopted by the next process. Billing safety as an API Every creating token must state its session’s cap, `close` confirms termination with an independent read, and `Session.mayStillBill` says whether a closed session may still bill. The hosted figures come from 22 paid runs between 2026-09-24 and 2026-10-01, on the 0.3.0-rc.0 to 0.9.0 libraries; on 0.9.0 only the one-session `showreel` check (2026-10-01) has run. 0.9.0’s other checks, its published client packages, the browser package and the in-process native host under Node have not run on hosted Reactor yet. ## Start here [Section titled “Start here”](#start-here) [Quickstart](/reactor-effect-client/start/quickstart/)Prompt H3 and follow each clip from acceptance to its end, offline in a minute. [Run a 24/7 channel](/reactor-effect-client/guides/channel/)One playout, renewing sessions, many viewers, and a house rotation when nobody asks. [Test offline](/reactor-effect-client/guides/testing-offline/)The same application on the simulated Reactor, deterministic on the test clock. [Hosted evidence](/reactor-effect-client/reference/hosted-evidence/)Every paid run behind this site's hosted claims, with its commit and spend, and what has not run on hosted Reactor yet. reactor-effect is an independent project, not an official Reactor SDK. It is Apache-2.0 licensed and published to npm with provenance.
# Errors and dispatch evidence
> Tagged failures with reasons, and whether a failed command may still have reached Reactor.
Every failure of a session, the H3 provider or a source is one of three tagged classes: `ReactorError`, `CommandFailure` and `AcquisitionFailure`; `Playout` adds its own refusals, [below](#the-playouts-own-errors). Each class carries a tagged `reason` to route on, and a `context` that says, among other things, whether the request may have reached Reactor. Every operation’s type names its exact failure, so the compiler shows you what can go wrong.
```ts
import { Effect } from "effect";
import type { H3 } from "reactor-effect-client";
export const enqueue = Effect.fn("enqueue")(function* (
h3: H3.Provider,
request: H3.Request,
) {
const acceptance = yield* h3.enqueue(request).pipe(
// Refused before anything was sent: the deployment takes no
// reference audio. Sending the clip without it is safe.
Effect.catchReason("CommandFailure", "UnsupportedCapability", () =>
h3.enqueue({ ...request, audio: [] }),
),
);
return acceptance.clip.clip_id;
});
```
## Three classes [Section titled “Three classes”](#three-classes) | Class | Raised by | Carries | | -------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------- | | `CommandFailure` | A command: an H3 command, `session.command` | `reason`, and `context` with its dispatch evidence | | `AcquisitionFailure` | `create`, `attach`, `H3Source.open` and `resume` | `reason`, `context`, and `cleanup`: the close report of whatever it allocated | | `ReactorError` | Everything else: reads, streams, layers, uploads | `reason` and `context` | Each keeps its own `_tag`, so `Effect.catchTag("CommandFailure", ...)` catches one class. `ReactorError.isReactorFailure(value)` recognizes any of the three, and each class has its own `is`. All three have `isRetryable` and `retryAfter`. ## Reasons [Section titled “Reasons”](#reasons) `reason._tag` says what happened. Most reasons are a code with a library-written `message`: | Code | What happened | | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `InvalidInput` | A request or option was refused before anything was sent | | `UnsupportedCapability` | The deployment or host lacks what was asked: a command, reference audio, decoded media in a browser | | `UnsupportedHost` | The host cannot run: no WebRTC in the page, or no native addon for this platform | | `InvalidState` | The operation does not fit the current state, such as H3 not yet ready | | `TerminalSession` | The session has ended | | `Timeout` | A library deadline passed: a reply, a connect, a reconnect | | `Disconnected` | The connection dropped | | `Closed` | The session or peer is closed | | `Aborted` | The work stopped because something else did, such as a failed `onAllocated` | | `Overflow` | A bound was exceeded: a reader fell behind, or too many requests are pending | | `Moderated` | Reactor’s content moderation ended the session | | `Indeterminate` | The provider retired before its evidence decided a clip’s fact | | `Protocol`, `UnexpectedReply` | Reactor or the model answered outside its contract | | `VersionMismatch` | Reactor refuses this client’s protocol | | `Upload`, `ChannelClosed`, `SdpRejected`, `Shutdown`, `AlreadyReading` | An upload, a data channel, the WebRTC negotiation, a native shutdown or a second reader of one observation failed | The others carry fields of their own: | Reason | Fields | | ---------------------------- | ----------------------------------------------------------------------------- | | `Http` | `status` (absent when no response arrived), `retryAfter`, `body` | | `Remote`, `RecorderDisabled` | `remoteCode`, `body`: the model’s own refusal | | `Native` | `backendMessage`: libwebrtc’s text | | `IceFailed` | `pairs`, `candidateTypes`: no ICE candidate pair worked | | `TransportFailed` | `pairs`: ICE worked, and the DTLS or SCTP transport above it failed | | `ClipEnded` | `clipId`, `lifecycle` (`clip_failed` or `clip_popped`), `transportGeneration` | Route on reasons with Effect’s `catchReason`, `catchReasons` and `unwrapReason`. The handler gets the reason and the failure it came in:
```ts
import { Effect } from "effect";
import { CoordinatorClient, H3, Reactor } from "reactor-effect-client";
export const session = Effect.gen(function* () {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
const reactor = yield* Reactor.Reactor;
const create = reactor.create({
model: H3.modelName,
tokens: coordinator.tokens({
modelName: H3.modelName,
maxSessionDuration: "5 minutes",
}),
});
return yield* create.pipe(
// Reactor's quotas refuse a sixth session at once, or creates
// faster than ten a minute (three back to back), with 429.
Effect.catchReason(
"AcquisitionFailure",
"Http",
(reason, failure) =>
reason.status === 429 && reason.retryAfter !== undefined
? Effect.andThen(Effect.sleep(reason.retryAfter), create)
: Effect.fail(failure),
),
);
});
```
On hosted Reactor, an expired token was refused with `Http` 401 and an unbound one with 403 ([0.8.0-api `adoption`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28), and attaching to a session that had ended failed with `TerminalSession` ([0.8.0-api `tour`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). ## Dispatch evidence [Section titled “Dispatch evidence”](#dispatch-evidence) A failure’s `context.outcome` says whether Reactor may have applied the request: | `outcome` | Meaning | Sending it again | | --------------- | --------------------------------------------------- | ------------------------------------------------- | | `not-submitted` | It never left this process | Safe | | `replied` | Reactor answered, and its reply or refusal is known | As the reply says | | `unknown` | It was sent, and no answer came in time | It may have applied: the SDK never sends it again | A `CommandFailure` always carries this evidence: either `not-submitted` with its `operation`, or `unknown` or `replied` with the `requestId` and connection `generation` that carried it. A local interruption after dispatch never proves that Reactor did nothing, so nothing in the SDK replays an `unknown` command. That is why an [H3 enqueue](/reactor-effect-client/concepts/h3/) whose reply was lost is never sent twice, and a [playout](/reactor-effect-client/concepts/playout/) records such an item as `Unknown` instead of inventing a start for it. `isRetryable` is true when a later attempt may succeed: backpressure (`Overflow`), a connection lost before dispatch (`Disconnected`, `ChannelClosed`), or an HTTP refusal for now (no response, `408`, `429`, a `5xx`, or a named `Retry-After`). It is never true when the outcome is `unknown`.
```ts
import { Effect, Schedule } from "effect";
import type { H3 } from "reactor-effect-client";
// Retries only what may be retried: never an `unknown` outcome.
export const enqueueRetrying = Effect.fn("enqueueRetrying")(
function* (h3: H3.Provider, request: H3.Request) {
return yield* h3.enqueue(request);
},
Effect.retry({
schedule: Schedule.exponential("250 millis"),
times: 3,
while: (failure) => failure.isRetryable,
}),
);
```
A request whose token could not be had in time was never sent. A create answered with a `5xx` may still have allocated a session, so its allocation is `unknown`. ## Acquisition failures [Section titled “Acquisition failures”](#acquisition-failures) A failed acquisition still returns the evidence of what it left behind. `failure.cleanup` is the close report of whatever it allocated: `allocation` is `none` when nothing was allocated, and `Session.mayStillBill(failure.cleanup)` says whether a session may still be billing.
```ts
import { Effect } from "effect";
import {
CoordinatorClient,
H3,
Reactor,
Session,
} from "reactor-effect-client";
export const created = Effect.gen(function* () {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
const reactor = yield* Reactor.Reactor;
const tokens = coordinator.tokens({
modelName: H3.modelName,
maxSessionDuration: "5 minutes",
});
return yield* reactor
.create({ model: H3.modelName, tokens })
.pipe(
Effect.tapError((failure) =>
Session.mayStillBill(failure.cleanup)
? Effect.logError("a failed create may bill", failure.cleanup)
: Effect.void,
),
);
});
```
Any failure after allocation closes the session before the acquisition fails, `onAllocated`’s included. A failure of the client keeps its own reason; any other error from `onAllocated` becomes an `Aborted` failure that keeps the error in `context.detail`. ## Provider text stays `Redacted` [Section titled “Provider text stays Redacted”](#provider-text-stays-redacted) A failure’s `message` is written by the library and never holds provider or payload text, so logs and spans that record it stay free of prompts, refusals and SDP. Text from outside lives only in `Redacted` fields: `body` on `Http` and `Remote`, `remoteCode` on `Remote`, `backendMessage` on `Native`, and `context.detail`. They print as `` in logs, spans and `toJSON`, and stay out of the cause chain that exporters such as `OtlpTracer` render. Read one only on purpose:
```ts
import { Effect, Redacted } from "effect";
import { ReactorError } from "reactor-effect-client";
export const report = Effect.fn("report")(function* (
failure: ReactorError.ReactorFailure,
) {
// Safe to log or store: routing facts, no outside text.
yield* Effect.logWarning(
"Reactor failure",
ReactorError.summarize(failure),
);
// H3's own words, read on purpose.
if (
failure.reason._tag === "Remote" &&
failure.reason.body !== undefined
)
yield* Effect.logDebug(Redacted.value(failure.reason.body));
});
```
Persist a failure as its `ReactorError.FailureSummary` (from `ReactorError.summarize`), a Schema of its tag, reason, message, operation, request, session, generation and outcome, never as the error itself. A playout’s `Failed` item with reason `Clip` keeps H3’s words the same way, in `provider`. ## The playout’s own errors [Section titled “The playout’s own errors”](#the-playouts-own-errors) `Playout` refuses a submission before anything is sent with its own tagged errors: `InvalidItem`, `KeyMismatch`, `LaneBusy`, `WouldMissDeadline` and `PlayoutClosed`. Handle them with `Effect.catchTags`:
```ts
import { Effect } from "effect";
import { Playout } from "reactor-effect-client";
export const ask = Effect.fn("ask")(function* (
playout: Playout.Playout["Service"],
id: string,
prompt: string,
) {
return yield* playout
.submit({
key: Playout.ItemKey.make(`viewer-${id}`),
lane: "viewer",
request: { prompt, seconds: 5 },
window: { startBy: "20 seconds", firm: true },
})
.pipe(
Effect.map(() => "queued"),
Effect.catchTags({
LaneBusy: () => Effect.succeed("busy, try again shortly"),
WouldMissDeadline: () =>
Effect.succeed("the next clips are already lined up"),
InvalidItem: (error) =>
Effect.succeed(`refused: ${error.message}`),
}),
);
});
```
What happens to an item after it is admitted is not an error: it is the item’s as-run status, `Failed` with a reason (`Clip`, `Command`, `Lost`, `Moderated` or `Closed`) or `Dropped`. The playout itself fails, through `playout.failure`, only when it cannot go on: with one of the three classes above, or `InvalidFiller` when its filler asks for a clip outside H3’s limits. ## Defects [Section titled “Defects”](#defects) A bug or a broken invariant stays a defect, not a typed failure. A `filler.clip` that throws stops the playout, and `playout.failure` dies with that defect. Every defect the playout catches, in its plan, an open or a source, also goes to Effect’s `ErrorReporter`. ## Next [Section titled “Next”](#next) [The H3 provider](/reactor-effect-client/concepts/h3/)Acceptance evidence, and why a lost reply is never re-sent. [Sessions and tokens](/reactor-effect-client/concepts/sessions/)Close reports and what may still bill. [Test offline](/reactor-effect-client/guides/testing-offline/)Inject faults and watch the failures they cause. [Modules](/reactor-effect-client/reference/modules/)Every public module and its exports.
# The H3 provider
> H3 Reference Turbo Realtime over a session: requests, references, the queue, and each clip's operation facts.
`H3.make(session)` is the provider for H3 Reference Turbo Realtime (`reactor/h3-reference-to-video-turbo-realtime`) over a connected session. It gives you H3’s state and queue as the model reports them, its commands, and evidence of what became of each clip. It observes a session it neither allocates nor closes.
```ts
import { Console, Effect } from "effect";
import { H3 } from "reactor-effect-client";
import type { Session } from "reactor-effect-client";
export const playOne = Effect.fn("playOne")(function* (
session: Session.Session,
) {
const h3 = yield* H3.make(session);
yield* h3.setAutoplay(true); // H3 plays nothing on its own
const submission = yield* h3.prepare({
prompt: "A paper boat on a rain-soaked street",
seconds: 5,
});
const acceptance = yield* submission.submit;
const clip = yield* h3.operation(submission);
yield* clip.reached("started");
const end = yield* clip.ended;
yield* Console.log(
`${acceptance.clip.clip_id} ended by ${end.message}`,
);
}, Effect.scoped);
```
Create the session with `model: H3.modelName`, as on [Sessions and tokens](/reactor-effect-client/concepts/sessions/). `H3.make` needs Effect’s `Crypto` service, which `NodeServices.layer` and `BunServices.layer` provide (in a page, build one over Web Crypto); it hashes references so each is uploaded once. ## Setting up [Section titled “Setting up”](#setting-up) `H3.make` reads the deployment’s OpenAPI document, then H3’s first state and queue. It needs only `enqueue`, `get_state` and `get_queue` to start. Any other command the deployment lacks fails as `UnsupportedCapability` before it is sent, and `provider.contract` says what was found: the commands offered, whether `enqueue` takes reference audio, and the deployment’s own title and version. | `H3.make` option | Default | Bounds | | ----------------- | ---------- | -------------------------------------------------------- | | `replyTimeout` | 15 seconds | Each command, through the observation of its reply | | `uploadTimeout` | 60 seconds | Each reference upload | | `setupTimeout` | 60 seconds | Reading the schema, then the first state and queue, each | | `reconcileWindow` | 5 seconds | How long an enqueue’s held evidence or lost reply waits | The provider targets H3’s documented `0.5.5` schema (`H3.documentedVersion`). ## Requests [Section titled “Requests”](#requests) A clip is an `H3.Request`. A request outside H3’s documented bounds, or the SDK’s own safety bounds (a prompt of at most 1 MiB; images of at most 25 MiB and 25 megapixels, between 1:4 and 4:1), is refused locally as `InvalidInput`, with outcome `not-submitted`, before anything is uploaded. | Field | Bounds | | ------------------ | ------------------------------------------------------------------------------------------------------------------------- | | `prompt` | Required text, well-formed, at most 1 MiB. H3’s budget is about 2,000 tokens; a longer prompt fails its clip as it builds | | `seconds` | 5 to 15.084. H3 aligns it up to its frame grid: 124 frames, then steps of 17, at 24 fps (5.167 s to 15.083 s) | | `references` | Up to 9 images: PNG, JPEG or WebP, each at most 25 MiB and 25 megapixels, between 1:4 and 4:1 | | `audio` | Up to 3 clips, each 2 to 15 s, mono or stereo, at most 25 MiB: WAV, MP3, AAC/M4A, OGG/Opus, FLAC or WebM | | `continueFrom` | A clip id to continue from | | `seed`, `position` | H3’s seed, and a place in the build queue | | `metadata` | Your own text, carried with the clip | A clip with audio needs an image or a `continueFrom`. A continued clip takes at most two audio references, since its continuation spends the third on the previous clip’s soundtrack. Images, audio and a continuation come to at most twelve. Audio is sent only to a deployment whose `enqueue` declares it, and refused as `UnsupportedCapability` otherwise. Your `metadata` travels inside an envelope that also names the provider and the submission; together they must fit H3’s 2,000 characters. The profile’s constants state these bounds in code: `H3.requestSeconds`, `H3.referenceLimits`, `H3.audioReferenceLimits` and `H3.canvases`, with `H3.alignFrames` and `H3.estimateTokens` (an estimate only; H3’s tokenizer decides). ## Validating references once [Section titled “Validating references once”](#validating-references-once) A reference is `{ _tag: "Bytes", bytes }`, or `{ _tag: "Uploaded", file }` for a file the session already holds. `H3.validateReference` reads an image’s header once, checks its type, size and dimensions, and keeps a private copy of its bytes, so every request can reuse it without checking it again. `H3.validateAudioReference` does the same for audio, with the length and channels of a WAV or FLAC.
```ts
import { Effect } from "effect";
import { H3 } from "reactor-effect-client";
export const twoShots = Effect.fn("twoShots")(function* (
h3: H3.Provider,
still: Uint8Array,
) {
const reference = yield* H3.validateReference({
_tag: "Bytes",
bytes: still,
});
yield* h3.enqueue({
prompt: "The same street at noon",
seconds: 5,
references: [reference],
});
// Already uploaded on this connection: nothing is uploaded again.
yield* h3.enqueue({
prompt: "The same street at dusk",
seconds: 5,
references: [reference],
});
});
```
Uploads are keyed by their content within a connection generation. On hosted H3, a second clip reused the first clip’s image and audio references and uploaded nothing ([0.8.0-api `tour`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). `ReactorTest.pngBytes` and `ReactorTest.wavBytes` make references that pass these checks, for tests. ## The queue and its commands [Section titled “The queue and its commands”](#the-queue-and-its-commands) H3 keeps two queues. The generation queue holds clips waiting to build, the build in flight first. The playout queue holds built clips, Ready, in the order they play. H3’s state reports each queue’s room, and hosted H3 reported 20 and 10 (2026-09-24 and 09-25). | Method | What it does | | --------------------------------- | ---------------------------------------------------------------------------- | | `enqueue(request)` | Adds a clip and waits for its acceptance | | `prepare(request)`, then `submit` | Checks the request first; `submit` uploads and sends it, once | | `pop(clipId)` | Removes a clip from either queue, a build in flight included; it never plays | | `move(clipId, position)` | Moves a clip within its queue | | `play(clipId?)` | While nothing plays, plays the Ready clip named, or the next one | | `stop` | Stops the clip playing; under autoplay the next one starts | | `setAutoplay(enabled)` | Plays each Ready clip in turn. Off when a session starts | | `setFlushOnClipEnd(enabled)` | Flushes to black at each clip’s end. On by default; off holds the last frame | | `setCanvas(aspect)` | `16:9` (1344×768), `1:1` (768×768), `9:16` (768×1344) or `4:3` (1024×768) | | `reset` | Stops what plays and empties both queues | | `getState`, `getQueue`, `refresh` | Read H3’s state and queue afresh | H3 takes a canvas only while it is idle with both queues empty, so set it before the first enqueue. `session.command` sends anything the provider does not wrap, such as `set_seed`. With autoplay off, you choose when each clip airs:
```ts
import { Effect } from "effect";
import type { H3 } from "reactor-effect-client";
export const playWhenBuilt = Effect.fn("playWhenBuilt")(function* (
h3: H3.Provider,
request: H3.Request,
) {
const submission = yield* h3.prepare(request);
const acceptance = yield* submission.submit;
const clip = yield* h3.operation(submission);
// Built, and waiting in the playout queue.
yield* clip.reached("generated");
yield* h3.play(acceptance.clip.clip_id);
return yield* clip.ended;
}, Effect.scoped);
```
On hosted H3, `stop` ended the playing clip 62 ms after it was sent, and `play` started the clip it named 52 ms after it was sent ([0.8.0-api `tour`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). ## Snapshots, changes and events [Section titled “Snapshots, changes and events”](#snapshots-changes-and-events) `h3.snapshot` is H3’s state and queue as the model last reported them. Its `_tag` is `Synchronizing` while a full state and queue are awaited, `Ready` with `state` and `queue`, or `Unavailable` with its `cause`. Every snapshot also lists the clips seen, each with its last lifecycle message. `h3.changes` streams a snapshot at every change, `h3.events` streams the provider’s events, and `h3.observe()` pairs a snapshot with every event after it, with no gap.
```ts
import { Console, Stream } from "effect";
import type { H3 } from "reactor-effect-client";
// The clip H3 reports playing, each time it changes.
export const nowPlaying = (h3: H3.Provider) =>
h3.changes.pipe(
Stream.map((snapshot) =>
snapshot._tag === "Ready" ? snapshot.state.playing_clip_id : null,
),
Stream.changes,
Stream.runForEach((clipId) =>
Console.log(`playing: ${clipId ?? "nothing"}`),
),
);
```
H3 replies to a command before it broadcasts the state and queue the command changed, except that hosted H3 broadcasts an enqueue’s queue first. So a command that needs current facts waits for them, within its `replyTimeout`, and resolves only once the broadcasts its reply implies have arrived: the caller reads its own effects. `getState` and `getQueue` never wait. An acknowledgement (`play` and `stop` return one) proves that H3 received the command, never that anything changed. ## A clip’s operation facts [Section titled “A clip’s operation facts”](#a-clips-operation-facts) `h3.operation(submission)` follows one committed clip, for as long as your scope holds it: | Fact | Resolves | | ---------------------- | -------------------------------------------------------------------------------------------- | | `accepted` | With the clip’s `Acceptance` | | `reached("generated")` | Once the clip is built; fails with `ClipEnded` if it failed or was popped first | | `reached("started")` | Once the clip starts playing, with the same failure | | `ended` | With the fact that ended it: `clip_finished`, `clip_stopped`, `clip_failed` or `clip_popped` | | `facts` | Everything established so far | Each fact names the connection generation of its evidence. Evidence from a later generation of the same session still resolves the operation, and if the provider retires before evidence decides a fact, it fails with `Indeterminate`. On hosted H3, a 16,171-character prompt was accepted and then ended by `clip_failed` 2.39 s later, as H3’s schema documents for a prompt past its budget; the operation reported `ClipEnded` ([0.8.0-api `tour`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). ## Acceptance, and why a lost reply is never re-sent [Section titled “Acceptance, and why a lost reply is never re-sent”](#acceptance-and-why-a-lost-reply-is-never-re-sent) An enqueue resolves with an `Acceptance`: the clip as H3 queued it and the evidence that accepted it, of `kind` `correlated` or `metadata`. * **Correlated**: H3’s reply to this enqueue’s own request. * **Metadata**: the clip itself, recognized by the exact prompt and the submission id in its metadata. Hosted H3 broadcasts the queue that lists a new clip before it replies. Evidence that came first decides once the reply has stayed away for `reconcileWindow`. An enqueue whose reply is lost waits `reconcileWindow` for evidence. If none comes, it fails with outcome `unknown`, and it is never sent again: H3 may have queued the clip, and sending it again could build and air it twice. Later evidence, within the window or on a later connection generation, can still prove the clip and resolve its operation.
```ts
import { Effect } from "effect";
import type { H3 } from "reactor-effect-client";
export const enqueueOnce = Effect.fn("enqueueOnce")(
function* (h3: H3.Provider, request: H3.Request) {
const acceptance = yield* h3.enqueue(request);
return acceptance.clip.clip_id;
},
// Sent, with no reply and no sign of the clip: it may still air.
// Never send it again.
Effect.catchIf(
(failure) => failure.context.outcome === "unknown",
() =>
Effect.as(
Effect.logWarning("enqueue outcome unknown"),
undefined,
),
),
);
```
`prepare` separates the work that may be retried from the dispatch that may not. It checks the request, copies its references and returns an inert `Submission`. Its `submit` uploads the references, which an interruption may cut short and a second `submit` runs again, then commits: from then on the enqueue runs to its own outcome, and a later `submit` joins it rather than send another. `submission.state` says whether it is `Prepared`, `Committed` or `Completed`. ## Limits [Section titled “Limits”](#limits) * On 2026-09-24 the deployment reported itself as `v0.0.0`, not the documented `0.5.5` ([0.3.0-rc.0](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.3.0-rc.0/summary.md)). The provider checks the commands the deployment’s schema offers, not its version. * An enqueue at position zero went ahead of the build already running, against H3’s schema ([0.7.0 `scheduler-cut`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.7.0/summary.md), 2026-09-28). * H3’s refusals are free text with no stable codes. The provider keeps them `Redacted` in the failure’s `Remote` reason and routes on nothing in them. * A clip asked to continue another may come out independent, and H3 does not say so. * `reached("started")` is H3’s report that a clip started, not proof that a frame was presented or encoded. ## Next [Section titled “Next”](#next) [Playout](/reactor-effect-client/concepts/playout/)A keyed schedule that keeps H3 on air across sessions. [Media](/reactor-effect-client/concepts/media/)Decoded frames and PCM, or browser tracks. [Errors](/reactor-effect-client/concepts/errors/)Dispatch evidence and tagged reasons. [Test offline](/reactor-effect-client/guides/testing-offline/)The same provider against the simulated H3.
# Media
> Decoded frames and PCM from the native and simulated hosts, and platform tracks in the browser.
A connected session carries the media of its current connection generation. The native host and the simulated Reactor decode it into owned frames and PCM, which `session.decoded` reads in Node or Bun with no browser. A browser host hands you its own `MediaStreamTrack`s instead, through `session.tracks`.
```ts
import { Console, Effect, Stream } from "effect";
import { Media } from "reactor-effect-client";
import type { Session } from "reactor-effect-client";
/** Your encoder: takes one BGRA frame. */
declare const encode: (frame: Media.VideoFrame) => Effect.Effect;
// Each frame of H3's picture, and each run the host dropped, in order.
export const record = Effect.fn("record")(function* (
session: Session.Session,
) {
const media = yield* session.decoded;
yield* Media.recorder(media.video("main_video")).pipe(
Stream.runForEach((entry) =>
entry._tag === "Frame"
? encode(entry.frame)
: Console.log(
`dropped ${entry.count} frames after ${entry.after}`,
),
),
);
});
```
H3 names its tracks `main_video` and `main_audio`; `media.tracks` lists what the session negotiated. ## Decoded media [Section titled “Decoded media”](#decoded-media) `session.decoded` returns the current generation’s `DecodedMedia`: `video(name)` and `audio(name)` streams, `pressure`, the negotiated `tracks`, and `generation`. | `VideoFrame` | What it is | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `width`, `height` | The frame’s size; hosted H3 sends 1344×768 on its default canvas | | `format` | `BGRA` from the native and simulated hosts, or `RGBA` from a renderer of your own; four bytes a pixel, rows packed | | `data` | `width * height * 4` bytes, the whole of an `ArrayBuffer` of their own | | `sequence` | The frame’s admission number on its track, taken before any queue could drop it | | `frameId`, `timestampMicros` | The sender’s frame identity and clock, `bigint`; zero when absent | | `AudioFrame` | What it is | | ------------------------ | --------------------------------------------------------- | | `sampleRate`, `channels` | 48 kHz mono from hosted H3 | | `samples` | Interleaved signed 16-bit PCM, an `Int16Array` of its own | | `sequence` | The block’s admission number on its track | Every reader of a track receives the same frames, so treat their bytes as read-only and copy before changing them. Holding a frame holds its bytes: about 4 MB for a 1344×768 frame. On hosted H3, the native host in process under Bun received all 124 frames of a 5 s clip, 1344×768 BGRA at 23.7–24.2 fps, and its 48 kHz mono audio, with nothing lost, over a direct path at 7.2–8.3 Mbps ([0.3.0-rc.0](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.3.0-rc.0/summary.md), 2026-09-24). A playout’s reader took 2,372 frames over three sessions without falling behind ([0.8.0-api `show`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). ## Bounds, pressure and `Lost` gaps [Section titled “Bounds, pressure and Lost gaps”](#bounds-pressure-and-lost-gaps) Media is bounded at every step, so a slow reader never holds up the host or the session. On the native host, libwebrtc’s callbacks copy each frame into a queue of 8 video frames (333 ms at 24 fps) and 256 PCM blocks (2.56 s of 10 ms blocks); a full queue evicts its oldest item and counts it. Each reader then holds at most 24 video frames (a second at 24 fps) or 128 PCM blocks. A reader that falls further behind fails alone with `Overflow`. `media.pressure` reports what the generation dropped, delivered and still holds: `droppedVideo`, `droppedAudio`, `deliveredVideo`, `deliveredAudio`, the queued counts and bytes, `pendingRequests`, and `readerOverflows`, the readers that fell behind. Because a frame’s `sequence` is taken before any queue could drop it, a dropped frame is a gap in the sequences a reader sees. `Media.recorder(stream)` makes the gaps explicit: it yields `{ _tag: "Frame", frame }` for each frame and `{ _tag: "Lost", after, count }` wherever frames were dropped, so a recording can fill the gap instead of drifting out of sync. A sequence that goes back starts a new run, as a new generation does. ## A preview that never falls behind [Section titled “A preview that never falls behind”](#a-preview-that-never-falls-behind) A live preview wants the newest frame, not every frame. Keep one with a sliding buffer, and the reader never overflows however slowly it draws:
```ts
import { Effect, Stream } from "effect";
import type { Media, Session } from "reactor-effect-client";
export const preview = Effect.fn("preview")(function* (
session: Session.Session,
draw: (frame: Media.VideoFrame) => Effect.Effect,
) {
const media = yield* session.decoded;
yield* media
.video("main_video")
.pipe(
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.runForEach(draw),
);
});
```
## Generations [Section titled “Generations”](#generations) Every media value belongs to one connection generation. A reconnect makes a new generation and ends the old one’s readers, which fail with that generation’s failure. To follow the picture across reconnects, read `session.decoded` again each time the session is ready on a new generation:
```ts
import { Effect, Option, Stream } from "effect";
import type { Session } from "reactor-effect-client";
export const picture = (session: Session.Session) =>
session.changes.pipe(
Stream.filter((snapshot) => snapshot.status === "ready"),
Stream.map((snapshot) => snapshot.generation),
Stream.changes,
Stream.switchMap((generation) =>
Stream.unwrap(
Effect.map(session.decoded, (media) =>
media.video("main_video"),
),
).pipe(
// A retired generation's reader fails, and a newer one takes
// over. Other failures stand.
Stream.catch((error) =>
Stream.unwrap(
Effect.map(Effect.option(session.ready), (ready) =>
Option.isSome(ready) &&
ready.value.generation === generation
? Stream.fail(error)
: Stream.empty,
),
),
),
),
),
);
```
`playout.video` and `playout.audio` already do this, and continue across renewals too. ## Tracks in the browser [Section titled “Tracks in the browser”](#tracks-in-the-browser) A browser host has no decoded frames: `session.decoded` fails there with `UnsupportedCapability`. `session.tracks` returns the generation’s `TrackMedia` instead, and `BrowserMedia.tracks(session)` types it with the DOM’s `MediaStreamTrack`:
```ts
import { Effect } from "effect";
import { BrowserMedia } from "reactor-effect-browser";
import type { Session } from "reactor-effect-client";
export const watch = Effect.fn("watch")(function* (
session: Session.Session,
video: HTMLVideoElement,
) {
const tracks = yield* BrowserMedia.tracks(session);
// A clone of the received track, playing until the scope closes.
yield* BrowserMedia.play(yield* tracks.track("main_video"), video);
});
```
| `TrackMedia` | What it does | | ------------------------------ | ------------------------------------------------------------------ | | `track(name)` | Leases a clone of a received track, stopped when your scope closes | | `publish(name, track)` | Sends a local track on a send-only track the model declares | | `unpublish(name)` | Stops sending it | | `setTrackActive(name, active)` | Pauses or resumes a track, here and at Reactor | | `setMaxBitrate(name, bits)` | Caps a sent track’s bitrate | `BrowserMedia.play(track, element, { playTimeout })` refuses a track that is not live and an element that already has a source, and fails when playback takes longer than `playTimeout` (10 seconds) to start. A reconnect makes new tracks: follow `session.changes` to the next ready generation and lease them again. Reactor sends a connection no media until its receive-only tracks are resumed. `create` and `attach` resume them as each connection becomes ready, unless you pass `resumeTracks: false`. ## Which host gives what [Section titled “Which host gives what”](#which-host-gives-what) | Host | Media | Runs on | | ---------------------------- | ------------------------------------------------------ | -------------------------------------------------- | | `NativePeer.layer()` | Decoded BGRA frames and 16-bit PCM, in process | Node and Bun on linux-x64 (glibc) and darwin-arm64 | | `NativePeer.layerIsolated()` | The same, from a child process per connection | Node on the same platforms | | `BrowserPeer.layer` | The browser’s own tracks | Browsers with WebRTC | | `ReactorTest.layer(options)` | Decoded synthetic frames (16×16 by default) and a tone | Anywhere | The isolated host keeps a crash inside libwebrtc to one connection. Each child takes about half a second to start, which overlaps allocation, and every frame is copied across the process boundary. The simulated frames carry their clip and index, which `ReactorTest.frameOf(frame)` reads back in tests. ## Limits [Section titled “Limits”](#limits) * The native peer accepts at most one incoming video track and one incoming audio track. * Decode failures are not reported: the pinned `reactor-webrtc` surfaces no decoder errors. * Starting playback in a browser does not establish that anyone saw it, and a decoded frame does not establish that it was shown or encoded. * The browser host has no hosted run yet, and the native host has run on hosted Reactor only on linux-x64. ## Next [Section titled “Next”](#next) [Frames in Node and Bun](/reactor-effect-client/guides/native/)The native host, in process or isolated. [Sessions in the browser](/reactor-effect-client/guides/browser/)A page that runs its own session. [Playout](/reactor-effect-client/concepts/playout/)The on-air picture and sound across renewals. [Sessions and tokens](/reactor-effect-client/concepts/sessions/)Generations, reconnects and close reports.
# Playout
> A keyed schedule that carries H3 across renewing sessions, switching at a clip boundary: lanes, filler, windows, placement and as-run evidence.
`Playout` airs a schedule of clips across Reactor sessions. You submit keyed items into priority lanes; one plan decides what to build, in what order, what to withdraw, and when a fresh session takes over before the current one reaches its cap. What actually aired comes back as as-run evidence, kept apart from what you asked for.
```ts
import { Console, Effect } from "effect";
import {
CoordinatorClient,
H3,
H3Source,
Playout,
} from "reactor-effect-client";
const house = [
"A slow dolly shot through a sunlit greenhouse",
"Waves on a black sand beach",
];
export const channel = Effect.gen(function* () {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
const playout = yield* Playout.make({
// Called for the first session and for each replacement.
open: H3Source.open({
tokens: coordinator.tokens({
modelName: H3.modelName,
maxSessionDuration: "10 minutes",
}),
}),
lanes: [{ name: "breaking", cut: true }, { name: "show" }],
filler: {
runway: { floor: "5 seconds", target: "10 seconds" },
clip: ({ index, seconds }) => ({
prompt: house[index % house.length] ?? "",
seconds,
}),
},
renewal: { lead: "30 seconds" },
});
const opening = yield* playout.submit({
key: Playout.ItemKey.make("opening"),
lane: "show",
request: {
prompt: "A hand-painted sign that reads OPENING NIGHT",
seconds: 5,
},
});
const outcome = yield* opening.outcome;
yield* Console.log(`opening: ${outcome._tag}`);
}).pipe(Effect.scoped);
```
The same program runs on the simulated Reactor, on paid H3, or on a renderer of your own: only the [source](/reactor-effect-client/concepts/sources/) behind `open` changes. A pure policy makes every decision, and the service applies them one provider command at a time on each session. It wakes on a submission, a session’s evidence or the plan’s next deadline, and never polls. ## Items and keys [Section titled “Items and keys”](#items-and-keys) An item is one clip: a key, a lane and an [H3 request](/reactor-effect-client/concepts/h3/), with an optional start, window, cues and continuity. `submit` returns an `ItemHandle` whose `started` resolves with the clip’s start (or how it settled without one) and whose `outcome` resolves with how it settled. Keys make submissions idempotent. Submitting the same spec under a key again returns the same handle, and a different spec under it fails with `KeyMismatch`, so a caller can retry a submission safely. The playout remembers 4,096 settled keys (`maxHistory`). A submission is refused before anything is sent with one of these tagged errors: | Error | When | | ------------------- | -------------------------------------------------------------------------------------------------------------- | | `InvalidItem` | The request is outside H3’s documented limits, naming each field and limit, or names an unknown lane or anchor | | `KeyMismatch` | The key is in use with a different spec | | `LaneBusy` | The lane skips new items while one waits or plays | | `WouldMissDeadline` | The plan cannot start the item before its firm `startBy` | | `PlayoutClosed` | The playout is draining or closed | An item whose request omits `seconds` is sent at 5 seconds, the length the plan counts it at. A filler request without `seconds` is sent at the length passed to its `clip` callback in `FillContext.seconds`. A request that names its length is sent unchanged. ## Lanes [Section titled “Lanes”](#lanes) Lanes are listed from the highest priority to the lowest, and each has a conflict rule:
```ts
import type { Playout } from "reactor-effect-client";
export const lanes: ReadonlyArray = [
// Stops a lower lane's clip once its own item is Ready.
{ name: "breaking", cut: true },
// Refuses an item while one waits or plays.
{ name: "viewer", conflict: "skip" },
// A new item replaces the one waiting.
{ name: "ticker", conflict: "replace" },
// Waits behind its lane's items: the default.
{ name: "show" },
];
```
* **`queue`** (the default): a new item waits behind its lane’s items. * **`replace`**: a new item replaces the lane’s waiting items, make-before-break. They stay as cover until it is Ready, then settle `Dropped` with reason `replaced`. * **`skip`**: a new item is refused with `LaneBusy` while the lane has one waiting or playing. * **`cut: true`**: once its own item is Ready, the lane stops a playing clip of a strictly lower lane, or filler, unless that clip ends within a second. A clip is stopped at most once. On hosted H3, `stop` names no clip and lands after its reply, so a cut goes a step at a time: the playout turns autoplay off, stops the clip if it still plays, waits for H3 to report it ended, plays the cutter and turns autoplay back on. In the two `show` runs on hosted H3 (2026-09-28 and 2026-09-29), the cut took one `stop` and paused 168 ms, then 165 ms, with no dark frame. `Playout.lineup(filler)` is the simplest shape: one lane, `line`, above filler. ## Filler and its runway [Section titled “Filler and its runway”](#filler-and-its-runway) Filler is the bottom lane, and nothing is owed to it: the playout asks for a filler clip whenever the air it has secured runs low. The air secured, `state.runwaySeconds`, is the rest of the clip playing and the Ready clips that will air after it, on the session on air and then on its replacement. * `runway: { floor, target }`: below `floor`, filler refills up to `target`. Once three builds are measured, the floor covers at least one slow (p95) build, so a refill started there is Ready in time. * `clip(context)`: called once per filler clip, with its `index`, the `runwaySeconds` and the `seconds` to ask for. Keep it pure. A request outside H3’s limits fails the playout with `InvalidFiller`, since asking again would get the same request. * `lengths`: the lengths a filler clip may take, H3’s request range by default. Before an `At` item, filler tiles the gap in equal clips. Where a tile would air past a capped session’s cap, the playout asks for the longest clip that airs before it, if `lengths.min` fits. H3 aligns each up to its frame grid, so the `At` item may air up to 0.7 s late for each tile. The plan counts a filler clip not yet sent at the `seconds` it asks `clip` for, so a callback that returns another length moves the tiling, and `place`’s `startsAt`, by the difference. * `protect`: what goes first when an item’s build would outlast the air secured. With `"air"`, the default, once three builds are measured, such an item waits for one filler clip that builds sooner and covers the rest. An item with a time to meet (an `At` start, a `startBy`, an `Asap` start, a released `Manual` one or a cutting lane) goes at once. `"order"` builds each item as soon as it may: it airs sooner, but the air may go dark while it builds. ## Starts and windows [Section titled “Starts and windows”](#starts-and-windows) An item’s `start` says when it may air. Only `Follow` keeps its lane’s order. | `start` | Airs | | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `Follow` | At its lane’s next boundary, in order: the default | | `Asap` | At the next boundary, ahead of everything waiting in every lane | | `Manual` | Held until `release(key)`, then as `Asap`; built ahead only while filler holds the runway at its floor, else once released | | `At { time, late }` | At the first boundary after `time` (epoch milliseconds, wall clock) | `late` says what to do when that boundary comes late: `nextBoundary`, `skipIfLaterThan` a duration, or `drop`. A `window` adds `notBefore` and `startBy`, measured from submission on the monotonic clock. A `firm` item is dropped at `startBy` with reason `late`, and refused up front with `WouldMissDeadline` when the plan cannot make it; a soft one may still air, with `lateByMillis` on its start. A start already under way at a deadline, a clip H3 holds armed for its seam or a `play` of it in flight, may still land, up to a command’s round trip past it.
```ts
import { Clock, Effect } from "effect";
import { Playout } from "reactor-effect-client";
export const schedule = Effect.fn("schedule")(function* (
playout: Playout.Playout["Service"],
) {
const now = yield* Clock.currentTimeMillis;
// The next full minute, skipped if it would start over 2 s late.
yield* playout.submit({
key: Playout.ItemKey.make("ident"),
lane: "show",
request: {
prompt: "A station ident spinning into view",
seconds: 5,
},
start: {
_tag: "At",
time: now - (now % 60_000) + 60_000,
late: { _tag: "skipIfLaterThan", by: "2 seconds" },
},
});
// Built ahead while filler covers the air; aired when released.
yield* playout.submit({
key: Playout.ItemKey.make("reveal"),
lane: "show",
request: {
prompt: "A curtain rising on a painted forest",
seconds: 8,
},
start: { _tag: "Manual" },
});
// Dropped unless it can start within 20 s.
yield* playout.submit({
key: Playout.ItemKey.make("viewer-17"),
lane: "show",
request: {
prompt: "A paper lantern drifting along a canal",
seconds: 5,
},
window: { startBy: "20 seconds", firm: true },
});
});
```
On hosted H3, an `At` item aired 559 ms after its time on 2026-09-28 and 545 ms on 2026-09-29, at the end of a filler tile H3 had built a little longer than asked, and never before its time. ## Cues and continuity [Section titled “Cues and continuity”](#cues-and-continuity) Cues fire at offsets from a clip’s observed start or end, as `Cue` events on `playout.events`. `continuity: "previous"` builds the clip continuing from the one that airs just before it.
```ts
import { Effect, Stream } from "effect";
import { Playout } from "reactor-effect-client";
export const segment = Effect.fn("segment")(function* (
playout: Playout.Playout["Service"],
) {
yield* playout.submit({
key: Playout.ItemKey.make("kitchen-2"),
lane: "show",
request: {
prompt: "The chef plates the dish, close up",
seconds: 8,
},
continuity: "previous",
cues: [
{ name: "caption-in", at: { from: "start", offset: "1 second" } },
{ name: "caption-out", at: { from: "end", offset: "2 seconds" } },
],
});
return playout.events.pipe(
Stream.filter((event) => event._tag === "Cue"),
Stream.map((event) => `${event.event.key}: ${event.event.name}`),
);
});
```
A continued build takes longer: on hosted H3 a continued 5 s clip built in 5.45 s, against about 2.2 s for an independent one (published 0.7.0, 2026-09-28). When a continued build would be late, the playout continues from the clip that will be playing by then instead. It never asks for continuity across a switch of sessions, and H3 may build an independent clip without saying so, so as-run never claims continuity. In the passing `edits` and `show` runs on hosted H3 (2026-09-28 and 2026-09-29), a continued seam changed the picture 2.3 to 5.8 times as much as the clip’s own motion, against 11 to 20 times at their independent seams; one cut seam measured 6.5 times. ## Edits, make-before-break [Section titled “Edits, make-before-break”](#edits-make-before-break) Every edit is make-before-break: what it replaces stays on air, or ready to air, until what replaces it is Ready. | Method | What it does | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `submitGroup(group)` | Parts built in order and aired back to back; a higher lane may go between | | `insert(spec)` | A clip right `before` or `after` an item, taking its lane, place, group and start | | `replace(key, next)` | A new clip for a queued item’s place; if the item starts first, `next` is dropped as `withdrawn` | | `edit(edits)` | Several of these, checked together and applied as one change | | `withdraw(key)` | Answers `withdrawn`, `already-started` or `not-found` | | `release(key)` | Airs a held `Manual` item at the next boundary | | `drain({ finish })` | Admits nothing more. `playing`, the default, withdraws what has not started; `accepted` airs everything accepted first, except held `Manual` items |
```ts
import { Effect } from "effect";
import { Playout } from "reactor-effect-client";
export const correct = Effect.fn("correct")(function* (
playout: Playout.Playout["Service"],
) {
const batch = yield* playout.edit([
{ _tag: "Withdraw", key: Playout.ItemKey.make("weather") },
{
_tag: "Insert",
insert: {
key: Playout.ItemKey.make("correction"),
after: Playout.ItemKey.make("headlines"),
request: {
prompt: "A newsroom desk with a single corrected headline",
seconds: 5,
},
},
},
]);
// All it adds is Ready or settled; what it removes went at once.
yield* batch.committed;
});
```
A batch holds what it adds behind the clips queued until all of it is Ready, though with nothing else to air H3 may start one first, and a replacement builds ahead of every other item waiting in its lane except an `Asap` one. Send an urgent insert alone, and a replacement meant to air after it as a second edit once the insert is `Building`. In the two `show` runs on hosted H3, an edit batch took effect 0.91 s before its boundary (2026-09-28) and 1.05 s before it (2026-09-29), and the clip it withdrew never aired. ## Placement [Section titled “Placement”](#placement) Some clips depend on the clips around them, such as an acknowledgement written for what just aired. `place` projects where a clip would land if you submitted it `submitIn` from now, before you write it. Submitted with `follows: placement.after`, the clip airs right after that clip, across a renewal too, or is dropped as `displaced`.
```ts
import { Effect } from "effect";
import { Playout } from "reactor-effect-client";
export const acknowledge = Effect.fn("acknowledge")(function* (
playout: Playout.Playout["Service"],
write: (placement: Playout.Placement) => Effect.Effect,
) {
const key = Playout.ItemKey.make("ack-1");
const placement = yield* playout.place({
key,
seconds: 5,
submitIn: "3 seconds",
});
// Null: no boundary can be made yet.
if (placement === null) return undefined;
const request = { prompt: yield* write(placement), seconds: 5 };
const follows = placement.after;
return placement.anchor === "next"
? yield* playout.submit({
key,
lane: "show",
request,
start: { _tag: "Asap" },
follows,
})
: yield* playout.insert({
key,
after: placement.anchor,
request,
follows,
});
});
```
A `Placement` names the clip it would follow (`after`), the clip projected after it (`before`), when it would start (`startsAt`), how to submit it (`anchor`: insert after that item, or `"next"`, an `Asap` submission to the lowest lane) and what the projection rests on (`basis`: `ready`, `projected` or `unmeasured`). `place` projects the plan as it is, at the median build rates, and reserves nothing. `before` is not enforced, since a clip submitted after the call may come between. A clip not built yet counts at the length the last clip that asked for as much aired at, so `startsAt` may be off by up to 0.7 s for each clip ahead at a length not asked for before. A `follows` clip gets no filler cover, and a cutting lane refuses it. While a follower waits, autoplay is fenced before its build once a clip H3 reported starting plays on that session, so an early end cannot air it before the clip it follows. A follower not Ready, on a session still open, when that clip ends, fails on air or is lost on air with its session, is dropped as `displaced`; a planned switch keeps it. While it waits, its session starts each clip with a provider command, a round trip after the boundary that `startsAt` does not project, so a command whose outcome is unknown can hold the air dark there until H3’s reply deadline. A follower of an item in a pending batch may be built and then dropped before the batch commits. `place` answers a boundary only with a clip projected Ready a readiness margin and one provider command before it, and on a replacement only if it airs there before that session’s cap. ## Renewal across sessions [Section titled “Renewal across sessions”](#renewal-across-sessions) A capped session builds only what can air before its cap. The replacement opens `lead` before the cap, and never earlier, since a replacement opened earlier would bill while it waits to air. It builds what cannot air on the retiring session in time, and holds it Ready with autoplay off. Once the retiring session has nothing left to play and `grace` has passed since its last clip ended, the replacement takes the air at that boundary, and the retiring session is closed. cap − leadswitchA’s cap Session A clip 1 clip 2 filler closed Session B opens clip 3 built, held clip 3 clip 4 Billed A and B together: at most lead A renewal. B opens `lead` before A’s cap and builds clip 3, which cannot air on A in time, holding it Ready with autoplay off. A airs what it has, here a filler clip. When that clip ends and `grace` has passed, B takes the air at the boundary and A is closed, so the two sessions bill together only from B’s start (its `ready`, by Reactor’s billing page) to A’s close. | `renewal` option | Default | What it does | | ------------------ | ---------- | ----------------------------------------------------------------------- | | `lead` | 30 seconds | Opens the replacement this long before the session’s cap | | `grace` | 250 ms | Waits this long after the retiring session’s last clip before switching | | `openTimeout` | 3 minutes | Bounds one open, the wait for a GPU included | | `maxSetupFailures` | 3 | Failed setups in a row that end the playout | A session lost before a planned switch is closed, and the clips it never aired are rebuilt on the next one (`Replaced`, with the number `carried`). A failed open is tried again a second later for each failure in a row, at most `maxSetupFailures` seconds later, and never sooner than a refusal’s `Retry-After`. After `maxSetupFailures` failures in a row the playout fails, unless a session still holds the air: it then airs on and tries once more when that session ends. An open refused with nothing allocated, such as a `4xx`, billed nothing, and while a session holds the air it does not count. When content moderation ends a session, the playout blames the item whose enqueue was sent there last, since the verdict names no clip, and fails it as `Moderated`. It stops after `maxModerations` (2) moderated sessions rather than keep opening paid ones. On hosted H3, the planned switch left a gap of 420 ms on air on 2026-09-28, and 432 ms on 2026-09-29. When moderation ended a session in the first of those runs, the clip it had Ready was rebuilt on a new session opened 3.17 s after the verdict, and aired 5.3 s after the lost session’s clip left the air. ## As-run evidence [Section titled “As-run evidence”](#as-run-evidence) `playout.asRun` streams what became of each item, kept apart from what was asked for: | Status | Meaning | | ------------ | -------------------------------------------------------------------------------------- | | `Accepted` | Admitted; with `carried` when it is rebuilt after its session was lost | | `Building` | Sent to a session, building | | `Ready` | Built, waiting to air | | `Started` | Seen to start: `at`, `sessionId`, `seconds`, and `lateByMillis` for a late soft window | | `Ended` | `finished` or `stopped`, with `airedSeconds` | | `Dropped` | `late`, `withdrawn`, `replaced` or `displaced` | | `Failed` | With a `reason`: `Clip`, `Command`, `Lost`, `Moderated` or `Closed` | | `Unobserved` | Acknowledged, but its start was never seen | | `Unknown` | Sent, but its acknowledgement was never seen; `terminal` once nothing can settle it | An `Unknown` item is never sent again, and no time is invented for it. A `Clip` failure keeps the provider’s own words `Redacted` in `provider`, out of messages, logs and spans.
```ts
import { Console, Stream } from "effect";
import type { Playout } from "reactor-effect-client";
export const asRunLog = (playout: Playout.Playout["Service"]) =>
playout.asRun.pipe(
Stream.map(({ key, status }) =>
status._tag === "Started"
? `${key} started on ${status.sessionId}`
: `${key} ${status._tag}`,
),
Stream.runForEach(Console.log),
);
```
`playout.events` adds cues, session events (`Opened`, `Switched`, `Replaced`, `SetupFailed`, `Moderated`, `Reconnecting` and `Reconnected`), each filler clip’s start and end, a `ReaderOverflow` when a reader of the picture or sound falls behind, and `Starved` when nothing is left to play. `playout.state` holds the clip on air, the lanes, each session’s role (`on-air`, `replacement` or `retiring`), the runway and the build estimates the playout has learned. `playout.video` and `playout.audio` are the on-air picture and sound as decoded frames, continuing across renewals. On hosted H3, one playout aired 20 clips over three 75 s sessions, with filler holding the air: 2,372 frames at 22.2 fps, none lost, and seams of 53–169 ms with no dark frame. A second run, on the 0.8.0 release candidate, measured seams of 46–168 ms. Both passed every criterion ([0.8.0-api](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28; [0.8.0-rc](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-rc/summary.md), 2026-09-29). ## Failure and cleanup [Section titled “Failure and cleanup”](#failure-and-cleanup) `playout.failure` completes with why the playout stopped: a session it could not open or keep, `InvalidFiller`, `Moderated`, or `Closed` when its scope closed. A supervisor restarts it by failing with that reason under a `Schedule`:
```ts
import { Effect, Schedule } from "effect";
import { Playout } from "reactor-effect-client";
export const supervised = Effect.fn("supervised")(
function* (options: Playout.Options) {
const playout = yield* Playout.make(options);
// Submit the programme here. Closing the scope closes the
// playout and its sessions.
return yield* Effect.flatMap(playout.failure, Effect.fail);
},
Effect.scoped,
Effect.retry(Schedule.exponential("5 seconds")),
);
```
`playout.cleanup` holds the close reports of retired sessions and of opens that failed after allocating: every one that may still bill, and the latest others. Closing the playout’s scope retires every session. | Option | Default | What it does | | ------------------- | ---------- | ----------------------------------------------------------------------------- | | `maxBuildsInFlight` | 1 | Builds at once on the session that takes new work | | `maxHistory` | 4,096 | Settled keys kept for idempotency | | `unknownTimeout` | 60 seconds | How long an enqueue’s outcome may stay unknown before its session is replaced | | `maxModerations` | 2 | Sessions moderation may end before the playout fails | An enqueue whose outcome stays unknown holds no build slot meanwhile, since H3 builds in order. ## Limits [Section titled “Limits”](#limits) * The hosted numbers on this page come from 2026-09-28 and 2026-09-29: the two `show` runs of the 0.8.0 playout, each over three 75 s sessions, and one-session `edits` and `cut` runs of 0.7.0 and of 0.8.0 in development. On 0.9.0 only the one-session `showreel` check has run on hosted Reactor (five scenes, seams of 89–120 ms, 2026-10-01): its `place` and `follows`, its renewal and its changes to how the air ahead is counted have run only on the simulated Reactor. No multi-hour run on hosted H3 has been made. * A `Started` status is H3’s report that a clip started, not proof that a frame was presented or encoded. * On a host with only platform tracks, such as a browser, `playout.video` and `playout.audio` fail with `UnsupportedCapability`. * Editorial priority, pricing and what counts as on air for your audience stay with your application. ## Next [Section titled “Next”](#next) [Sources](/reactor-effect-client/concepts/sources/)H3Source for paid H3, LocalSource for a renderer of your own. [Run a 24/7 channel](/reactor-effect-client/guides/channel/)One playout broadcast to many viewers. [Hosted evidence](/reactor-effect-client/reference/hosted-evidence/)Every hosted run, its commit, criteria and cost. [Errors](/reactor-effect-client/concepts/errors/)Tagged failures and dispatch evidence.
# Sessions and tokens
> One session owns each allocation: its tokens, connection generations, commands and cleanup evidence.
A session is one model running for you on Reactor’s GPUs. The `Reactor` service acquires sessions: `create` allocates one that this process owns, and `attach` joins one that is already running. Either way the session lives in your scope, connected, and closing the scope closes it.
```ts
import { Console, Effect, Layer, Redacted } from "effect";
import {
CoordinatorClient,
H3,
Reactor,
ReactorTest,
Session,
} from "reactor-effect-client";
export const program = Effect.gen(function* () {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
const reactor = yield* Reactor.Reactor;
const session = yield* reactor.create({
model: H3.modelName,
// Tokens that may create one session, capped at two minutes.
tokens: coordinator.tokens({
modelName: H3.modelName,
maxSessionDuration: "2 minutes",
}),
});
const { status, generation } = yield* session.snapshot;
yield* Console.log(
`${session.id}: ${status}, generation ${generation}`,
);
const report = yield* session.close;
yield* Console.log(
`termination confirmed: ${report.remote.confirmed}`,
);
yield* Console.log(`may still bill: ${Session.mayStillBill(report)}`);
}).pipe(Effect.scoped);
// Reactor simulated in memory: no key and no native code.
// The hosted layers are below.
export const Offline = Reactor.layer().pipe(
Layer.provideMerge(
CoordinatorClient.layer({ apiKey: Redacted.make("demo") }),
),
Layer.provideMerge(
ReactorTest.layer({
timing: ReactorTest.Timing.hosted,
apiKey: "demo",
}),
),
);
```
Provide the layer once, at your entry point: `program.pipe(Effect.provide(Offline), NodeRuntime.runMain)`. The [Quickstart](/reactor-effect-client/start/quickstart/) runs a complete program. ## What a session owns [Section titled “What a session owns”](#what-a-session-owns) One `Session` owns each allocation or attachment. The H3 provider and the playout build on a session and never allocate around it, so every paid second traces back to one session and its close report. | Area | Members | | --------------------- | --------------------------------------------------------------------------- | | Identity | `id`, `ownership` (`"owned"` or `"attached"`) | | State and events | `snapshot`, `changes`, `observe`, `events`, `ready` | | Commands | `command`, `upload`, `schema`, `stats`, `requestRecordingClip`, `recording` | | Media | `decoded` or `tracks`, for the current connection generation | | Lifetime and evidence | `reconnect`, `close` and its `CloseReport` | ## The layers under `Reactor` [Section titled “The layers under Reactor”](#the-layers-under-reactor) `Reactor.layer()` needs a `CoordinatorClient`, which speaks Reactor’s HTTP API over an Effect `HttpClient`, and a host’s `PeerFactory`, which makes each WebRTC connection. Pick the host for where the code runs. * Node and Bun
```ts
import { Layer } from "effect";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import { CoordinatorClient, Reactor } from "reactor-effect-client";
import { NativePeer } from "reactor-effect-native";
// Reads REACTOR_API_KEY, and REACTOR_API_URL if it is set.
export const Hosted = Reactor.layer().pipe(
Layer.provideMerge(
Layer.mergeAll(CoordinatorClient.layerConfig, NativePeer.layer()),
),
Layer.provide(FetchHttpClient.layer),
);
```
* Browser
```ts
import { Layer } from "effect";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import { CoordinatorClient, Reactor } from "reactor-effect-client";
import { BrowserPeer } from "reactor-effect-browser";
// No API key in the page: each session asks your server for its tokens.
export const InPage = Reactor.layer().pipe(
Layer.provide(
Layer.mergeAll(CoordinatorClient.layer(), BrowserPeer.layer),
),
Layer.provide(FetchHttpClient.layer),
);
```
* Offline `ReactorTest.layer({ timing })` provides the `HttpClient` and the `PeerFactory` from a simulated Reactor, as in the example at the top of this page. See [Test offline](/reactor-effect-client/guides/testing-offline/). Building a host layer is its preflight. `NativePeer.layer()` loads the addon and `BrowserPeer.layer` checks for WebRTC, so a host that cannot run fails before anything is allocated, and the factory’s `check` runs again before every allocation. Over `FetchHttpClient`, every coordinator request omits ambient credentials and refuses redirects. ## Tokens [Section titled “Tokens”](#tokens) A session never sees your API key. It runs on `Tokens`, two effects you supply: * `create`: a token that may create one session; * `bind(sessionId)`: a fresh token bound to that open session. `coordinator.tokens({ modelName, maxSessionDuration })` mints both with the key the `CoordinatorClient` holds. `maxSessionDuration` is required. It is whole seconds from one second to a day, or `"unlimited"`, because an uncapped session bills until something ends it.
```ts
import { Effect } from "effect";
import { CoordinatorClient, H3 } from "reactor-effect-client";
export const channelTokens = Effect.gen(function* () {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
return coordinator.tokens({
modelName: H3.modelName,
// Reactor ends each session at its cap.
maxSessionDuration: "30 minutes",
// Each token's own life.
expiresAfter: "1 hour",
});
});
```
The session keeps one token for all its calls. Before that token expires (a minute before, or a quarter of a shorter token’s life), the session’s next call mints a fresh one with `bind(session.id)`, so a session outlives any one token. Reactor keeps a token for an hour by default and six hours at most, while a session can run a day or more. On hosted Reactor, a session created on a 30 s token minted a token bound to itself 22.9 s in, and reconnected 2 s after the creating token had expired ([0.8.0-api `tour`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). ### Grants [Section titled “Grants”](#grants) `coordinator.mintToken(options)` mints one token directly. Besides `modelName`, `maxSessionDuration` and `expiresAfter`, it takes `maxSessions` (1 to 500; one for a token that binds nothing) and `bind`, the open sessions the token acts on besides those it creates. It returns a `TokenGrant`: | Field | What it is | | ------------------- | -------------------------------------------------------------------------------- | | `jwt` | The token, `Redacted` | | `expiresAt` | When it expires, in seconds since the epoch, as Reactor set it | | `maxSessionSeconds` | The cap on each session it creates: as asked, or the narrower cap Reactor states | | `granted` | Reactor’s echo of what it granted, when the reply carries one | A grant Reactor echoes wider than asked is refused as a `Protocol` failure. A narrower one caps each session at what it grants. `CoordinatorClient.fixedTokens(grant)` serves one grant for both effects, for a session shorter than its token; that session never refreshes its token.
```ts
import { Effect } from "effect";
import { CoordinatorClient, H3, Reactor } from "reactor-effect-client";
// One token creates the session and carries its every call.
export const shortSession = Effect.gen(function* () {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
const reactor = yield* Reactor.Reactor;
const grant = yield* coordinator.mintToken({
modelName: H3.modelName,
maxSessionDuration: "90 seconds",
expiresAfter: "150 seconds",
});
return yield* reactor.create({
model: H3.modelName,
tokens: CoordinatorClient.fixedTokens(grant),
});
});
```
### Binding a token to a session [Section titled “Binding a token to a session”](#binding-a-token-to-a-session) In a browser the page holds no key, so its `Tokens` ask your server: `create` for a token that may create one session, and `bind` for one bound to the session the page created. The server mints the bound token with `bind: [sessionId]`. A bound token commands, watches and ends its session, so bind only sessions the caller created.
```ts
import { Effect } from "effect";
import { CoordinatorClient, H3 } from "reactor-effect-client";
// On your server, after checking that this caller created `sessionId`.
export const bindToken = Effect.fn("bindToken")(function* (
sessionId: string,
) {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
return yield* coordinator.mintToken({
modelName: H3.modelName,
bind: [sessionId],
expiresAfter: "10 minutes",
});
});
```
Each call has its own credential. `mintToken` and `tokens` use the API key. `inspect`, `terminate` and `downloadClip` use the `CoordinatorClient`’s `credential` option, or the key when none is set, so a server that holds the key can read and end any session of its account. A session’s own calls use its token. ## Create, attach, adopt [Section titled “Create, attach, adopt”](#create-attach-adopt) | Call | Ownership | Closing it | | ---------------------------------------------------- | ---------- | -------------------------- | | `reactor.create({ model, tokens })` | `owned` | terminates the session | | `reactor.attach({ sessionId, tokens })` | `attached` | leaves the session running | | `reactor.attach({ sessionId, tokens, adopt: true })` | `owned` | terminates the session | `create` takes an `onAllocated` hook that runs after allocation and before connecting, so a supervisor can record the owner first. If the hook fails, the session is closed and `create` fails with an `AcquisitionFailure` carrying the close report. `attach` needs only tokens bound to the session. `adopt: true` takes over the session’s remote lifetime, as a process resuming a dead owner’s session does.
```ts
import { Effect } from "effect";
import { CoordinatorClient, H3, Reactor } from "reactor-effect-client";
// Take over a session whose owner died, from the id it recorded.
export const adopt = Effect.fn("adopt")(function* (sessionId: string) {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
const reactor = yield* Reactor.Reactor;
return yield* reactor.attach({
sessionId,
tokens: {
bind: (id) =>
coordinator.mintToken({ modelName: H3.modelName, bind: [id] }),
},
adopt: true,
});
});
```
An acquisition resumes the session’s receive-only tracks as each connection becomes ready (`resumeTracks`, true by default), since Reactor sends a connection no media until it does. On hosted Reactor, an owner was killed 12.7 s into the run. A plain attach was ready 3.06 s after the kill, with fresh frames and nothing enqueued. `H3Source.resume` started 0.57 s after the creating token had expired and adopted the session 2.71 s later ([0.8.0-api `adoption`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). ## Status and connection generations [Section titled “Status and connection generations”](#status-and-connection-generations) `session.snapshot` reads the session’s status, and `session.changes` streams a snapshot at every change, in order, even to a reader that falls behind. | `status` | Meaning | | -------------- | ------------------------------------------------------------------------------- | | `idle` | Acquired, not yet connecting | | `connecting` | Allocating, or opening a new connection | | `waiting` | Waiting for the model, then negotiating WebRTC | | `ready` | Connected, with both data channels open | | `disconnected` | The connection dropped; `reconnecting` says whether the session is trying again | | `closing` | `close` has begun | | `closed` | Closed | Each connection is a generation, numbered in `snapshot.generation`. A reconnect makes a new generation of the same session: it allocates nothing and never replays a command. Each generation’s media and replies belong to it. A reconnect ends the old generation’s readers, and a reply that arrives late from an old generation is published with the correlation `stale-generation`. `session.observe()` pairs a snapshot with every event after it, with no gap. ## Reconnecting [Section titled “Reconnecting”](#reconnecting) A session reconnects a dropped connection on its own, owned or attached. It tries again at once, then on its `reconnect` schedule: 250 ms after the first failure, doubling to at most 4 s, jittered, and never sooner than a refusal’s `Retry-After`. It stops once a connection has stayed ready for 10 seconds, when Reactor or its moderation ended the session, when Reactor refuses this client’s protocol (`VersionMismatch`), or once `reconnectTimeout` (30 seconds) has passed since the drop. Meanwhile the status goes `disconnected`, then `connecting`, `waiting` and `ready` on the new generation, and the snapshot’s `reconnecting` is true until a connection is ready or the session stops trying. `disconnected` without `reconnecting` is lasting, and `lastError` says why. Each failed attempt is a `Diagnostic` event. * `Reactor.layer({ reconnect: false })` leaves a dropped connection down; Reactor then ends the session 30 seconds after its last connection dropped. * `session.reconnect` opens a new generation now, from a ready connection or a lasting drop. While the session reconnects on its own, it joins that reconnect instead of starting another. On hosted H3, a playout’s connection was cut with 8.07 s of air secured. It was ready again 1.64 s later, and its picture resumed 1.82 s after the drop, on the same session ([0.8.0-api `show`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). Reconnecting keeps a session billing A session keeps billing through its drops. An owned session your application never closed, or one a viewer holds after its owner has gone, runs for as long as the process holding it does, to its cap if it has one. ## Commands [Section titled “Commands”](#commands) `session.command(name, data)` sends one model command and resolves with its `CommandReply`: the reply’s `kind` (`ack` or `message`, with its `type` and `data`), and its attribution: `requestId`, `sequence`, `generation` and `correlation`. The [H3 provider](/reactor-effect-client/concepts/h3/) wraps these for H3; `command` is the escape hatch for anything it does not cover. `replyTimeout` is the library’s own deadline for a reply, not your wait. Once the command has been sent, its expiry fails with `Timeout` and outcome `unknown`, since Reactor may have applied it. To stop waiting without abandoning a command, fork it and bound the join:
```ts
import { Effect, Fiber } from "effect";
import type { Session } from "reactor-effect-client";
export const setSeed = Effect.fn("setSeed")(function* (
session: Session.Session,
) {
const fiber = yield* Effect.forkScoped(
session.command("set_seed", { seed: 1 }),
);
// Fiber.await(fiber) still reads the command's own outcome later.
return yield* Fiber.join(fiber).pipe(Effect.timeout("2 seconds"));
});
```
The session also carries uploads (`upload`), the deployment’s OpenAPI document (`schema`), connection statistics (`stats`) and recordings (`requestRecordingClip` and `recording`, then `coordinator.downloadClip`). ## Deadlines [Section titled “Deadlines”](#deadlines) `Reactor.layer(options)` sets every session’s deadlines. Each duration is a `Duration.Input`; a bare number is milliseconds, so write the unit. A deadline must be positive and at most ten minutes, or the layer fails with `InvalidInput`. | Option | Default | Bounds | | ------------------- | -------------------------------- | ------------------------------------------------------- | | `replyTimeout` | 10 seconds | A command’s or control request’s reply | | `uploadTimeout` | 60 seconds | An upload, in all | | `connectTimeout` | 3 minutes | Allocation and negotiation, the wait for a GPU included | | `reconnectTimeout` | 30 seconds | A reconnect, from the drop | | `readyTimeout` | 30 seconds | The peer and both channels, after the answer | | `heartbeatInterval` | 10 seconds | Between heartbeats; `"Infinity"` for none | | `reconnect` | 250 ms doubling to 4 s, jittered | A session’s own reconnect, a `Schedule`, or `false` | | `maxPending` | 128, at most 4,096 | Requests awaiting a reply, per channel | | `maxUploadBytes` | 16 MiB, at most 64 MiB | The largest upload | Reactor’s billing page says waiting for a GPU is not billed. ## Closing, and what may still bill [Section titled “Closing, and what may still bill”](#closing-and-what-may-still-bill) `session.close` is idempotent. For a session this process owns, it asks Reactor to terminate it and then confirms the end with an independent read, since a `DELETE` response alone proves nothing. It returns a `CloseReport`, a Schema you can persist: | Field | What it says | | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `allocation` | `none`, `known` (with `sessionId` and `ownership`) or `unknown` | | `remote` | The termination verdict: `attempted`, `confirmed`, `evidence` (`absent` or `terminal`), `deleteStatus`, `state` | | `localClosed`, `localErrors` | Whether local cleanup succeeded, and every cleanup failure | | `unpublishSubmitted`, `unresolvedPublications` | Published tracks the close released, and those it could not settle | `Session.mayStillBill(report)` is true for a session this process owned that no read confirmed ended, and for an allocation whose outcome, and so whose id, never arrived. Nothing can confirm the second ended; its token’s cap bounds it.
```ts
import { Effect, Schema } from "effect";
import { Session } from "reactor-effect-client";
const encodeReport = Schema.encodeEffect(
Schema.toCodecJson(Session.CloseReport),
);
export const closeAndRecord = Effect.fn("closeAndRecord")(function* (
session: Session.Session,
) {
const report = yield* session.close;
if (Session.mayStillBill(report))
yield* Effect.logWarning("a session may still be billing");
// JSON to keep with your own records.
return yield* encodeReport(report);
});
```
In 21 paid runs from 2026-09-24 to 2026-09-29, all 27 sessions ended with their termination confirmed. Where it was timed, the confirming read found `CLOSED` 0.63–0.86 s after the request ([hosted evidence](/reactor-effect-client/reference/hosted-evidence/)). ## `CoordinatorClient`, the HTTP API [Section titled “CoordinatorClient, the HTTP API”](#coordinatorclient-the-http-api) `CoordinatorClient` is Reactor’s HTTP API over your `HttpClient`. Building it makes no request. | Member | What it does | | ---------------------- | -------------------------------------------------------------------------- | | `tokens`, `mintToken` | Mint session tokens with the API key | | `pricing` | The pricing catalog; `CoordinatorClient.modelRate` reads one model’s rate | | `inspect(sessionId)` | Reactor’s view of a session: its `state`, cluster, zone and server version | | `terminate(sessionId)` | End a session; returns a verdict and never fails | | `downloadClip(clip)` | Download a recording a session prepared | `CoordinatorClient.layer({ apiUrl, apiKey, credential })` takes its settings directly, and `CoordinatorClient.layerConfig` reads `REACTOR_API_URL` (by default `https://api.reactor.inc`) and `REACTOR_API_KEY` from the environment. A session’s create and signaling requests name this SDK in their `client_info`, as `sdk_type: "typescript-effect-independent"`. `CoordinatorClient.isTerminal(state)` is true only for `CLOSED`. Reactor reads `INACTIVE` for a session whose last connection has gone, and that session is still live and billed until Reactor ends it 30 seconds later, unless a connection returns. ## Limits [Section titled “Limits”](#limits) * Reactor’s account quotas apply: by default five sessions at once and ten created a minute, at most three back to back, refused with `429` beyond that. * A token lives at most six hours, and a capped session at most a day. A channel that runs past its session’s cap renews the session, which [Playout](/reactor-effect-client/concepts/playout/) does for you. * The library keeps no pool of sessions. Effect’s `Pool.makeWithTTL` over `reactor.create`, with `min: 0` and a short `timeToLive`, closes a session left idle. * Reactor’s billing page says a session bills from `ready` until it ends. On 2026-09-30 Reactor’s pricing API listed H3 at 350 credits a second (10,000 credits to the dollar): $0.035 a second, or $2.10 a minute. The billing page still says per session-minute. * On hosted Reactor, a session read `ACTIVE`, not `INACTIVE`, for at least the first 2.1 seconds after its last connection closed, when the reads stopped ([0.8.0-api](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). ## Next [Section titled “Next”](#next) [The H3 provider](/reactor-effect-client/concepts/h3/)Prompt H3 over a session and follow each clip. [Errors](/reactor-effect-client/concepts/errors/)Tagged reasons, and whether a failed command reached Reactor. [Cost control](/reactor-effect-client/guides/cost-control/)Caps, confirmed termination and budgets. [Going live](/reactor-effect-client/start/going-live/)From the simulated Reactor to a paid session.
# Sources
> Where a playout's sessions come from: H3Source for paid H3, LocalSource for a renderer of your own.
A playout gets its sessions from an `open` effect, which it calls for the first session and for each replacement. `H3Source.open` opens a paid H3 session, and `LocalSource.open` renders clips in your own process. Both return a `Playout.Source`, so the same programme runs on either.
```ts
import { Effect, FileSystem, Schema } from "effect";
import {
CoordinatorClient,
H3,
H3Source,
Playout,
} from "reactor-effect-client";
const encodeOwner = Schema.encodeEffect(
Schema.fromJsonString(H3Source.Allocation),
);
export const recordedChannel = Effect.gen(function* () {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
const fs = yield* FileSystem.FileSystem;
return yield* Playout.make({
open: H3Source.open({
tokens: coordinator.tokens({
modelName: H3.modelName,
maxSessionDuration: "1 hour",
}),
canvas: "16:9",
// Runs before the session connects, so no session goes unrecorded.
onAllocated: ({ allocation }) =>
encodeOwner(allocation).pipe(
Effect.flatMap((json) =>
fs.writeFileString(
`owners/${allocation.sessionId}.json`,
json,
),
),
),
}),
lanes: [{ name: "show" }],
});
});
```
## `H3Source.open` [Section titled “H3Source.open”](#h3sourceopen) 1. Mints the creating token from `tokens.create`. 2. Allocates the session, then runs `onAllocated` with the session, the grant and the owner record, before the session connects. 3. Connects and sets H3 up: autoplay off until the playout turns it on, the last frame held between clips, and the canvas set before the first enqueue. 4. Returns a `Source` whose `lifetime` is what remains of the token’s cap, counted from the allocation request, so the playout never plans past the session’s end. | Option | Default | What it does | | --------------- | ------------ | ---------------------------------------------------------------- | | `tokens` | required | The session’s `Tokens`; the API key never reaches the opener | | `onAllocated` | none | Records the owner after allocation and before connecting | | `canvas` | as H3 has it | `16:9`, `1:1`, `9:16` or `4:3`, set before the first enqueue | | `holdLastFrame` | `true` | Holds the last frame between clips; `false` flushes to black | | `recovery` | 20 seconds | How long a dropped connection may take to come back | | `provider` | defaults | The [H3 provider’s options](/reactor-effect-client/concepts/h3/) | | `create` | none | Other `reactor.create` options, such as `resumeTracks` | An uncapped session has no end the playout can plan for: it is replaced only when it is lost. If `onAllocated` fails, the session is closed and `open` fails with an `AcquisitionFailure` carrying its close report, as any failure after allocation does. The session reconnects a dropped connection itself. The source reports `Reconnecting`, then `Reconnected` with how long it took, and gives the session `recovery` to be ready again and H3 to be read afresh. Past that, or at once for a session that will not come back (reconnect turned off, ended by moderation, or no longer trying), the source counts the session lost and the playout replaces it. The playout sends the session nothing meanwhile. ## Owner records [Section titled “Owner records”](#owner-records) `onAllocated` receives an `H3Source.Allocation`: the owner record of the session, without a token. It is a Schema, so it encodes to JSON and decodes back. | Field | What it is | | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | | `sessionId` | The session’s id | | `ownership` | `owned` or `attached` | | `model` | The model, `H3.modelName` | | `endsAt` | When the session’s cap ends, in seconds since the epoch, counted from the allocation request; absent for an uncapped session | The cap starts no earlier than the request, so the cap cannot end the session before `endsAt`. With the record, a process that takes over can find the session again, adopt it with a token bound to it, or end it with the API key:
```ts
import { Effect } from "effect";
import { CoordinatorClient } from "reactor-effect-client";
import type { H3Source } from "reactor-effect-client";
// Ends an orphaned session; true once a read confirmed the end.
export const endOrphan = Effect.fn("endOrphan")(function* (
allocation: H3Source.Allocation,
) {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
const verdict = yield* coordinator.terminate(allocation.sessionId);
return verdict.confirmed;
});
```
## `H3Source.resume` [Section titled “H3Source.resume”](#h3sourceresume) `H3Source.resume({ allocation, tokens })` adopts a session `open` allocated, after its owner died. This process then owns its remote lifetime. The session keeps its canvas, queue and playback, and a session its owner already set up gets only reads. Its `lifetime` is what remains until the record’s `endsAt`.
```ts
import { Effect, Schema } from "effect";
import { CoordinatorClient, H3, H3Source } from "reactor-effect-client";
const decodeOwner = Schema.decodeEffect(
Schema.fromJsonString(H3Source.Allocation),
);
export const takeOver = Effect.fn("takeOver")(function* (
record: string,
) {
const coordinator = yield* CoordinatorClient.CoordinatorClient;
const allocation = yield* decodeOwner(record);
return yield* H3Source.resume({
allocation,
// Reactor takes no other token for a session this process did not create.
tokens: {
bind: (id) =>
coordinator.mintToken({ modelName: H3.modelName, bind: [id] }),
},
});
});
```
`resume` refuses, as `InvalidInput`, a record of another model or one whose `endsAt` has passed. A resume that fails ends the session it adopted. On hosted Reactor, an owner was killed 12.7 s into the run. `H3Source.resume` started 0.57 s after the creating token had expired, was ready 2.71 s later on the owner’s playing clip, and enqueued a clip with an image and an audio reference on a refreshed 12 s bound token ([0.8.0-api `adoption`](https://github.com/mannyc2/reactor-effect-client/blob/main/integration/hosted/evidence/0.8.0-api/summary.md), 2026-09-28). ## `LocalSource` [Section titled “LocalSource”](#localsource) `LocalSource.open` renders clips in this process instead of on Reactor, with H3’s autoplay semantics: one build slot and a playout queue. Use it for material you render yourself, such as speech or stills, and for demos. It costs nothing, and closing it ends no paid session.
```ts
import { Effect } from "effect";
import { LocalSource, Playout } from "reactor-effect-client";
/** Your speech engine: 48 kHz mono PCM for a line of text. */
declare const synthesize: (
text: string,
) => Effect.Effect>;
const block = 480; // 10 ms at 48 kHz
export const announcer = Playout.make({
open: LocalSource.open({
// Renders a clip before it is Ready, and says how long it really is.
build: (clip) =>
Effect.map(synthesize(clip.request.prompt), (pcm) => ({
value: pcm,
seconds: pcm.length / 48_000,
})),
// Plays it into the source's audio, in real time. The clip ends
// when this completes.
present: (_clip, pcm, sink) =>
Effect.forEach(
Array.from(
{ length: Math.ceil(pcm.length / block) },
(_, index) => index,
),
(index) =>
sink
.audio({
_tag: "AudioFrame",
track: "main_audio",
sampleRate: 48_000,
channels: 1,
sequence: BigInt(index),
samples: pcm.slice(index * block, (index + 1) * block),
})
.pipe(Effect.andThen(Effect.sleep("10 millis"))),
{ discard: true },
),
}),
lanes: [{ name: "line" }],
});
```
| Option | What it does | | ------------ | ---------------------------------------------------------------------------------------------------- | | `build` | Makes a clip’s value, and its built length when that differs (`seconds`) | | `present` | Plays the value through the `sink`’s `video` and `audio`; without it, a clip plays for its length | | `lifetime` | Caps the session, as a paid session’s cap does, so the playout renews it; unending by default | | `buildRatio` | Without hooks: a stand-in whose clips build for this share of their length and play for their length | `build` and `present` run in a scope the clip owns, which closes once the clip leaves the source, so a finalizer releases what they made. A hook that fails with your own error fails that clip alone: `Failed` with reason `Clip`, your error pretty-printed and `Redacted` in `provider`. A hook that dies stops the source, and the playout replaces its session. A clip’s request still goes through H3’s documented limits, since the playout checks every request against them. ## Writing a source [Section titled “Writing a source”](#writing-a-source) `Playout.Source` is an interface: a `sessionId`, a `lifetime`, `events`, the commands `enqueue`, `remove`, `move`, `setAutoplay`, `stop` and `play`, the `video` and `audio` streams, and `close`. Its doc comment lists what the playout relies on, such as `events` starting with a `State` and reporting a clip’s start or end before the `State` that shows it. Write one only for a model or renderer the SDK does not cover. ## Limits [Section titled “Limits”](#limits) * `H3Source` drives H3 Reference Turbo Realtime only; `resume` refuses a record of another model. * A `LocalSource` renders in your process: its builds and presentations use your CPU, and one slow build holds its one build slot. * The playout validates a `LocalSource` clip’s request against H3’s limits too, so `seconds` is still 5 to 15.084. ## Next [Section titled “Next”](#next) [Playout](/reactor-effect-client/concepts/playout/)Lanes, filler, renewal and as-run evidence. [Sessions and tokens](/reactor-effect-client/concepts/sessions/)Tokens, adoption and what a close report says. [Media](/reactor-effect-client/concepts/media/)The frames and sound a source plays. [Run a 24/7 channel](/reactor-effect-client/guides/channel/)Owner records and renewal in a real server.
# Examples
> Applications built on reactor-effect, from one clip in a file to a 24/7 channel: what each shows, and how to run it offline or live.
Every example is a small Effect application in the repository, typechecked against the packages on every change. Most run offline on `ReactorTest` with no API key. All but the rundown are written to run on hosted H3 with `REACTOR_API_KEY` set, where only the layer changes; no example has run there yet, though paid checks ran most of the SDK they use ([hosted evidence](/reactor-effect-client/reference/hosted-evidence/)). Clone the repository and install once:
```sh
git clone https://github.com/mannyc2/reactor-effect-client && cd reactor-effect-client
bun install && bun run build
```
## Quickstart offline or live [Section titled “Quickstart ”](#quickstart-) One H3 clip from prompt to its end, with its frames decoded in your process, in one file. [The quickstart page](/reactor-effect-client/start/quickstart/) walks through it.
```sh
node examples/quickstart/src/main.ts
```
[examples/quickstart](https://github.com/mannyc2/reactor-effect-client/tree/main/examples/quickstart) ## Terminal viewer offline or live [Section titled “Terminal viewer ”](#terminal-viewer-) H3 drawn in your terminal. Each frame is decoded in the process and drawn as 24-bit colour text about twelve times a second, while every clip of a playlist is followed from acceptance to its end. No browser is involved anywhere: this is what `reactor-effect-native` adds to Node and Bun.
```sh
cd examples/terminal
node src/main.ts "A lighthouse on a sea cliff at dusk" "A neon-lit alley in the rain"
```
[examples/terminal](https://github.com/mannyc2/reactor-effect-client/tree/main/examples/terminal) ## Live channel offline or live [Section titled “Live channel ”](#live-channel-) Your own 24/7 AI channel. Viewers send prompts from a page, a house rotation fills the gaps, and `Playout` renews sessions before their cap so a renewal never takes the channel off air. The server decodes one paid session and broadcasts it to many viewers, and can restream it to Twitch, YouTube or X over RTMP (so far run against a local RTMP listener only; the channel’s live mode has not run on hosted Reactor yet). The [channel guide](/reactor-effect-client/guides/channel/) explains each piece.
```sh
cd examples/livestream
node src/main.ts # then open http://127.0.0.1:3000
```
[examples/livestream](https://github.com/mannyc2/reactor-effect-client/tree/main/examples/livestream) ## H3 Studio in the browser [Section titled “H3 Studio ”](#h3-studio-) A page that runs its own session: H3’s queue live with its play, move, pop and stop controls, reference images validated once and reused, each clip’s lifecycle, and failures shown with their dispatch outcome. Rehearse a failed build, a lost reply or a moderation verdict with one click. [Open the playground](/reactor-effect-client/playground/): Studio on `ReactorTest`, running in your tab, with no key and nothing billed. With an API key on its server, the same page is built to run on hosted H3 over `BrowserPeer`, which has not yet run on a paid session.
```sh
cd packages/browser/examples
bun run start # offline without REACTOR_API_KEY; open http://127.0.0.1:3000
```
[packages/browser/examples](https://github.com/mannyc2/reactor-effect-client/tree/main/packages/browser/examples) ## Capture live only [Section titled “Capture ”](#capture-) A command line that generates one clip and writes its decoded frames and audio to an MP4 through ffmpeg, filling any dropped frame explicitly. It prints the most the run can cost before it allocates anything.
```sh
cd packages/native/examples
REACTOR_API_KEY=rk_... node src/capture.ts --prompt "A red kite over a green hill" --out kite.mp4
```
[packages/native/examples](https://github.com/mannyc2/reactor-effect-client/tree/main/packages/native/examples) ## Rundown offline [Section titled “Rundown ”](#rundown-) An application service written against `Playout`: it plays an ordered list of prompts and reports what became of each. Its tests run it on `ReactorTest` under `TestClock`, with faults standing in for a failed build and a lost reply, in a fraction of a second of real time. [Test offline with ReactorTest](/reactor-effect-client/guides/testing-offline/) builds on it.
```sh
cd packages/client/examples
bun run start && bun run test
```
[packages/client/examples](https://github.com/mannyc2/reactor-effect-client/tree/main/packages/client/examples)
# Sessions in the browser
> A page that runs its own session over RTCPeerConnection, with a server that only mints tokens.
Give each visitor a session of their own: the page allocates and connects an H3 session over the browser’s WebRTC and plays it, and your server’s only job is to mint the session’s tokens. ## The pieces [Section titled “The pieces”](#the-pieces) * **A token server.** It holds the API key and answers one request: without a session id, a token that may create one session, capped; with one, a fresh token bound to that open session. The page never sees the key. * **The page’s `Tokens`.** `reactor.create` takes `tokens: { create, bind }`, two effects that ask your server. The session starts on `create`’s token and asks `bind` for the next before each expires, so it outlives its first token. * **One runtime.** `ManagedRuntime.make(layer)` builds the SDK’s services once; button handlers are ordinary DOM code that call `runtime.runPromise`. Building `BrowserPeer.layer` checks for `RTCPeerConnection` and `MediaStream` and fails with `UnsupportedHost` without them, before any session is paid for. * **Crypto.** `H3` needs Effect’s `Crypto` service for request identities. Effect’s `@effect/platform-browser` provides it as `BrowserCrypto.layer`; this page builds the same layer over Web Crypto, so it needs no further package. * **Tracks.** `BrowserMedia.tracks(session)` gives the current connection’s tracks as the browser’s own `MediaStreamTrack`s, and `BrowserMedia.play(track, element)` plays a clone of one in a media element until its scope closes. * **A scope the page holds.** The session, its peer, its tracks and their playback all live in one `Scope` the page keeps between clicks, so one close releases everything. ## Build the page [Section titled “Build the page”](#build-the-page) 1. **The token endpoint’s contract,** shared by the server and the page as an Effect `HttpApi`: Api.ts
```ts
import { Schema } from "effect";
import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
/** A session token, as the page's `Tokens` needs it. */
export const SessionToken = Schema.Struct({
jwt: Schema.String,
expiresAt: Schema.Finite,
maxSessionSeconds: Schema.optionalKey(Schema.Int),
});
export class TokenUnavailable extends Schema.TaggedError()(
"TokenUnavailable",
{ message: Schema.String },
{ httpApiStatus: 503 },
) {}
/** Without `session`, a token that may create one session; with it, one bound to that session. */
export class Api extends HttpApi.make("tokens").add(
HttpApiGroup.make("session", { topLevel: true }).add(
HttpApiEndpoint.post("token", "/api/token", {
payload: Schema.Struct({ session: Schema.optionalKey(Schema.String) }),
success: SessionToken,
error: TokenUnavailable,
}),
),
) {}
```
2. **The server mints tokens** with the key it reads from `REACTOR_API_KEY`. A creating token caps its session at five minutes, so a page closed without cleanup leaves its session running no longer than that: server.ts
```ts
import { Config, Effect, Redacted } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { CoordinatorClient, H3 } from "reactor-effect-client";
import { Api, TokenUnavailable } from "./Api.ts";
export const TokenHandlers = HttpApiBuilder.group(
Api,
"session",
Effect.fn(function* (handlers) {
const apiKey = yield* Config.Redacted("REACTOR_API_KEY");
const coordinator = yield* CoordinatorClient.make();
const tokens = coordinator.tokens({
apiKey,
modelName: H3.modelName,
maxSessionDuration: "5 minutes",
expiresAfter: "6 minutes",
});
return handlers.handleAll({
token: ({ payload }) =>
(payload.session === undefined ? tokens.create : tokens.bind(payload.session)).pipe(
Effect.map((grant) => ({
jwt: Redacted.value(grant.jwt),
expiresAt: grant.expiresAt,
...(grant.maxSessionSeconds === undefined
? {}
: { maxSessionSeconds: grant.maxSessionSeconds }),
})),
Effect.mapError(() => TokenUnavailable.make({ message: "no session token" })),
),
});
}),
);
```
Serve `HttpApiBuilder.layer(Api)` with these handlers through `HttpRouter.serve` and `@effect/platform-node`’s `NodeHttpServer`, beside the page and its bundle; it needs an `HttpClient` such as `FetchHttpClient.layer`. A real deployment authenticates and rate-limits this endpoint, and binds only sessions its caller created: a creating token starts a paid session, and a bound token commands, watches and ends its session. 3. **The page’s runtime,** built once, with the Web Crypto layer beside it: WebCrypto.ts
```ts
import { Crypto, Effect, Layer, PlatformError } from "effect";
/** Effect's `Crypto` service over Web Crypto, which every secure context has. */
export const WebCrypto = Layer.sync(Crypto.Crypto, () =>
Crypto.make({
randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)),
digest: (algorithm, bytes) =>
Effect.tryPromise({
try: () =>
globalThis.crypto.subtle
.digest(algorithm, Uint8Array.from(bytes))
.then((digest) => new Uint8Array(digest)),
catch: (cause) =>
PlatformError.systemError({
_tag: "Unknown",
module: "Crypto",
method: "digest",
cause,
}),
}),
}),
);
```
page.ts
```ts
import { Effect, Exit, Layer, ManagedRuntime, Redacted, Scope } from "effect";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import { HttpApiClient } from "effect/unstable/httpapi";
import { CoordinatorClient, H3, Reactor, ReactorError } from "reactor-effect-client";
import type { Session } from "reactor-effect-client";
import { BrowserMedia, BrowserPeer } from "reactor-effect-browser";
import { WebCrypto } from "./WebCrypto.ts";
import { Api } from "./Api.ts";
const runtime = ManagedRuntime.make(
Reactor.layer().pipe(
Layer.provide(CoordinatorClient.layer()),
Layer.provideMerge(Layer.mergeAll(FetchHttpClient.layer, WebCrypto, BrowserPeer.layer)),
),
);
```
`CoordinatorClient.layer()` talks to `https://api.reactor.inc` with no key: the session’s own calls carry its token. 4. **Start a session** on tokens from the server, in a scope the page keeps. The video plays as soon as the session connects:
```ts
interface Live {
readonly scope: Scope.Closeable;
readonly session: Session.Session;
readonly provider: H3.Provider;
readonly media: BrowserMedia.Tracks;
}
const start = Effect.fn("start")(function* (video: HTMLVideoElement) {
const api = yield* HttpApiClient.make(Api);
const token = (session?: string) =>
api.token({ payload: session === undefined ? {} : { session } }).pipe(
Effect.map((reply) => ({
jwt: Redacted.make(reply.jwt),
expiresAt: reply.expiresAt,
maxSessionSeconds: reply.maxSessionSeconds,
})),
Effect.mapError(() => ReactorError.ReactorError.fromCode("Http", "no session token")),
);
const scope = yield* Scope.make();
return yield* Effect.gen(function* () {
const reactor = yield* Reactor.Reactor;
const session = yield* reactor.create({
model: H3.modelName,
tokens: { create: token(), bind: token },
});
const provider = yield* H3.make(session);
yield* provider.setAutoplay(true);
const media = yield* BrowserMedia.tracks(session);
yield* BrowserMedia.play(yield* media.track("main_video"), video);
const live: Live = { scope, session, provider, media };
return live;
}).pipe(
Scope.provide(scope),
Effect.onError(() => Scope.close(scope, Exit.void)),
);
});
```
A failed start closes the scope at once, which ends a session that was allocated. 5. **Sound on its own click.** Browsers refuse unmuted playback that starts seconds after the gesture that asked for it, and connecting takes seconds. So the video element is muted (`