Run a 24/7 channel
Keep one H3 channel on air around the clock, across sessions that each end at their cap, and show it to many viewers from one server that holds the key.
The pieces
Section titled “The pieces”A channel is one Playout on a server. Viewers never touch Reactor: they send prompts to your
server and watch the stream it encodes. The Playout concept
has the whole model; these are the parts a channel uses.
- Sessions come from
open.H3Source.open({ tokens, onAllocated })mints a token, allocates an H3 session, runsonAllocatedwith the owner record before it connects, and sets H3 up for a playout: autoplay off until the playout turns it on, and the last frame held between clips. The playout callsopenfor the first session and for each replacement. A session’s lifetime is what remains of its token’s cap. - Renewal.
renewal.lead(30 seconds by default) before a session’s cap, the playout opens the replacement. New clips build there; the retiring session plays what it already holds, and the air switches at a clip boundary once it is idle andrenewal.grace(250 ms) has passed. A session lost early is replaced, and the clips it never aired are rebuilt on the next one. - Lanes, listed highest first. A lane queues by default,
conflict: "replace"replaces its waiting items make-before-break, andconflict: "skip"refuses an item while one waits or plays (LaneBusy). Acut: truelane stops a lower lane’s playing clip once its own clip is Ready, unless that clip ends within a second. - Filler is the bottom lane. It keeps a runway of Ready air between
runway.floorandrunway.target, built fromfiller.clip(context), which the playout calls once per clip. - Admission windows. An item’s
window(notBefore,startBy,firm) counts from its submission. The playout refuses at once, withWouldMissDeadline, a firm item it cannot start bystartBy. If the plan slips later, a firm item is droppedlateand a soft one airs late. - Starts.
Follow(the default) keeps its lane’s order,Asapgoes at the next boundary ahead of everything waiting,Manualis held untilrelease(key)(built ahead while filler holds the runway), andAtairs at the first boundary after a wall-clock time. - What aired.
playout.eventsreports each item’s as-run status, sessions opening and switching, cues, filler, andStarvedwhen nothing was Ready.playout.stategives the clip on air, the lanes and the runway in seconds.playout.failurecompletes with why the playout stopped. - The picture.
playout.videoandplayout.audioare the on-air session’s decoded frames and PCM, continuing across renewals.
Build the channel
Section titled “Build the channel”-
Sessions. Each starts on a token capped at 30 minutes and carries on with tokens bound to it. The owner record is logged here; Cost control writes it somewhere durable.
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import { Effect, Layer, Redacted, Stream } from "effect";import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";import {CoordinatorClient,H3,H3Source,Playout,Reactor,ReactorTest,} from "reactor-effect-client";import { NativePeer } from "reactor-effect-native";const house = ["A slow dolly shot through a sunlit greenhouse full of ferns","Waves rolling onto a black sand beach at dusk, seen from a low angle","A paper lantern drifting along a quiet canal at night",];const open = Effect.gen(function* () {const coordinator = yield* CoordinatorClient.CoordinatorClient;return yield* H3Source.open({tokens: coordinator.tokens({ modelName: H3.modelName, maxSessionDuration: "30 minutes" }),onAllocated: ({ allocation }) => Effect.logInfo("session allocated", allocation),});}); -
The playout. A
breakinglane that cuts in, viewers’ prompts below it, and a house rotation as filler, renewed 45 seconds before each cap:const Channel = Playout.layer({open,lanes: [{ name: "breaking", cut: true }, { name: "viewer" }],filler: {runway: { floor: "8 seconds", target: "16 seconds" },clip: ({ index, seconds }) => ({ prompt: house[index % house.length] ?? "", seconds }),},renewal: { lead: "45 seconds" },});filler.clipgets the length to ask for inseconds: usually the shortestfiller.lengthsallows, which keeps boundaries frequent; long enough to cover an item’s build when the air would otherwise go dark; or one of equal tiles that fill the gap before anAtitem. A filler request outside H3’s limits fails the playout withInvalidFiller, since asking again would get the same request. -
Admission. A viewer’s prompt starts within 37 seconds, or is refused up front, or, if the plan slips after it was accepted, is dropped
late. The key makes a retry safe: submitting the same key with the same spec returns the same handle.const submitPrompt = Effect.fn("submitPrompt")(function* (id: string, prompt: string) {const playout = yield* Playout.Playout;return yield* playout.submit({key: Playout.ItemKey.make(`viewer-${id}`),lane: "viewer",request: { prompt, seconds: 8 },window: { startBy: "37 seconds", firm: true },});});Its failure is a
SubmitError:WouldMissDeadlineandLaneBusy(ask the viewer to wait),InvalidItem(outside H3’s limits, naming each field),KeyMismatchandPlayoutClosed. Map them onto your API’s responses withEffect.catchTags. -
Watching the air. The events, logged as they happen:
const logAir = Effect.gen(function* () {const playout = yield* Playout.Playout;yield* playout.events.pipe(Stream.runForEach((event) => {switch (event._tag) {case "Session":return Effect.logInfo("session", event.event);case "AsRun":return Effect.logInfo("as-run", event.event.key, event.event.status._tag);case "Starved":return Effect.logWarning("nothing Ready to air");default:return Effect.void;}}),);});const Air = Layer.effectDiscard(Effect.forkScoped(logAir)).pipe(Layer.provideMerge(Channel)); -
The network edge. The same channel runs live or offline; only the layers beneath it change.
Layer.launchruns it until the process is stopped, and closing it on Ctrl-C ends every session.const Offline = Air.pipe(Layer.provide(Reactor.layer()),Layer.provide(CoordinatorClient.layer({ apiKey: Redacted.make("offline") })),Layer.provide(Layer.mergeAll(ReactorTest.layer({ timing: ReactorTest.Timing.hosted, apiKey: "offline" }),NodeCrypto.layer,),),);Layer.launch(Offline).pipe(NodeRuntime.runMain);// The key from REACTOR_API_KEY; each connection's native peer in a child process.const Live = Air.pipe(Layer.provide(Reactor.layer()),Layer.provide(CoordinatorClient.layerConfig),Layer.provide(Layer.mergeAll(NativePeer.layerIsolated(), FetchHttpClient.layer, NodeCrypto.layer),),);Layer.launch(Live).pipe(NodeRuntime.runMain);Offline, it logs a simulated session’s owner record, then its
Openedevent with the rest of the 30-minute cap aslifetimeSeconds.NativePeer.layerIsolated()needs Node; on Bun, useNativePeer.layer().
Broadcast it
Section titled “Broadcast it”playout.video and playout.audio follow the air across renewals, so one reader of each serves the
channel’s whole life. Encode once and fan the result out:
- Read both streams for as long as the channel runs, whether anyone watches or not. A reader
that falls more than a second behind fails alone with
Overflow, which the playout reports as aReaderOverflowevent, and reads on from the next frame. For a preview, keep only the newest frame withStream.buffer({ capacity: 1, strategy: "sliding" }). - Encode the BGRA frames and 16-bit PCM once, for example with ffmpeg reading raw frames on standard input and PCM on a second pipe. A steady 24 fps clock that repeats the newest frame between clips gives the encoder constant-rate input.
- Fan out the encoder’s output with
Stream.share, so every viewer gets the same bytes and no viewer slows another. Viewers never hold a token.
The live channel
example does all of this: one playout on the native host, renewed before each cap, encoded once to
fragmented MP4 and shared with many <video> elements, with prompts and an event feed over an
HttpApi. It runs offline by default and is written to run live with a key; its live mode has not
run on hosted Reactor yet.
Restreaming. The same encoder output can go to an RTMP ingest, such as YouTube’s or Twitch’s, with ffmpeg’s FLV output. That is application code over the decoded media, and no restream of hosted H3 has been run with this SDK yet.
Session caps as a cost bound
Section titled “Session caps as a cost bound”A session bills from ready until it ends, so its cap is the most it can cost: at H3’s rate on
2026-09-30, $63 for a 30-minute session. A channel on air all day bills about $3,024 a day, plus
each renewal’s overlap, while the replacement is open and the retiring session finishes: 6 to 9
seconds in the paid show runs, which used a 30-second lead.
To bound the whole channel, count the sessions open may make and fail past a budget; the
cost control guide shows a wrapper. An open refused
with nothing allocated billed nothing, so while a session holds the air the playout asks again
later; once nothing is on air, opens that keep failing end the playout after
renewal.maxSetupFailures (3).
Measured on hosted H3
Section titled “Measured on hosted H3”The paid show check runs one playout over three 75-second sessions with a 30-second lead and 5–8
seconds of filler runway. It passed every criterion twice, on 2026-09-28 and 2026-09-29
(first,
second):
- 20 clips and 2,372 frames at 22.2 fps in the first run, no frame lost, and the show’s own reader never fell behind;
- seams of 46–169 ms with no dark frame, and a planned switch between sessions with 420 and 432 ms between clips on air;
- an edit batch that took effect 0.91–1.05 s before its boundary, its withdrawn clip never aired;
- cues 0–1 ms from due, and an
Atitem 545–559 ms late, never early; - a dropped connection back on the same session in 1.64 and 1.84 s, with no replacement opened;
- a session ended under a playing clip (by moderation, then by the API key) replaced, its Ready clip rebuilt on a session opened 3.17 s after the moderation verdict, and 5.2–5.3 s with no clip on air;
- a cut lane that stopped a playing clip with one
stopand a 165–168 ms pause.
The second run’s checkout matches the 0.8.0 release’s library source except for its version string; the first ran on an earlier commit of the same API. The longest hosted run lasted under two minutes: no multi-hour session has run yet.
Pitfalls
Section titled “Pitfalls”- Lead and cap. The replacement takes new work from the moment it opens, so a session airs roughly its cap less the lead. Keep the cap long against the lead.
- Five sessions per account by default. Each renewal holds two sessions for a few seconds, and every other session the account runs counts too. Reactor raises the quota on request.
- Moderation. Reactor ends a session given flagged content, and the verdict names no clip. The
playout blames the item whose enqueue went there last, fails it
Moderated, never builds it again, and stops aftermaxModerations(2) moderated sessions rather than keep opening paid ones. - Supervision. A stopped playout stays stopped. To restart it, fail with
playout.failureand retry under aSchedule; see the Playout concept. - A start is a provider fact.
Startedmeans H3 reported the clip started, not that a viewer saw a frame. - Media needs decoded frames.
playout.videofails withUnsupportedCapabilityon a host with only platform tracks, such as a browser. Run the channel on the native host.