@clawboo/executor is the substrate that lets Clawboo drive five heterogeneous agent runtimes through one interface. It defines the RuntimeAdapter trait (the seam an adapter implements), the normalized RuntimeEvent union (the lifecycle stream every adapter emits), the RuntimeRegistry (the open set of available adapters), a single-consumer AsyncQueue primitive, the runAdapterContract test suite every adapter must pass, and the ./tiers KV-cache prompt-assembly discipline. The package is pure: no workspace dependencies, no node:* imports, so it builds browser-safe and ships inlined into the CLI bundle.
This page explains the trait method by method, the seven-variant event union and the two runtime asymmetries it encodes in the types, the registry and queue primitives, the contract suite that keeps adapters honest, and the prompt-tier helpers. It is the executor’s view of the agent model: a Boo’s runtime field names a runtime, and that runtime is reached through exactly this trait.
What it is, and what it isn’t
The trait answers one question: how do you drive a black-box runtime through a uniform stream? Clawboo’s hot path is supervision, relay, and UI; it wraps runtimes, it does not reimplement their inner loops. An adapter is a thin wrapper that starts a run on its runtime and translates the runtime’s native frames intoRuntimeEvents.
What the trait is not:
- Not the dispatcher. Claiming a task, provisioning a worktree, running verification, and recording the handoff belong to the executor runner, which consumes adapters. The trait knows nothing about the board.
- Not the registry of record. The trait owns how an agent runs; who exists lives in AgentSource. An adapter never decides which Boos exist.
- Not runtime-specific. The five concrete runtimes (
openclaw,clawboo-native,claude-code,codex,hermes) each ship their ownRuntimeAdapter, but every consumer codes against the trait. Adding a sixth runtime is a new adapter, not a change to any consumer.
src/index.ts) deliberately exports only the trait, the event union, the registry, the async queue, the integration plan, and the rotation helpers, never the contract suite, which imports a test runner. The contract suite lives under the ./contract subpath so an app importing the main barrel never pulls vitest into its bundle.
The trait
Identity: id and participantKind
id is a RuntimeId, an open set: 'openclaw' | 'claude-code' | 'codex' | 'hermes' | (string & {}). The (string & {}) escape hatch makes the union a list of autocomplete hints, not a closed enum, so a new runtime id is a value, not a type change.
participantKind is 'agent' | 'human'. Today every adapter sets 'agent'; nothing in the shipped code branches on this. It is a reserved seam so a person can later be a first-class task assignee, delegation target, or approver behind the same interface, without baking “executor == automated agent” into the trait.
participantKind: 'human' is a future seam, not a shipped feature in v0.3.1. There is no human-participant adapter. The field exists so the trait, the board’s assignee model, and the event stream don’t have to be rewritten when one is added.capabilities(): the declarative profile
capabilities() returns a Capabilities block describing what the runtime can do and how it composes with the host. The base flags are concrete runtime properties:
On top of the base flags sits the native-preservation seam, a block of optional fields that let the host route a runtime to the right integration depth by construction, never by branching on a runtime id:
runtimeClass?: 'wrapped-oneshot' | 'connected-substrate' | 'native': how the runtime composes with the host. Omitted ⇒'wrapped-oneshot'(the conservative spawn path).nativeHome?: NativeHomeClaim: a claim about the home dir the runtime accrues state in (scope: 'per-identity' | 'per-run',persist: boolean). The adapter never computes a path; it states a claim and the host materializes the actual directory.nativeSkills?/nativeMemory?: 'preserve' | 'none': whether the runtime’s on-disk skills and memory survive across runs.nativeChannels?: 'gateway' | 'none': whether the runtime owns its own delivery channels.nativeScheduler?: boolean: informational only; the host’s scheduler always owns when-to-run for teammate dispatch.
resolveRuntimeIntegration (covered in seams and used by the runner); the conceptual mapping of runtime → class lives in the agent model.
runtimeClass lives on the adapter’s Capabilities, not on the runtime descriptor. The descriptor (RUNTIME_DESCRIPTORS) covers install and auth: package name, health binary, auth kind, the vault env var. The class is an execution property the adapter declares. They answer different questions: the descriptor is “how do I bring this runtime online?”, the class is “how does the host drive it once it’s online?”.start() and the late-bound runId
start(task, opts) delivers the run’s opening message and returns a RunHandle:
runId is deliberately mutable and late-bound. A runtime usually does not return its run id synchronously from start(); it arrives on the first lifecycle frame; so callers begin with runId: null and read it once events flow. The events() implementation re-binds run.runId from the first frame of each run.
TaskHandle ({ taskId?, teamId? }) carries the optional board references, so an adapter can run board-backed or ad-hoc. StartOpts carries the runtime-side agentId, the sessionKey, the message, an optional model and context block, and a childToolBlocklist, tools a run must not use (e.g. sessions_send for no recursive delegation, sessions_spawn / sessions_yield for no self-spawned sub-agents). The blocklist is advisory for runtimes that can’t restrict tools per-run; the host also enforces the real guarantee out-of-band, e.g. a board spawn-depth ceiling, or (for OpenClaw, whose adapter applies no per-message blocklist) denying the sub-agent-spawn tools in the Gateway’s tools.deny.
events(): a continuous observer, not a one-shot generator
events(run) returns an AsyncIterable<RuntimeEvent> carrying the normalized lifecycle stream for run.sessionKey. The contract has one subtlety worth internalizing:
events() is a continuous observer for long-lived sessions. A Gateway team session hosts many successive runs on one sessionKey; the stream keeps yielding across runs, and done/error are emitted as events but do not necessarily end the stream. The consumer terminates observation explicitly (break or iterator.return()), which releases the underlying subscription. For one-shot runtimes (a single CLI process) the stream naturally ends when the process exits. Either way, the consumer drives termination.events(), not on the first next(); so frames emitted between subscription and the first pull are buffered rather than dropped. The OpenClaw adapter shows the canonical shape: it subscribes via client.onEvent, filters frames by sessionKey, re-binds run.runId from each frame, maps native frames to RuntimeEvents, and only unsubscribes when the iterator’s return() is called.
Control: abort(), setModel(), writeContext()
The three side-effecting methods map onto the runtime’s own controls:
abort(run)cancels the live run. The OpenClaw adapter does a two-tier teardown: a surgical per-runchat.abortwhen arunIdis bound, plus a heavier session-levelsessions.abortbackstop always, covering the runId-not-yet-bound race and any queued/pending work.setModel(run, model)switches the model mid-session.writeContext(run, key, value)writes a context file (e.g. updating an agent file).
Optional members: sessionCodec and dispose
sessionCodec (optional) serializes a run’s session to a blob and restores it, the resume primitive. dispose (optional) releases adapter-level resources. Both are absent on adapters that don’t support them; the trait stays minimal and the host degrades gracefully.
The RuntimeEvent union
Every adapter normalizes its runtime’s native signals into oneRuntimeEvent union, so the orchestrator, board, and UI consume a single stream and stay decoupled from per-runtime quirks. There are seven kinds:
RuntimeEventBase:
seq matters: events that share a millisecond ts are ordered by this monotonic per-stream counter, so a consumer can always reconstruct causal order.
Two asymmetries encoded in the types
The union deliberately encodes two real runtime differences in the types rather than papering over them:- Not every runtime reports USD cost.
cost.costUsdisnumber | null, and anestimated?flag marks a derived (non-authoritative) value. A runtime that can’t supply USD emitscostUsd: nullrather than a fabricated number. - Not every runtime emits incremental text. A runtime without native deltas emits a single synthetic
text-deltacarrying the whole message, so streaming-unaware consumers still see one delta.
assertExhaustive(x: never): never, for switch statements over the union; a new variant becomes a compile error at every switch that forgot to handle it.
The registry
RuntimeRegistry is the open set of available adapters, keyed by id. It is a thin Map wrapper: register, unregister, get, has, ids, list, that mirrors the open-set philosophy: OpenClaw is the reference adapter today, and future adapters (or a human participant) register through the same interface. Like everything else here, the registry has no opinion about which adapters exist; the server wires concrete adapters in at boot.
The async queue
Adapters bridge a callback event source (a WebSocketonEvent, a subprocess stdout reader) into an AsyncIterable by pushing into a createAsyncQueue<RuntimeEvent>() from the callback and letting the consumer pull via for await. The queue is single-consumer push/pull with one notable design choice:
Backpressure is drop-oldest at a
max (default 1000). Orchestration cares about tool-calls and done, not every text delta, so dropping the stalest buffered item if a consumer falls behind is safer than unbounded memory growth. A close() ends the stream; the iterator’s return() closes the queue, which is what lets events() release its subscription on consumer termination.The contract suite
EveryRuntimeAdapter must pass one shared test suite: runAdapterContract(harness). The adapter supplies a runtime-specific AdapterTestHarness: how to make the adapter, start a run, emit a native frame, and read recorded side-effects, and the generic suite drives a runtime-agnostic scenario through the adapter and asserts on the normalized output. The harness translates abstract ContractFrames (delta, toolCall, final, aborted, error) into the runtime’s own transport frames.
The contract asserts the load-bearing properties of the trait:
idis a non-empty string andparticipantKindis'agent'or'human'.capabilities()returns a well-formed shape (the base flags are the right types).health()resolves within two seconds.start()returns a handle with the adapter id and an unboundrunId(null).events()round-trips a normalized stream that ends indone: successwith monotonically increasingseq.runIdbinds late, from the first lifecycle frame.abort()issues a cancel side-effect and the stream surfacesdone: aborted.setModel()andwriteContext()each issue the expected side-effect.
The contract suite is exposed under the
@clawboo/executor/contract subpath and vitest is kept external in the tsup config. If the runner were bundled, @clawboo/executor/contract would register its describe/it on a detached collector and the adapter’s contract tests would silently never run. Keeping it external binds the suite to the consumer’s vitest instance at runtime.InMemoryAdapter that satisfies the same runAdapterContract suite the real adapters use. It proves the harness is sound independently of any runtime and doubles as the smallest reference implementation of the trait. All five real adapters import runAdapterContract from the contract subpath and run it against their own fake driver.
The ./tiers KV-cache primitives
The ./tiers subpath is the prompt-assembly discipline, the rules every shipping harness converged on for keeping a provider’s KV/prefix cache warm. It is generic, browser-safe (no node:*), and the seam each adapter assembles its prompt through.
A prompt has three tiers ordered by how often they change:
assembleTiers(tiers) joins them stable → context → volatile so the frozen content forms the cacheable head and only the volatile tail changes per turn; append, don’t mutate the prefix. It returns the joined prompt, the stablePrefix (identical turn-over-turn), its byte length, and suggested cacheBreakpoints (byte offsets an Anthropic-style consumer maps to cache_control markers; OpenAI/OpenClaw ignore them).
Two helpers enforce the cache-stability rules:
sortToolDefs(defs)sorts tool definitions by name, non-mutating. Tool-array order is a load-bearing cache key; an unsorted (or hash-map-ordered) list re-orders turn-to-turn and busts the cached tool-definitions prefix.dateStamp(d)returns a date-onlyYYYY-MM-DD(UTC) stamp. Never minute/second precision; a fine-grained timestamp in the prompt busts the KV prefix cache on every rebuild. If a runtime needs wall-clock time, expose it as a tool, never bake it into the cacheable prefix.
TextEncoder (the UTF-8 unit providers cache on), which is global in Node 22+ and every browser, keeping the module browser-safe.
The runner (executor-runner) assembles each run’s prompt through assembleTiers, putting the worktree handoff into the volatile/context tier; the native runtime’s conversation loop uses dateStamp for its date-only header.
Session rotation
The package also ships the session-rotation helpers (shouldRotate, buildRotationHandoffNote, rotateSession, DEFAULT_ROTATION), the model-agnostic answer to “the session ran out of room before the task finished.” Rotation happens at the run boundary, not mid-generation, because the adapter owns the inner loop. The helper is pure and DB-free: it takes an adapter, a restart closure (the host re-assembles the prompt with a short structured handoff note and calls adapter.start), and an optional recordRotation callback for lineage/observability. shouldRotate fires on the context watermark (tokensUsed / contextWindow >= thresholdPct, default 0.85), inert when the runtime reports no window, so a runtime without a context window stays byte-identical to pre-rotation behavior and only rotates on an explicit max_turns. DEFAULT_ROTATION.maxRotations (3) bounds the successor chain per task. The runner wires all of this into its drive loop; the mechanics live in executor-runner.
Design rationale and trade-offs
The trait exists to make “a teammate is a runtime” structurally true. By normalizing every runtime onto one event union and one control surface, the orchestrator, the supervisor, the UI, and the verification layer code against a single shape and never learn a runtime’s quirks. The two type-level asymmetries (costUsd: number | null, synthetic text-delta) are the honest cost of heterogeneity; they push the difference into the types where a consumer must acknowledge it, rather than hiding it behind a lie.
The package is pure on purpose. With no workspace deps and no node:* imports, the trait and its primitives build browser-safe and inline cleanly into the CLI bundle; the real drivers (the WebSocket client, the subprocess spawner, the SDK) live in the adapter packages and the server, behind the seam. The contract suite is the enforcement mechanism: a new adapter is correct when it passes runAdapterContract, and the in-memory self-test proves the suite itself is sound.
The trade-off is indirection. Driving a runtime is two layers, the adapter and the runtime behind it, and a behavior change often touches an adapter’s frame mapper rather than any consumer. The native-preservation seam adds another: capabilities are data the host turns into an integration plan, so getting a runtime’s home/skills/memory right means getting its declared Capabilities right, not editing the runner.
Boundaries and non-goals
- The trait does not dispatch. Claiming, worktrees, verification, budgets, and handoff are the runner’s job. An adapter starts a run and yields events; it knows nothing about the board.
- The trait does not decide who exists. That is the registry of record (AgentSource). An adapter never creates or archives a Boo.
- The contract suite is not in the app bundle. It imports vitest and is reachable only through the
./contractsubpath, with vitest kept external. participantKind: 'human'is a dormant seam. No human-participant adapter exists in v0.3.1../tierscache breakpoints are advisory. They are suggestions for an Anthropic-style consumer; OpenAI auto-caches prefixes and the OpenClaw Gateway owns its own caching, so both ignore the breakpoints.
These docs describe Clawboo v0.3.1, the current release.
See also
- The agent model: what a Boo is and how it maps onto a runtime
- The executor runner: claim → worktree → run → verify → handoff, plus rotation and breakers
- Seams:
resolveRuntimeIntegrationand the source multiplexers - AgentSource (internals): the registry of record
- Runtimes overview: the five runtimes and the capability matrix
@clawboo/executorreference: the full public API- Glossary: canonical term definitions