@clawboo/events is the package that turns the OpenClaw Gateway’s raw WebSocket event stream into reactive UI state. It is a small, dependency-light library; its only runtime deps are @clawboo/gateway-client, @clawboo/protocol, and @clawboo/logger, and it imports nothing from node:*, so it ships in the browser bundle. Three stages do the work, and the design’s whole point is where the line between them falls: the first two stages are pure functions you can unit-test without a Gateway, a browser, or a store; only the third touches state.
This page is the contributor-level walk through that package: the three layers, the typed seam that connects them (EventIntent[]), why purity is enforced at the package boundary, the RAF patch queue that smooths streaming, the guard state the Handler concentrates, and the invariant that every OpenClaw frame flows through this one pipeline. For the user-facing framing of the Gateway flow and device pairing, see Gateway and events; this page is the internals.
The three layers
A raw frame becomes a store mutation in three named hops:- Bridge (
bridge.ts): pure parsing.classifyEvent(frame)answers “what is this frame?” and returns aClassifiedEvent(kind, extractedagentId/sessionKey, timestamp). It never decides what to do. - Policy (
policy/*): pure decision-making.derivePolicy(event)answers “what should happen?” and returns an array of typedEventIntents, a description, not a mutation. It reads only its inputs. - Handler (
handler.ts): the one stateful stage.createEventHandler(deps)dispatches each intent to injected store-writers, applying cross-cutting guards (debounce, stale-run dropping) as it goes.
processEvent(frame, handler) calls classifyEvent → derivePolicy → handler.applyIntents. That function is the entire public entry point a caller needs.
Stage 1: Bridge: classifyEvent and the pure helpers
classifyEvent(frame) switches on the frame’s event name and produces a ClassifiedEvent. The mapping is small and total; anything unrecognized falls through to unknown rather than throwing:
Frame event | Classified kind |
|---|---|
presence, heartbeat | summary-refresh |
chat | runtime-chat |
agent | runtime-agent |
exec.approval.pending / .requested / .resolved | approval |
| anything else | unknown |
chat frames the agentId comes from the session-key shape agent:<id>:<session> (parsed by SESSION_KEY_RE). For agent frames a top-level agentId wins, else the session key is parsed. For approval frames agentId may be at the top level or nested inside payload.request, and classifyEvent checks both.
The Bridge also owns a set of small pure helpers the rest of the pipeline reuses:
parseChatPayload/parseAgentPayload: shape-validate a raw payload and returnnullon anything malformed (missingrunId/sessionKey, an out-of-rangestate). The Policy router relies on thisnullto turn a junk frame into anignoreintent instead of a crash.isReasoningStream(stream): classifies a stream name as a thinking trace, with an explicit deny-list (assistant,tool,lifecycle) checked before the allow-list (reason,think,analysis,trace) so anassistantstream is never misread as reasoning.resolveLifecyclePatch: maps astart/end/errorlifecycle phase to a status patch, ignoring a terminal phase whoserunIddoesn’t match the currently-tracked run.mergeRuntimeStream,dedupeRunLines: stream concatenation and per-run line de-duplication.- It re-exports
extractText/extractThinking/extractToolLinesfrom@clawboo/protocolso consumers have one import surface.
The Bridge knows nothing about Zustand,
apps/web, or even what a “store” is. It transforms EventFrame → ClassifiedEvent and validates payloads. That is the whole contract.Stage 2: Policy: derivePolicy and the four deciders
derivePolicy(event) is the router. It switches on the classified kind and delegates to one of four pure deciders, each in its own file, returning EventIntent[]:
{ kind: 'ignore', reason } intent. Nothing in this layer throws.
The four deciders map to three logical planes (work, agent, trust):
decideAgentEvent(agent.ts): forsummary-refresh. It emits onescheduleSummaryRefreshintent with a fixeddelayMs: 750, and setsincludeHeartbeatRefreshonly when the originating frame was aheartbeat(vs apresence). It does not mutate fleet state directly; it asks the Handler to debounce a reload.decideWorkChatEvent(work.ts): forruntime-chat. Adeltabecomes aqueueLivePatchcarrying the extracted streaming text and thinking trace (RAF-batched downstream). Afinalbecomes aclearPendingLivePatch+ acommitChatthat finalizes the turn with output lines and anidlepatch, and, if the final message carried no thinking trace, an extrarequestHistoryRefreshso the full transcript is re-fetched.abortedanderroreach become aclearPendingLivePatch+commitChatwith the appropriate terminal status (idle/error).decideWorkAgentEvent(work.ts): forruntime-agent. Thelifecyclestream’sstart/end/errorphase becomes anupdateAgentStatusintent (running/idle/error); theassistantand reasoning streams becomequeueLivePatchintents. Thetoolstream is deliberately handed off to the Handler viaappendOutputLinesrather than carrying lines through an intent, so this decider returns anignorefor it.decideTrustEvent(trust.ts): forapproval. It emitsapprovalPendingforexec.approval.pending/.requested, elseapprovalResolved. Missing anagentIdis anignore, not an error.
(event, payload) and returns an array, the same input always derives the same intents. That is what makes Policy unit-testable in isolation, and it is exactly what policy-work.test.ts and policy-trust.test.ts exercise: feed a ClassifiedEvent, assert the EventIntent[], no Gateway and no store in sight.
The seam: EventIntent[]
The boundary between pure and stateful is a typed discriminated union, EventIntent. Policy emits these; the Handler consumes them. The union is the contract:
Stage 3: Handler: applyIntents and its guard state
createEventHandler(deps) returns { applyIntents, dispose }. The deps are the injected dispatchers and state queries that write to and read from the Zustand stores: queueLivePatch, appendOutputLines, dispatchIntent, getAgentRunId, loadSummarySnapshot, refreshHeartbeatLatest, requestHistoryRefresh, plus an injectable setTimeout/clearTimeout pair for testing. The pure package never imports apps/web; the wiring lives the other way around (see the wiring, below).
applyIntents(intents, event) is a switch over intent.kind. Most cases are a one-line delegate to a dep. Two of them carry the real intelligence, and they share a guard structure.
The closedRuns stale-run guard. The Handler keeps a Map<runId, expiry> with a 30-second TTL (CLOSED_RUN_TTL_MS), capped at 500 entries (CLOSED_RUNS_MAX_SIZE, oldest-evicted on overflow). pruneClosedRuns() runs at the top of every applyIntents. When a commitChat or terminal updateAgentStatus actually clears the agent’s runId, that runId is added to closedRuns. A subsequent terminal updateAgentStatus whose run is already in closedRuns is dropped. This guards against a duplicate final or a late lifecycle end flipping a freshly-started run back to idle.
The pending-approval subtlety. The Handler captures the agent’s runId before calling dispatchIntent, then re-reads it after. A run is only marked closed if the dispatch actually cleared the runId. This matters because when an exec approval is pending, the injected dispatchIntent deliberately skips the terminal status patch; the LLM stream ended but the run is still alive, blocked on the approval decision. If the Handler blindly marked the run closed on every commitChat, it would then drop the real final event that arrives after the approval resolves. The pre/post runId comparison is what threads that needle.
The summary-refresh debounce. scheduleSummaryRefresh cancels any pending summaryRefreshTimer and re-arms one, so a burst of presence/heartbeat frames collapses into a single fleet reload after delayMs. dispose() clears that timer and the closedRuns map; it is called when the Gateway disconnects so no timer leaks across a reconnect.
The Handler is the only place in
@clawboo/events that holds state. Every guard, the debounce timer, the stale-run map, the pre/post-runId capture, is concentrated here on purpose. The cost of keeping Bridge and Policy pure is paid by making the Handler the single home for all the messy temporal logic, which is a deliberate trade: one stateful unit is far easier to reason about than state smeared across three layers.The RAF patch queue
Streaming deltas arrive faster than the screen refreshes; a single agent turn can fire dozens ofdelta frames a second. createPatchQueue(onFlush) (patch-queue.ts) coalesces them. It keeps a Map<agentId, updates> of pending patches; enqueue merges an incoming patch into the agent’s pending entry and schedules a flush on the next requestAnimationFrame. flush drains the map and calls onFlush(patches) once with everything accumulated, so the store is written at most once per animation frame per agent.
The merge has one important rule: a runId change discards the old streaming state. If an incoming patch carries a different runId than the pending one, the queue replaces the pending entry rather than merging, so a stale partial stream from a finished run can never bleed into a fresh run’s text.
requestAnimationFrame when that global exists, and cancelAnimationFrame is guarded the same way, so the same code is a harmless no-op in SSR / Node test contexts. dispose() cancels any pending frame and clears the map.
Wiring in the web app
@clawboo/events exports only pure functions and an intent-driven Handler; it knows nothing about which stores exist. The binding is done once, in the browser, by the useGatewayEvents hook. On mount it:
- Creates a patch queue whose
onFlushwrites each batch into the fleet store (useFleetStore.patchAgent). - Constructs the Handler with
depsbacked by the real Zustand stores:getAgentRunIdreads the fleet store;dispatchIntentwritesupdateAgentStatus/commitChat/ approval intents, applying the pending-approval guard;queueLivePatchenqueues to the patch queue;loadSummarySnapshot/refreshHeartbeatLatest/requestHistoryRefreshreload from the server. - Subscribes to
client.onEventand runsprocessEvent(frame, handler)for every frame.
patchQueue.dispose() + handler.dispose(), so both the RAF frame and the Handler’s timers are torn down when the Gateway connection closes or the component unmounts.
This split, pure library, browser-only wiring, is what keeps the package’s invariant true: the pure stages can be tested without a DOM, and the only file that imports a store is the hook, not the package.
Design rationale and trade-offs
Pure Bridge and Policy, stateful Handler. Classification and decision-making are the parts that are hard to get right (what a frame means, which intents it produces, how a malformed payload is handled). Making them pure means they are testable in isolation, deterministic, and reusable, and it pushes every piece of temporal state into one auditable place. This is the same purity discipline the board and governance layers follow: side effects live at the edges, decisions in the middle. Intents over direct store calls. Policy could call stores directly and skip the union. It doesn’t, because the indirection is what enforces purity (you cannot dispatch from a function that only returns objects) and what lets the Handler layer guards on uniformly. TheEventIntent union is a small price for a clean seam.
RAF batching over write-per-frame. Writing the store on every streaming delta would thrash React’s render path. Coalescing per agent per animation frame keeps the live fleet view smooth without dropping data, and the runId-change-discards-state rule keeps the coalescing correct across run boundaries.
Dependency injection for the Handler’s effects. The Handler takes setTimeout/clearTimeout and every store-writer as deps. That is what lets the package stay browser-agnostic and lets tests drive the Handler with fake timers and spy dispatchers, no real Gateway, no real store, no real clock.
Boundaries and non-goals
- OpenClaw-specific. This pipeline maps OpenClaw WebSocket frames to the live fleet view. The other four runtimes (clawboo-native, Claude Code, Codex, Hermes) emit a normalized
RuntimeEventunion server-side and do not flow throughclassifyEvent/derivePolicy. See the agent model and the RuntimeAdapter trait. - Not the team-orchestration engine. Bridge→Policy→Handler keeps an agent’s status, streaming text, and approvals in sync. Turning delegation signals into durable work is the board orchestrator’s job, on a different code path. See delegation and orchestration.
- Not a transport. The package never opens a socket. It transforms frames the
GatewayClientalready delivered; the socket, reconnect, and the same-origin proxy live in@clawboo/gateway-clientand@clawboo/gateway-proxy. See Gateway and events. - Token-count gap is upstream. Gateway
chatpayloads carry no usage data, so the cost path estimates tokens elsewhere; that is a Gateway limitation, not this pipeline’s. See Known issues.
This documents the v0.2.0 working tree (commit
03b206a). The current npm latest is clawboo@0.1.9, so npx clawboo installs 0.1.9 until the v0.2.0 tag is published. Differences are noted in Known Issues.See also
- Gateway and events, the user-facing Gateway flow, the two connections, and device pairing
- The RuntimeAdapter trait, how the other four runtimes emit lifecycle events instead
- The agent model, where this OpenClaw pipeline fits among the five runtime classes
- Architecture invariants, “all Gateway events go Bridge→Policy→Handler”
@clawboo/events, the package’s public API surface- Glossary, canonical term definitions