Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

HostCratePath to coreInputOutput
Zellij plugincowboy-harness (wasm32-wasip1)links cowboy-core natively, calls the Rust traitkeys/mouse/paste → navigation stays local, semantic keys → IntentANSI to stdout (Zellij captures)
Headless embedder montanacowboy-component (wasm32-wasip2)WIT cowboy-agent worldcommand + host-eventJSON display model via snapshot
Browser (future)jco of the same componentsame WIT worldsame command + host-eventsame 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-core is a headless agent model: it owns the DisplayItem data and AgentHarness::frame_json, while the renderers, syntax highlighting (syntect), modal input, and view state (ViewState) live in crates/harness/src/tui — wrapped around the core as Tui { agent, view }, which derefs to AgentHarness. The generic ANSI atoms (colors, symbols, text, spinner, key types) live in cowboy-ui. Core depends on neither cowboy-ui nor syntect, so the WASI component sheds both. Every view↔core coupling crosses the Host seam 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 is Intent, owned by cowboy-contracts and re-exported by cowboy_core::intent so 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 dDebug, mod.rs sentry y/o/A/nApproval) 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 by handle-command.
  • host-event — completions and notifications the host produces: timer, command-result, web-result, permission-result. Delivered by handle-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; ZellijHost records a bit its Tui drains after the event (honoring the user’s mode).
  • open_debug(path)ZellijHost opens the debug log in a floating pane; headless no-ops. Keeps the zellij action new-pane … less +F exec 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 is false.
  • editor_active() -> bool — core’s heartbeat cadence; the $EDITOR round-trip is a Zellij-only affordance, so the default is false.

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 the control-frame rule in specs/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) — the spawn_subagent tool 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.