Component ABI
The agent core (cowboy-core) is host-agnostic. Its entire outside world is a
narrow seam: 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 |
| Browser (future) | jco of the same component | same WIT world | same command + host-event | same JSON |
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. If a
TUI-remoting host ever appears, a key interface plus an ANSI-render export
can be added to the world — additive, non-breaking.
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: dissolution, not ABI
Roughly two-thirds of core’s command dispatches are file I/O emulated by shelling
out (cat, sh -c) because a Zellij WASI plugin cannot do direct file I/O.
Those are not ABI surface: under a wasi:filesystem-capable host they
dissolve into ordinary in-core code against preopened directories. The three
production sites that already use std::fs directly (context/plan.rs,
context/tool_result_meta.rs, config/loaders.rs) work today under montana’s
identity preopens (host path X preopened at guest path X, so exec and
in-process fs agree). Converting the shelled-out I/O to direct fs is a future,
capability-gated simplification — it shrinks core without touching this world.