Component ABI
The agent core (cowboy-core) shares its state machine across hosts. Its
custom asynchronous interface is effects out (a Host trait) and
inputs in — split into
semantic Intents (the shared vocabulary) and raw HarnessEvents (the Zellij
transport plus effect completions). This chapter is its ABI: the WIT world
cowboy:agent@0.2.0 at
specs/wit/cowboy-agent.wit
— the single WIT source every bindgen consumer reads — which lets a runtime
embed core without linking Rust in-process.
The world is small enough to state in a sentence. It exports one interface,
agent, with five functions — initialize, handle-command,
handle-host-event, snapshot, describe — and imports one, host, for
the effects a component requests: exec, http, set-timer, close-self,
spawn-peer, and own-agent-id.
Two host paths
Core has two adapters, and only one goes through WIT:
| Host | Crate | Path to core | Input | Output |
|---|---|---|---|---|
| Zellij plugin | cowboy-harness (wasm32-wasip1) | links cowboy-core natively, calls the Rust trait | keys/mouse/paste → navigation stays local, semantic keys → Intent | ANSI to stdout (Zellij captures) |
Headless embedder montana | cowboy-component (wasm32-wasip2) | WIT cowboy-agent world | command + host-event | JSON display model via snapshot |
The Zellij adapter deliberately bypasses the component ABI. It needs keystroke-level fidelity to drive the terminal UI (prompt-line editing, navigation, expand/collapse), and it can link Rust, so paying the component-boundary tax buys it nothing. The WIT world exists for hosts that cannot link Rust — and those hosts don’t remote a TUI, they render the JSON display model and build their own input affordances.
The TUI lives in the adapter.
cowboy-coreis a headless agent model: it owns theDisplayItemdata andAgentHarness::frame_json, while the renderers, syntax highlighting (syntect), modal input, and view state (ViewState) live incrates/harness/src/tui— wrapped around the core asTui { agent, view }, which derefs toAgentHarness. The generic ANSI atoms (colors, symbols, text, spinner, key types) live incowboy-ui. Core depends on neithercowboy-uinorsyntect, so the WASI component sheds both. Every view↔core coupling crosses theHostseam as a defaulted hook (below), never a field poke.
The named-intent seam
Raw keystrokes never cross the ABI. Core’s input surface splits in two:
- Navigation / view — scroll, expand/collapse, search, prompt-line editing. Client-local: the Zellij adapter owns it (it holds the TUI projection); a JSON client navigates its own copy of the frame. Never crosses WIT.
- Semantic intents — the handful of actions that change agent/session state:
submit/interrupt/approval/debug/quit. The type isIntent, owned bycowboy-contractsand re-exported bycowboy_core::intentso the native adapters and the component adapter cannot drift.
Both adapters converge on one vocabulary via AgentHarness::handle_intent —
no bifurcation. The Zellij adapter’s semantic key arms (in
crates/harness/src/tui/input: prompt.rs Enter → Submit, navigate.rs
Esc → Interrupt and d → Debug, mod.rs sentry y/o/A/n →
Approval) call the same method the WIT component maps command’s cases onto.
Intent derives serde, so it is also the montana driver’s inbound wire shape
({"submit":"hi"}, "interrupt", {"approval":"once"}, "debug", "quit",
specified by specs/control.cddl)
— one vocabulary end to end, not a second protocol.
Commands vs. host events
The world’s inbound surface is two types, delivered through two exports, because they have different producers and different trust semantics — a transport command must never be confusable with an effect completion:
command— semantic operations an operator or protocol adapter applies to the instance:submit(string),interrupt,approval(approval),shutdown. Delivered byhandle-command.host-event— completions and notifications the host produces:timer,command-result,web-result,permission-result. Delivered byhandle-host-event.
Both return bool: true when observable state changed and a fresh snapshot
is worth pulling.
debug is deliberately not a WIT command — it opens a local log viewer, so
it is a host diagnostic rather than an agent operation, and it stays in the
native Intent vocabulary only. Intent::Quit is what command.shutdown maps
onto guest-side.
Neither type carries key, mouse, or paste. Core keeps the full
Key/KeyEvent/Pasted + HarnessEvent types for the Zellij transport.
One component instance has one foreground turn. The world exposes no task
handle, no detach/background operation, and no reattachment; submitting while
work is active queues the input, and interrupt asks the guest to abandon its
turn without promising cancellation of already-issued host effects. Peers
spawned via host.spawn-peer are separate instances, not background tasks of
the spawner.
Rust-only host verbs
Some seams stay in the Rust Host trait and never enter the world at all,
because they are meaningless off a terminal.
The pane verb is one: pane visibility is client-local view state a windowed
host acts on, so montana/remote never needs it. pane_op lives in the Rust
trait (the native Zellij adapter implements it); the WASI component no-ops it.
Defaulted view hooks
Four more Rust-only Host methods carry the view couplings — all defaulted,
so headless hosts inherit them for free (they carry no meaning off a terminal),
and only ZellijHost overrides them:
reveal_latest()— “a new item was appended; snap the view to it.” Core calls it wherever the view must follow appended output;ZellijHostrecords a bit itsTuidrains after the event (honoring the user’s mode).open_debug(path)—ZellijHostopens the debug log in a floating pane; headless no-ops. Keeps thezellij action new-pane … less +Fexec out of core.has_draft_input() -> bool— core’s idle judge/autoreprompt guard; the draft lives in the adapter’s view state, so the default isfalse.editor_active() -> bool— core’s heartbeat cadence; the$EDITORround-trip is a Zellij-only affordance, so the default isfalse.
ZellijHost and its Tui share an Rc<HostSignals> cell block: core→view for
reveal_latest, view→core for has_draft_input/editor_active. Like pane_op,
none of these are in the WIT world — they are Rust-trait seams a windowed host
implements and everyone else ignores.
The opaque-correlation rule
Every effect that expects a reply carries a correlation — an opaque
list<tuple<string, string>> created by the guest (core holds it as Ctx, an
ordered map, and converts at the boundary). The host echoes it back verbatim
on the matching host-event and never reads or writes it. The request “kind”
lives inside it as a string, meaningful only to core (see dispatch.rs).
A correlation identifies an effect callback, not a foreground or background agent task — it is not a handle a host can hold, poll, or reattach to.
The payoff: new command/web kinds are new correlation string values, not new ABI surface. The world never changes when core grows a new internal effect kind. A host that treats the correlation as bytes-in/bytes-out is forward-compatible by construction.
Effects are requests, not grants
An exec or http call across the host import is a request. Nothing about
crossing the boundary authorizes it: the host evaluates it under the active
backend and capability policy (sheepdog, the credential proxy, the OCI
sandbox — see Security) and may refuse. The same is true of
initialize’s config map: its entries are configuration, not capability
grants. own-agent-id is the one purely informational import — the instance’s
own id, which core’s heartbeat uses to address itself.
Additive timer semantics
host.set-timer(secs) is additive and one-shot: each call arms an
independent timer, and there is no cancellation. This mirrors Zellij’s
set_timeout, which has no cancel API. Core’s heartbeat (heartbeat.rs)
compensates with an armed_count that culls the resulting fan-out down to a
single live chain.
A host must not “improve” this to one cancelable timer. Core’s re-arm logic
assumes additive delivery; a cancel-on-rearm host would stall or double-fire the
heartbeat. A headless embedder implements this as a plain deadline min-heap:
push on set-timer, pop-and-deliver when the earliest deadline passes.
The JSON frame contract
snapshot() returns one line of literal JSON:
{"status": "WaitingForInput", "items": [ /* DisplayItem… */ ]}
status is AgentStatus; items is the DisplayItem list core also renders
to ANSI. ANSI highlight caches (highlighted_lines, highlighted_input) are
#[serde(skip)]’d — they are render artifacts, not state.
Deliberately unstabilized. WIT specifies only the carrier — a string holding one JSON object. The shape inside tracks core’s display model and will change as that model settles; a typed, versioned presentation contract is deferred work, so no host may treat this serialization as a stable interface by accident.
montana’s ndjson stream wraps these frames per agent as{"seq", "agent", "frame"}— that envelope is specified, by thecontrol-framerule inspecs/control.cddl.
The describe contract
describe() returns the protocol-neutral agent descriptor, also as one JSON
object in a string: name, description, version, provider, model, capabilities,
and the skill list (one entry per tool, carrying its JSON Schema input schema
verbatim). Unlike the snapshot, this record is specified — by
specs/descriptor.cddl,
with Descriptor in cowboy-contracts as the Rust shape.
It exists so a host can advertise the agent without knowing anything about
Cowboy’s internals: crates/a2a maps this one export onto an A2A agent card and
its skill list, and montana serves that mapping. Protocol adapters are bindings
onto describe + handle-command, not alternative agent APIs.
/connect — a harness as a client of another harness
/connect <host:port>#<secret-path> (or /connect <montana-uds-path>) turns a
Zellij harness into a thin client of a remote agent, built entirely on the two
contracts above: inbound, montana’s ndjson envelope rehydrates into
Vec<DisplayItem> (pinned by display_item_round_trips_through_json);
outbound, every user action is one Intent wire line (pinned by
intent_wire_shapes). The fragment names a bearer-token file — the token
is the first line of the connection, exactly the cowboy-runtime __forward
handshake; the token itself never enters plugin config or argv.
Mechanically it is drop-and-connect: the plugin writes a request JSON to
connect_request_path and quits; the Python launcher’s relaunch loop
(cli._run) starts a fresh Zellij whose plugin sees connect_target at
load() and comes up in client mode (crates/core/src/client.rs) — no
providers, no session, no API keys. Because a WASM plugin cannot hold a
socket, a cowboy _connect bridge process owns the connection and streams
each frame line in via zellij pipe --name connect-frame; sends go out as
one-shot cowboy _connect --send connections. /disconnect is the same
sentinel dance back to a local session. Standalone entry:
cowboy connect TARGET#SECRET-PATH.
Caveats: in a full NixOS (netns) deployment, outbound TCP is DNAT’d to the
secrets proxy — a /connect target port must be in
secretsProxy.passthroughPorts (UDS targets are unaffected; sheepdog always
allows AF_UNIX), and the in-session handoff loop exists only in lite launches
(full mode: run cowboy connect from a host shell — a client session needs no
agent config anyway). A read-only __forward --ro port works as an observer:
frames flow, intents are dropped server-side.
Known Zellij leaks in core
A few Zellij-isms survive in cowboy-core. None break a headless host; they
degrade gracefully and are tracked here for eventual promotion to proper host
verbs. (The two obvious candidates are already out: the $EDITOR round-trip is
Tui-local, and the debug pane is the open_debug host hook.)
- Subagent wasm path (
spawn.rs) — the fallback peer path assumes a Zellij plugin layout (data_dir()/zellij/plugins/cowboy-harness.wasm). A headless host overrides it via config /AGENT_HARNESS_WASM. - Model-facing prose (
config/loaders.rs) — thespawn_subagenttool description says “in a separate Zellij pane”. Cosmetic; visible to the model, not load-bearing.
Filesystem access alongside WIT
Core also uses direct filesystem operations for configuration, plan state and metadata. Montana grants these through WASI preopens, mapping host directories to identical guest paths so native execution effects see the same files. These grants are separate from the custom WIT imports. An embedder must review both the preopens and effect policy; see Design Overview.