Skip to main content
Glama
Deploy-Forward

convoy

Official

Convoy

Convoy: local-first multi-agent orchestration. Run Claude Code, Codex, Cursor, Grok and agy side by side on your machine, on one durable thread.

Convoy's MCP runs on your machine at http://127.0.0.1:8788/mcp (convoy mcp). There is no hosted Convoy endpoint. One local server serves every thread on the machine; each call names its thread, and --root pins one.

Remote access to your loopback MCP is not a Convoy feature; if you build it, put it behind your own access control.

Install

Python >= 3.11, standard library only (no runtime dependencies).

git clone https://github.com/Deploy-Forward/convoy.git
cd convoy
python -m pip install .
convoy --help

Alternative: pipx install . (not verified on Windows in this pass).

The convoy console script and python -m convoy both work only after install. To run from a checkout without installing, put src on the path: PYTHONPATH=src python test/run.py on bash, or $env:PYTHONPATH='src'; python test/run.py in PowerShell.

Skills and plugins

Skills ship from Deploy-Forward/plugins (github.com/Deploy-Forward/plugins): the convoy and worklanes plugins carry the Convoy skills and the MCP connection to your own Convoy (http://127.0.0.1:8788/mcp). The convoy-wizard skill is not in the plugins repository; convoy preflight still checks the verbs a guided setup needs, from the list in src/convoy/wizard_preflight.py. This repository is the CLI and the MCP server. It ships no skill text and no plugin pack, so there is one source for the skills a neuron reads. Claude Code installs with claude plugin install convoy@deploy-forward; the plugins repository's README has the install for every other harness.

A launch writes one Convoy file a neuron reads, the AGENTS.md pointer (see below): one paragraph naming the convoy plugin's convoy-operate skill and convoy-dictionary. Convoy replaces that block only when it is a block an earlier Convoy wrote; a block you edited is left as it is and the card names the file. Convoy deletes nothing in a worktree: skill copies, agent files or prompts an earlier version wrote stay where they are, and removing them is your call.

convoy --version prints the version, the executable that ran, the package source it imported and whether that source is an editable install. Run it first when a skill and the CLI seem to disagree.

Host rendering

Host rendering: unverified. Whether Grok Bot surfaces Convoy as @convoy or /convoy, and whether it renders the card tool's structuredContent as one card with a drill-down or only shows the text copy, is a fact about the host that nothing in this repository observes. The server declares card with an MCP outputSchema and answers through structuredContent so a host that renders cards can; the claim that it does stays unverified until a maintainer records a live run in test/demo/fixtures/host_rendering.json (date + verbatim evidence), which flips host_rendering_contract_test from skipped to asserting.

The canonical local and plugin sequence is documented in docs/convoy-happy-path.md, including the exact chair-addressed send path and the tenant-isolation requirement any future hosted product would have to meet before the word "cloud" applies.

Receiving messages needs a command that resolves

Neurons receive through a harness hook, and a hook runs in its own shell that inherits nothing from yours. A hook file only ever carries the bare convoy inbox --hook-pretooluse or convoy end --hook, never an interpreter path, and Convoy probes that command before writing any hook file. Check what it found:

convoy --root <thread-root> skills --worktree <worktree>

In a worktree Convoy did not mint, skills refreshes only the Convoy-named files; add --write-repo-files for AGENTS.md. Convoy writes no .codex/hooks.json: Codex runs Convoy's hooks from the convoy plugin, once the person trusts them with /hooks in Codex.

The card's hooks.resolved_via is console-script when the installed convoy answers in the hook shell. When it does not (it is not on PATH, or an unrelated convoy shim shadows it), the card names the PATH problem with an install hint, no hook file is written or removed, and a launch refuses rather than start a pane that cannot receive. Put the console script first on PATH (convoy --version names the one that runs) and re-run. The same command is the repair when a hook goes stale: it replaces Convoy's own older entries, an interpreter-pinned one included, with the bare command and leaves your own hooks untouched.

Related MCP server: wmux

CLI reference

One line per verb; flags shown are the ones you will reach for (see convoy <verb> --help for the full set). Every verb accepts a global --root <thread-root> before the verb name.

Read (no writes to thread state):

  • list [--json] [--all] [--since <window>] — deterministic numbered thread blocks and full neuron rows. Default: present threads updated/active within 14 days. Temp and absent roots are always named as skipped, never removed. --all includes hidden/older usable roots with reasons. Unknown fields stay unknown; detached is not dead. The read-only MCP list exposes the same card to authenticated callers, not the anonymous product edge.

  • threads [--prune] — every Convoy thread this machine knows. --prune drops rows whose root is under the OS temp dir or is absent and reports every dropped row (never silent).

  • panes — every body of every neuron on this thread, from the OS process table; never a token.

  • whoami — which chair is this process? Walks process ancestry to the harness. On a seated chair it adds lead and launched_by ({chair, neuron_id} or null).

  • report "<text>" — send your result to the chair that launched you (launched_by), else to the lead; refuses with why when neither exists. Needs environment, token or pane-host proof of your session.

  • A neuron launched over MCP (crew, join, launch, bring_up) records the conductor its bearer proves as its launcher (launched_by: {kind: conductor, name}); its convoy report is a proven note addressed to that conductor, read with the conductor's replies cursor. An MCP launch without a bearer identity refuses.

  • adopt --id <neuron id> (or --seat <chair>) — make yourself the launcher of an existing neuron that has none (or whose launcher is gone or detached), then send it its report and reply commands. Needs environment, token or pane-host proof; an unseated caller is attached first. A live recorded launcher is replaced only by the thread's lead.

  • reply <token> "<text>" — answer one send: a note to its proven sender citing the token, which is the delivery receipt replies counts. Refuses an unknown token or a caller that was not the recipient.

  • graph [--neuron <chair>] [--html [--out <file>]] — read-only ontology of the thread.

  • seats [--convoy-id <id>] — seat rows.

  • feed --since <10m|2h|1d|45s|ISO> — events in a window; the card echoes since_iso.

  • relaunch [--thread <name>] [--timeout <s>] [--dry-run] — after the panes died (shutdown): brings every chair up again from seats.jsonl in its own worktree, queues each chair an inbox row saying when it left off (last_seen, unread, the exact feed --since <ts> to run), stamps kind=relaunch, and proves connected only from seated acks stamped after the relaunch. Dry shows the timeline and spawns nothing.

  • rail [--since <window>] [--probe] — the strip under the panes: feed events, seats connected | pending | stale (from the seated acks), usage remaining per harness (null is unknown, never 0; read by running each harness's CLI only with --probe), last stamp, lead. Reads only the thread; from a chair's worktree it finds its thread through the machine index, so every neuron sees one rail.

  • context [--instance-id <chair>] — pointer pack for a neuron.

  • glance [--thread <name>] [--tray] — one-screen status.

  • resume --neuron <chair> — dry: prints native argv + cwd, spawns nothing.

  • choices — installed harnesses, known worktrees, chairs, terminal adapter; no resume tokens.

  • probe --to <harness>, id, terminals.

  • widget [--topmost/--no-topmost] [--refresh 3] [--service] — always-on-top tkinter strip: one dot per thread from recent(), expand chairs, click → focus; a stale chair shows a nudge button (dry card first, keys typed only on confirm, then the feed is polled 60 s for the chair's own row — that row alone means delivered). pin toggles topmost; x hides to the tray where pystray+PIL import, else minimizes. --service starts one detached strip per machine behind $CONVOY_HOME/widget.pid (already: true when the pid is alive and its image is our interpreter; a reused pid respawns). crew --launch and relaunch start it unless --no-widget. A start with seats from the widget records the thread's held lead chair as each neuron's launcher (the card says launcher.source: "widget-lead"), so the neurons report to the lead; a thread with no held lead refuses until an agent session attaches (convoy attach <thread>). Stdlib only.

  • focus --seat <chair> — ask the pane host to highlight that chair. {focused: false, reason} until a host adapter is evidenced (tmux select-pane -t is tested; Windows Terminal wt focus-pane is not evidenced).

Write (thread state):

  • init — create the thread layer at --root.

  • bind --thread <name> — bind this root to a named thread.

  • start [<target>] [--to <harness> ...] [--thread <name>] [--cancel] — resolve an existing local path without network access; a git URL or owner/repo reuses a checkout by normalized remote identity before cloning; a bare name searches indexed/local repos and the authenticated GitHub user's and organizations' repositories. Only exact case-insensitive repository names or folder basenames can resolve automatically; fuzzy suggestions return a numbered picker. Among complete same-remote matches, prefer an indexed thread root (latest indexed update), then a unique main checkout. Otherwise ask, with main checkouts first and linked worktrees collapsed to a count; --all expands them. No match asks local folder versus GitHub creation, unless discovery failed (unknown, never absence). Only explicit --create creates a private GitHub repo. Missing/logged-out gh stays local; an exact local match also works offline without refresh. --search-root <dir> repeats and scans one level deep, excluding Convoy state folders except the owned checkout store; --scan-budget <seconds> defaults to 5 and includes local candidate dates. Incomplete scans carry warnings and never auto-resolve. Ordinary remotes use config file reads, including shared worktree configs and once-per-resolution global insteadOf rules; conditional includes/overrides fall back to bounded Git reads. GitHub candidate date metadata comes from the single listing's updatedAt (marked as that source, not a commit timestamp). Cloud-backed checkouts fetch non-interactively and fast-forward only if clean, behind, and without local commits or ignored/untracked files that would be overwritten; dirty/ahead/diverged/detached work is kept, never stashed, reset, rebased or merged. Fetch and ls-remote alone disable interactive credentials; clone/worktree keep their existing policy. Helper timeouts terminate their process trees without waiting on inherited output pipes. No target returns the existing thread picker. No pane launches. Repo writes stay under .convoy/ unless --write-repo-files; cards include the resolution and pulled outcome, with credentials redacted and non-secret Convoy receipt tokens retained.

The ignored-file guard checks incoming paths immediately before fast-forward. An ignored file created concurrently after that check can still be overwritten by Git; this remaining race is not an atomic preservation guarantee. No stash/reset or implicit file deletion is used.

An indexed thread root outranks the main checkout only when its known index update is within 14 days. Older, future or unknown dates do not prove freshness. When only linked worktrees match, the picker shows their actual numbered rows, newest first, rather than an empty collapsed list. Refresh performs one non-interactive fetch followed by local git merge --ff-only @{upstream}; it never creates a merge commit or performs a second network round trip through pull.

  • attach [<thread>] [--as-harness H] — link the calling native session to the chosen thread using the same process/native identity proof as whoami. <thread> is the exact cvy_ id, a unique prefix of it of at least 8 characters (cvy_ counts), the exact thread name, or the thread's root path; an ambiguous prefix refuses with matches N threads: <ids>. detach --thread and lead --thread resolve the same way. Pick numbers are display-only: pass the block's cvy_ id. No choice prints the list and refuses to guess; unavailable/conflicting identity refuses without creating a chair. A native session can sit on several threads at once, one chair on each: the card lists the others in also_on, the hooks deliver every thread's inbox (each row labelled with its thread), and a command that needs one thread (whoami with no root, detach without --thread) refuses with the list. Repeating returns already: true and per-chair catch-up; a bare local self-join reuses the chair. Explicit names, titles, other worktrees and join --launch still provision a new neuron. An unavailable non-temp indexed root is reported in ownership_skipped and never refuses the attach; temp roots remain in the index but are excluded from attachment, with reasons in ownership_skipped. No pane or vendor usage probe is launched by attach. --as-harness asserts, never overrides, the proven harness. Legacy catch-up-only callers use attach [<cvy_id>] --read-only (still records an attach event, but never seats the caller).

  • detach [--thread <thread>] — prove the calling chair, write its rolling handoff and append detached state. No history deletion, pane close or session kill. Pending rows remain on disk but cannot wake the detached chair; sends refuse detached; attach again. Attach reactivates the same proven chair.

  • onboard --to <harness> [--to ...] [--thread <name>] [--checkout-root <path|git-url>] [--github yes|no] — name installed harnesses and bind; a URL is cloned once under $CONVOY_HOME/checkouts/<owner>/<repo> (.convoy/ and thread.md go into that clone's .git/info/exclude). Onboard names no lead: a new thread's lead is unset until a session attaches (it takes the lead) or a seated chair passes it; a later onboard reports a standing lead and never changes it.

  • seat --to <harness> --session-id <chair> [--worktree <path>] [--model M] [--resume <vendor-id>] [--title T] [--effort E] — register a seated neuron.

  • join --to <harness> [--worktree <path>] [--title T] [--as <chair>] [--launch] [--consent <id>] — register one fresh chair.

  • crew --seat <harness>[,model=M][,effort=E][,where=local|cloud][,title=T] [--seat ...] [--checkout <path>] [--launch] — N neurons at once: validates every seat first, mints one worktree per local seat, joins every chair with a boot prompt, and (with --launch) brings them up in ONE window. Launched is not connected: the card's seated snapshot says pending.

    • --count N repeats one --seat N times (1..16; titles <harness>-<i> or <title>-<i>): convoy crew --seat codex --count 6 --launch.

    • --brief TEXT|@path sends every neuron the same task once it has seated (--brief-timeout, default 300 s), each copy prefixed with "You are neuron of ...", through send: one token per neuron in the card's briefs. A neuron that does not seat gets no send and a warning. Without --launch the card shows the planned briefs.

    • More than four panes open in tabs of four, each a 2x2 grid, in the thread's window. --here splits your own window and takes at most four.

    • Canary first: a launch of several neurons starts the first alone and launches the rest only once it is seated (--canary-timeout, default 120 s). If it does not seat or its harness exits, the rest stay joined and unlaunched (reason: "canary_failed") and ok is false. --no-canary launches all at once.

  • add <harness> [<model>|auto] [--effort E] [--title T] [--checkout <path>] [--dry-run] — one neuron: mints its worktree, joins its chair, and launches it inside tmux as a split of your exact pane; on Windows into the thread's own Windows Terminal window (wt -w convoy-<8 hex>: the first neuron opens it, later ones split inside it, never your working window); on POSIX outside tmux into the thread's one detached tmux session. It refuses before any write when there is no terminal to use, and when it cannot prove who is launching (run it from an agent session such as Claude Code or Codex, or convoy attach <thread> first): every neuron records its launcher. Model and effort are auto unless given: no flag, the harness picks. The card's placement is split, thread-window, detached, here or none; a failed launch leaves the chair joined with a recovery verb that works on that path. --here is the opt-in to split the window you are working in (Windows: wt -w 0 split-pane; inside tmux: a split of your pane); outside both it refuses and names thread-window/detached. It is never the default.

  • await-seated --seat <chair> [--seat ...] [--timeout <s>] — observe the acks: per chair connected (its own seated row cites the minted token) | pending | stale, with the seconds waited.

  • swap --seat <chair> --to <harness> --handoff <.convoy/handoff/<chair>-<ts>.md> --as <chair> — replace the occupant, keep the chair.

  • seated --seat <chair> --token <token> — proof-of-life echo from the new occupant.

  • lead [--thread <thread>] [--to <chair> [--as <you>]] — read the lead (lead, lead_chair, dangling, reachable_id), or pass it to a seated chair. The author is the proven calling chair (environment or token proof); --as asserts it and refuses when it disagrees. Only the current lead passes it; --to <harness> passes to that harness's one seated chair under the same rule; while the lead is unset or dangling (it names a harness with no seated chair), any seated chair may take it. convoy attach takes an unset or dangling lead for the attaching chair. On the lead and attach cards, conductor is the lead chair or null; the hosted grok-bot is named only where that hosted conductor is meant.

  • hook note "<text>" [--as-me] --to <chair> — leave a note for a chair (or grok-bot). A receipt that cites a send token (re token <t> or token=<t>) sent to you, with no --to, goes to that send's proven sender (a third party citing it stays unaddressed); one addressed to its own author refuses (a receipt goes to the sender: --to <chair>).

  • stamp "<summary>" [--agent A] [--model M] [--effort E] [--transcript <pointer>] — conductor stamp.

  • send --to <chair|harness> "<body>" [--live] [--dry-run] [--instance-id <chair>] — synapse. --to <chair> (a session_id, e.g. codex-1-demo) queues into that chair's inbox in its own worktree (delivery: queued, delivered: false); --to <harness> with a chair already on that harness refuses (naming a vendor is not naming a neuron); default runner records a feed row (delivery: recorded); --live runs a fresh headless vendor session (executed).

  • inbox [--seat <chair>] [--drain | --hook-pretooluse] — list or drain the live-seat inbox. The hook command is always convoy inbox --hook-pretooluse (never a baked interpreter path).

  • end [--summary <text>] [--push | --hook] — explicit task completion, or the Codex/Claude Stop heartbeat. --push authorizes exactly one plain git push and refuses dirty, detached, or no-upstream state; --hook never pushes.

  • install --to <harness> --opt-in [--live] — cataloged installer; dry-run by default.

Launch / panes:

  • choices — see above; run it first.

  • launch --seat <chair> [--dry-run] [--consent <id>] — split one already-joined fresh chair into the active pane host.

  • consent --grant <request-id> — grant a prior consent request after the user explicitly approves it.

  • close --seat <chair> [--consent <id>] — request closure of one Convoy-managed pane.

  • nudge --seat <chair> [--keys <exact>] [--target <tmux-pane>] [--dry-run] [--consent <id>] — wake an idle chair on this machine. Identifies the pane first (live body from panes plus a unique WT title or tmux target). Live send needs a consent card that names that pane and the exact keys. delivery: nudged, never delivered. Refuses when the pane cannot be proven. Write-gated on MCP.

  • bring-up / open [--thread <name>] [--dry-run] — bulk show of seated neurons in the thread's own terminal window (wt -w convoy-<8 hex>).

  • hide / minimize / background [--dry-run] — bulk hide.

  • resume --neuron <chair> --go — spawn once in the chair's worktree; refuses when a live body holds the chair.

MCP:

  • mcp [--root <thread-root>] [--host 127.0.0.1] [--port 8788] — serve the loopback MCP endpoint for every thread in the machine index; --root pins one.

Run your own MCP

convoy mcp --port 8788

Then attach http://127.0.0.1:8788/mcp in your MCP client. The server binds only a loopback address (--host accepts 127.0.0.1, localhost or ::1 and refuses anything else) and answers loopback requests only: the connecting peer must be this machine's loopback, the Host must be 127.0.0.1, localhost or [::1] on its port, and an Origin, when a browser sends one, must be an http:// loopback origin on that same port. Anything else is a 403, which keeps a web page (or a DNS-rebinding name) from driving your MCP. No response carries CORS headers. The desktop widget's own local server applies the same checks.

Writes need a conductor bearer: run convoy conductor mint on this machine and send it as Authorization: Bearer <bearer> on every request. The bearer opens the write gate for send, stamp, note, join, seat, launch, crew, seated, consent, await_seated, focus, nudge, onboard, clone, mint, repos, resume with go=true, and inbox with drain=true. There is no process-wide switch: CONVOY_MCP_WRITE_TOOLS was removed in 1.3.2. A tools/list on a machine with no bearer minted hides the write tools. Once a bearer exists they are listed for every caller and refused at tools/call without it, so a listed verb is a promise only to the caller holding the bearer. Reads (choices, neurons, inbox pending, graph) need no bearer, and an inbox read without one never echoes the row token. repos wraps gh repo list on the MCP process PATH (name, url, private, updated_at; gh absent is an install hint); it lists the gh login on the MCP host, the conductor's account, which is why it sits behind the gate rather than handing that inventory to strangers. clone puts a URL under $CONVOY_HOME/checkouts/<owner>/<repo>; mint derives one worktree per seat from that checkout as <checkout>-wt-<name> on branch convoy/<name>, so nobody hand-makes worktrees for N neurons. crew does the whole walk for N seats (validate, mint, join each with a boot prompt, one window) and await_seated reads the acks back, so "they all connected" is observed, never assumed. convoy preflight tells you which of the wizard's verbs a live tools/list is missing and why. One local server serves every thread on the machine; each call names its thread. A server started with --root is pinned to that thread, and a call that names another thread still wins.

Wake service

Wake dispatch is off for a root until convoy --root <root> wake enable. Read convoy --root <root> wake status for enabled state, routes, faults and held alerts; convoy --root <root> wake disable opts that root out without restarting the origin. The supervised MCP origin runs one dispatcher per enabled root under an OS lock.

The waiter route is dispatcher-managed. A session starts its own background waiter using the command printed by its Stop hook; it receives a token pointer, drains only its proven inbox, answers with a token-citing receipt and re-arms. A detached waiter started by a hook is not a session wake. Do not run legacy inbox polling alongside the enabled waiter protocol or infer delivery from a queued/fired card.

A detached chair retains history, handoff and pending rows but cannot drain, pulse or wake; new sends refuse until the same proven session attaches again. Detach does not close its pane or terminate its harness.

Names you will see

  • Grok Bot — the xAI desktop conductor chat that attaches the MCP; not a neuron.

  • ola-brain — a private predecessor wrapper; refused by install, not needed.

  • platform — a closed sibling repo; not needed to run this repo.

Terms

  • Grok Bot: the conductor in this chat; not a neuron and not a window.

  • neuron: one BYO harness session (grok, claude, codex, cursor-agent, agy/antigravity, hermes, or pi) on a thread.

  • synapse: a native Convoy send into one neuron; one harness, one meter, compact card back.

  • Convoy: source of truth (feed, seats, convoy_id).

  • thread: durable circuit keyed by convoy_id.

  • named thread: a --root binding (not a second MCP URL).

  • grok-bot-local vs grok-bot-cloud: neuron host (user machine vs cloud agent), not a second source of truth.

Product wording retires hop in favor of neuron/synapse/thread.

The problem

Single-harness chat is weak project memory: context windows bloat, meter state drifts, and another agent cannot safely rehydrate shared state without copy/paste loss.

Wrapper stacks (one vendor CLI inside another) add indirection and contention instead of shared truth.

The solution

Convoy keeps a slim pointer/stamp layer while Grok Bot remains conductor. Synapses run on native vendor CLIs, return compact cards, and keep session ownership separated.

Contract: feed + seats + convoy_id. Unknown values stay JSON null; no invented usage/session numbers.

Machine-readable contract: src/convoy/harness_effort.json (loaded by MCP-facing code).

Keyed effort language (locked)

Effort keys are harness-scoped and must not be merged. The locked key space lives in CANON.md ("Effort keys are harness-scoped") and the machine-readable source of truth is src/convoy/harness_effort.json — this README deliberately does not restate the table, so there is exactly one place for it to drift from the code: none.

Supported neurons (code-true contract)

grok, claude, codex, cursor-agent, and agy have a cataloged installer (convoy install --to); hermes and pi are BYO-only (install refuses them) and their direct-id resume is unverified.

Where a first run writes: in a worktree Convoy minted (.convoy/minted.json, written by crew / mint when they create it, naming that worktree and its checkout) every file in the table goes in. Anywhere else, often your own repo, a launch and skills write only the Convoy-named files git excludes (.claude/settings.local.json, the convoy-root pointers, .grok/hooks/convoy-inbox.json). AGENTS.md is written there only after an opt-in: --write-repo-files on the CLI, write_repo_files: true on MCP bring_up / open / launch / crew behind the write gate (never on a dry run: a dry bring-up / open / relaunch on the CLI, or a dry MCP bring_up / open, refuses it and writes nothing). The opt-in is kept, bound to that folder and kept out of git, in .convoy/repo-files.json, so later launches there refresh them. Withdraw it with convoy --root <root> skills --worktree <worktree> --no-write-repo-files; the files already written stay, and are yours to keep or delete. Until then the card lists what is missing on disk as would_write, and a Codex seat without Convoy's hook cannot receive; the note names the route that works where it is read. A .claude/settings.local.json that git tracks is never written. The card names each home trust store a launch wrote (trust_stores_written). start and onboard write nothing outside .convoy/ without the flag; terminals writes nothing.

Harness

onboard / roster id

resume_argv shape

ensure_first_run behavior

send --live behavior

grok

grok

grok -m <model?> --agent <path?> --resume <vendor-id?>

Writes PATH ungate block; writes the AGENTS.md plugin-skills pointer (minted worktree, or --write-repo-files); passes --agent only for an agent file the seat names; writes project PreToolUse hook (convoy inbox --hook-pretooluse).

Native CLI on PATH. Named live seats queue (delivery: queued); never steals --resume.

claude

claude

claude --resume <vendor-id?>

Writes PATH ungate block; writes the AGENTS.md plugin-skills pointer (minted worktree, or --write-repo-files); writes .claude/settings.local.json (inbox hooks + Stop heartbeat + auto-compact; no permission keys), merges user ~/.claude/settings.json skip key, and writes ~/.claude.json trust project keys.

Native CLI on PATH. Named live seats queue (delivery: queued); never steals --resume.

codex

codex

codex resume <vendor-id?> (not --resume)

Writes PATH ungate block; writes the AGENTS.md plugin-skills pointer (minted worktree, or --write-repo-files). No .codex/hooks.json: the convoy plugin's codex-hooks.json (in Deploy-Forward/plugins, named by the manifest's hooks field) carries the Stop heartbeat and PostToolUse inbox hook, keyed convoy@<marketplace>:codex-hooks.json:<event>:0:0; add / crew cards warn until the person trusts them with /hooks. No Claude permission-ungate writes.

Native CLI on PATH. A send codex queues (delivery: native-queued, wake: "codex-queue-accepted": accepted, not a turn started) once the plugin's Stop hook has recorded the session id; before that it is queued, wake: "inbox-only", with why.

cursor-agent

cursor-agent

cursor-agent --resume <vendor-id?>

Writes PATH ungate block; writes the AGENTS.md plugin-skills pointer (minted worktree, or --write-repo-files); writes Grok/Claude inbox hook files (swap-safe). Drain via convoy inbox --drain (no vendor hook proven).

Native CLI on PATH. Named live seats queue (delivery: queued); never steals --resume.

agy

agy

agy --conversation <vendor-id?> (live --help 2026-09-01: no --resume)

Writes PATH ungate block; writes the AGENTS.md plugin-skills pointer (minted worktree, or --write-repo-files); inbox hook files as above.

Native CLI on PATH. Named live seats queue (delivery: queued); never steals --resume.

hermes

hermes

hermes --resume <vendor-id?> (live --help 2026-09-01)

Writes PATH ungate block; writes the AGENTS.md plugin-skills pointer (minted worktree, or --write-repo-files); inbox hook files as above.

Native CLI on PATH. Named live seats queue (delivery: queued); never steals --resume.

pi

pi

pi --resume <vendor-id?> (flag verified live; --resume opens a session picker — direct-id resume unverified)

Writes PATH ungate block; writes the AGENTS.md plugin-skills pointer (minted worktree, or --write-repo-files); inbox hook files as above.

Native CLI on PATH. Named live seats queue (delivery: queued); never steals --resume.

Notes tied to code/tests:

  • seat.session_id and seat.resume are distinct: session key vs vendor resume token.

  • First-run seats can omit vendor resume; then no resume token is passed.

  • Live send is headless and never steals an active interactive neuron; refusal cards ask users to bring_up / open a pane or write .convoy/handoff/<chair>-<ts>.md.

  • context.pack overlays home-layer convoy_id + thread_key onto seat-worktree pointers when present.

  • bring_up / open are the bulk show commands; targeted join --launch is the explicit one-chair exception described below.

Bring-up and pane invariants

  • Every WT launch on a thread targets the thread's own window, -w convoy-<8 hex of sha256(convoy_id)>: new-tab for the first neuron, split-pane -V inside it after that, panes in one spawn joined with literal ";" argv elements. Never -w 0, never the caller's window, never -- before the harness exe; never per-seat CREATE_NEW_CONSOLE; never close on fail with WM_CLOSE.

  • Two same-harness seats on different worktrees are two panes; duplicates collapse by worktree/resume/session key.

  • Ctrl+Shift+W should only drop one split pane at a time (or no-op when there is no split pane left).

  • Codex TUI conflict: while a Codex pane is focused, Shift+Up / Shift+Down can change reasoning level and fight pane navigation. Do not use those shortcuts for pane selection in that focus state.

Targeted one-chair launch

Full contract and DoD: docs/targeted-launch.md.

convoy choices lists installed harnesses, known Git/registered worktrees, existing chair identifiers, and the detected terminal adapter. It deliberately omits every vendor resume token. A model or user can then invoke:

convoy join --to <harness> --worktree <path> --launch

This registers and launches exactly one fresh chair. Harness argv construction is independent of terminal placement, so all harnesses use the same terminal adapter contract. A persistent atomic launch claim refuses duplicate launchers; existing/resumable chairs are not eligible and a failed terminal spawn leaves the fresh chair pending for an explicit retry.

Supported active-pane adapters:

Host

Detection

Targeting

Windows Terminal

Windows and wt on PATH

wt -w convoy-<8 hex> new-tab | split-pane -V; the thread's own window (WT_SESSION decides nothing)

tmux on macOS/Linux

TMUX, TMUX_PANE, and tmux on PATH

tmux split-window -t <caller-pane>; exact caller pane

Windows Terminal, --here

Windows, wt on PATH, and the person's explicit --here (MCP here: true)

wt -w 0 split-pane -V; the window the person is working in, never the default

On macOS or Linux outside tmux, with tmux installed, launch and join --launch join the thread's one detached tmux session (convoy-<8 hex>; the first neuron creates it with new-session -d, later ones split-window -t =<name>:) instead of refusing. The card says placement: detached and gives attach (tmux attach -t =<session>). The session is created synchronously, so a refused new-session is reported as not launched. The MCP launch tool, behind the write gate, does the same.

Other terminal hosts fail closed with a manual-pane instruction. Convoy never injects keystrokes or guesses an iTerm, Terminal.app, WezTerm, kitty, or shell API. Source installs currently require Python 3.11+; the project exposes a cross-platform convoy console entry point, but a machine without Python still needs a packaged executable/runtime before a skill can invoke it.

Creation and closure are separate capabilities. The Windows Terminal CLI can create a split but does not expose its closePane action. A killed TUI may leave an exited pane visible under graceful closeOnExit, so absent process IDs are not pane-close proof. New targeted launches therefore use a Convoy lifecycle host: after a separate, scoped close-chair consent it terminates only its owned child tree and exits zero. Legacy panes still require Ctrl+D or the configured closePane binding. First-run harness trust prompts are also user decisions; Convoy returns an awaiting-user-consent card and never auto-accepts them.

Resume: what works today, and what is being improved

Resume is an area to improve. Convoy only resumes a neuron's conversation when it knows the harness's own id for it, and today it learns that id for two harnesses only. For the others, the id is recorded only if someone writes it on the neuron's row. So a relaunch may start a fresh conversation instead of continuing the old one.

Before you promise a person that a neuron will resume, read the dry plan: convoy --root <root> resume --neuron <id> (without --go it launches nothing). If the argv carries the harness's resume form with an id, it will resume. If it carries a declare flag (--session-id, -s) or no id at all, it will start a new conversation. Say so.

Harness

Resume command

Where the id comes from, and when

Evidence

claude

claude --resume <id>

Convoy mints the id before the first launch and declares it with --session-id <id>. Every later launch resumes it once the conversation's file exists at ~/.claude/projects/<worktree slug>/<id>.jsonl.

ran: a session declared with --session-id, then relaunched with the argv Convoy builds, answered from the same conversation

grok

grok --resume <id>

The same minting: declared with -s <id> on the first launch, and resumed once ~/.grok/sessions/<URL-encoded worktree>/<id>/ exists.

help: -s, --session-id is for a new conversation only; the resume path is covered by tests, not yet by a live run

codex

codex resume <id>

Not captured. No launch flag sets the id; record it with convoy seat --resume <id>.

help

cursor-agent

cursor-agent --resume <id>

Not captured. No launch flag sets the id; record it with convoy seat --resume <id>.

help

agy

agy --conversation <id>

Not captured. No launch flag sets the id; record it with convoy seat --resume <id>.

help

hermes

hermes --resume <id>

Not captured; record it by hand as above.

help

pi

pi --resume

Not captured. --resume opens a session picker; resuming a given id directly is unverified.

help

What is being improved:

  • capturing the harness's own id automatically for every harness, not only the two that let Convoy choose it at launch;

  • recording each harness's resume command, and the evidence for it, in the harness contract.

How it works

  1. Start Convoy on your machine (convoy mcp) and attach http://127.0.0.1:8788/mcp.

  2. Run onboard with harnesses you already installed.

  3. Bind one thread at --root; Convoy writes/reads one durable convoy_id.

  4. Use send for synapses. A send that names a live seat queues the body (delivery: queued, delivered: false); it does not type into the TUI and does not spawn a second --resume. Codex may use codex queue. Drain with convoy inbox --drain or the project hook convoy inbox --hook-pretooluse (Grok PreToolUse, Claude PreToolUse + UserPromptSubmit). Hook files never bake an absolute interpreter path.

  5. Use bring_up / open only when you want visible interactive TUIs for seated neurons.

End-to-end example

# (after `python -m pip install .`, see Install)
# 1) Name installed harnesses and bind this root to one thread
convoy onboard --to grok --to claude --to codex --thread demo

# 2) Register seated neurons (session key + optional vendor resume token)
convoy seat --to grok --session-id seat-grok --worktree ../wt-grok --model gpt-5.6-sol --resume vendor-grok-uuid
convoy seat --to codex --session-id seat-codex --worktree ../wt-codex --resume vendor-codex-uuid

# 3) Dry-run bring-up shows native argv (Codex uses "resume" subcommand)
convoy bring-up --dry-run

# 4) Headless synapse (safe default)
convoy send --to claude "Summarize open payment retry bugs and propose a fix plan."

# 5) Optional live headless run in a fresh native session (no resume token)
convoy send --to codex --live "Draft unit tests for the retry planner."

Development: the supported source-checkout test entrypoints are PYTHONPATH=src python test/run.py (PowerShell: $env:PYTHONPATH='src'; python test/run.py) and python -m unittest from the checkout (for example, python -m unittest discover -s test/demo -p '*_test.py'). Both discover test/demo/*_test.py and isolate CONVOY_HOME under the OS temporary directory, even if it was set to a non-temporary path. Other test adapters, including direct top-level discovery by IDEs or pytest, must be given their own throwaway CONVOY_HOME; their startup shapes are not covered by this guard. License: MIT.

Related MCP Connectors

Related MCP Servers