One CLI for every LLM. Drive Claude, GPT, Gemini, DeepSeek, and Grok from a single binary — then connect them into a coordinating mesh where agents from different vendors talk to each other through a shared protocol, each staying itself.
pip install swarph-cli
swarph --versionWhat it is: a multi-provider LLM command line. Run one-shot prompts or an interactive REPL against any supported provider; spawn long-lived agent cells that persist across restarts and coordinate over a mesh; install hooks, MCP servers, and skills by content-addressed URI. An open, inspectable substrate — not a closed orchestration platform.
Who it's for: builders running more than one LLM who want them to cooperate instead of sitting in separate tabs — multi-agent systems across vendors, agnostic by design. A thin client over the swarph-mesh substrate library.
This is one of three repos in the v0.3.x architecture:
| Repo | Role |
|---|---|
swarph-mesh |
Substrate Python package — Protocol + adapters + SwarphCall + MeshClient. Pure library, no CLI |
swarph-cli |
This repo — the swarph binary |
swarph-meshlm |
Simon Willison llm plugin |
swarph "prompt" one-shot against any provider (claude/openai/gemini/deepseek/grok)
swarph chat interactive REPL — multi-turn, slash commands, live provider switch
swarph spawn <role> launch a long-lived agent session as a named mesh cell
swarph daemon foreground inbox-drain loop (the mesh doorbell)
swarph watchdog detect + recover stranded agent sessions (cron- or systemd-driven)
swarph add <uri> install a hook / MCP server / skill by content-addressed URI
swarph hooks install Claude Code hooks as reusable artifacts
swarph onboard / ratify bring a new peer into the mesh (mechanics + witness ratification)
swarph import <path> port a session from another CLI into swarph-native format
Each verb is documented below.
Wraps claude with three flags that make a session a stable, resumable named cell:
--name <role>— display name for/resumepicker--session-id <uuid>— pinned UUID, persisted to$XDG_STATE_HOME/swarph/sessions/<role>.session-idso re-spawns reuse the same session--append-system-prompt <text>— starter prompt injected without manual paste
# 1. Author a cell.yaml (one-time per role)
$ cat ~/.config/swarph/cells/researcher.yaml
schema_version: v1
name: researcher
role: researcher
cwd: ~/work
starter_prompt_path: ~/.claude/session_start_reminder.txt
provider: claude
# 2. Summon the cell (long-lived claude session, exec-replaced)
$ swarph spawn researcher
╭───╮
│ ◉ │
╭──┴───┴──╮
│ swarph │
╰──┬───┬──╯ spawn │ chat │ daemon
│ ◉ │
╰───╯
[claude session takes over the terminal — same flags as `claude --name researcher --session-id <uuid> --append-system-prompt <starter>`]
# 3. Resume the same cell after exit — same UUID, same session
$ swarph spawn researcher # picker shows ONE entry: "researcher" (stable disambiguation)Resolution order for swarph spawn <role-or-path>:
--onboarding <path-or-url>(alias:--cell) — explicit override- Positional ending in
.yaml/.ymlor containing a path separator — literal path - Plain role name —
$XDG_CONFIG_HOME/swarph/cells/<role>.yaml(default~/.config/swarph/cells/) - No positional given — auto-discover
./cell.yamlin current directory
Useful flags:
| Flag | Effect |
|---|---|
--dry-run |
Print resolved claude command + cell summary; do not exec |
--no-starter |
Skip starter-prompt injection even if cell.yaml sets one |
--print-id |
Print resolved session-id to stdout (capture for shell scripts) |
--no-banner |
Suppress the swarph banner on stderr |
-- <claude-args> |
Pass remaining args through to claude unchanged |
cell.yaml schema is frozen at schema_version: "v1". v0.7 migrates the parser to swarph-shared as a symbol-relocation only — v0.6 cell.yaml files keep working unchanged. Breaking changes require a schema_version: "v2" bump and parallel-supported-version window per swarph-mesh DEPRECATIONS discipline.
Known limitations (v0.6). Single-instance-per-role only. Re-running swarph spawn <role> reuses the persisted UUID (R5 fix), so sibling-spawn (alpha + beta co-existing on the same peer-id) requires v0.7's --new-instance flag. Manual sibling spawning via tmux + explicit --session-id pinning still works unchanged; v0.6 does not regress that path, it just doesn't yet expose a CLI shape for it.
Replaces the 4-layer tail -F | grep | Monitor | systemd | cron poll stack with one foreground process. Liveness check collapses to:
ps aux | grep '[s]warph daemon' # zero output = monitoring is down$ swarph daemon --state-dir ~/swarph_state/researcher --self researcher
[swarph-daemon] starting: self=researcher gateway=http://localhost:8788 poll=30s ...
[2026-05-08T21:00:30Z] id=728 from=alice kind=answer → 'review on the two PRs looks good...'
[2026-05-08T21:01:10Z] id=729 from=alice kind=fyi → 'both PRs merged...'
^C
[swarph-daemon] signal 2 received — draining + flushing cursor
[swarph-daemon] shutdown: iterations=12 dms_seen=2 cursor.last_msg_id=729Loud-on-down: never silently exits. Cursor writes are atomic (write-and-rename — corrupted mid-flush leaves the previous cursor intact). Backoff: 60s after 5 consecutive empty polls; 300s after 5 min of consecutive 5xx. SIGINT/SIGTERM trigger clean drain + flush.
--auto-act flag is documented for v0.5.1+ when handler registration via @swarph.on_dm(...) lands; v0.5.0 ships surface-only mode (DMs printed + JSONL-logged to inbox.log, no automatic replies).
Detects stranded Claude sessions (API throttle / harness death) via cursor-mtime + tmux pgrep AND-gate, and recovers via A1 tmux send-keys wake-prompt → A2 swarph spawn respawn. Cell.yaml-pinned cursor + tmux session (F4) since v0.7.2.
One-shot mode (cron-callable, v0.7+):
*/5 * * * * swarph watchdog --check --cell lab >> ~/.local/log/swarph-watchdog.log 2>&1Systemd timer install (v0.7.3+):
# Preview without writing (any user):
swarph watchdog --install-service --cell researcher --dry-run
# Install + enable (requires root for /etc/systemd/system writes):
sudo swarph watchdog --install-service --cell researcherThis writes three files:
| Path | Purpose |
|---|---|
/etc/systemd/system/swarph-watchdog.service |
Type=oneshot, runs swarph watchdog --check |
/etc/systemd/system/swarph-watchdog.timer |
Fires every 5 minutes (OnUnitActiveSec=5min) |
/etc/default/swarph-watchdog |
Sets SWARPH_CELL=<role> for the service env |
Then runs systemctl daemon-reload && systemctl enable --now swarph-watchdog.timer. Idempotent — re-running overwrites with current package version (newer-version semantics).
Monitoring:
systemctl status swarph-watchdog.timer # is it scheduled?
systemctl list-timers swarph-watchdog.timer # next fire?
journalctl -u swarph-watchdog.service -f # live log
tail -f /var/log/swarph-watchdog.log # append-log alternativeWhy this matters: a long-running agent session can go silent after an API throttle or a harness death, and you won't notice until you go looking. The watchdog turns that into a self-healing loop — and the systemd install path means any host gets it with one command instead of hand-rolled cron.
Cross-host throttle-recovery wake (--dm-wake, "mesh-monitor mode"):
A1 (local tmux send-keys) and A2 (respawn) can only recover a cell on the watchdog's own host. --dm-wake adds the cross-host complement: the watchdog also scans the gateway /peers list, finds peers whose last_health is stale (throttle-stranded sessions on other hosts), and sends each a wake DM (kind="fyi") via the gateway /messages. The wake chain is watchdog → wake DM → target peer's sidecar/inbox-watcher → tmux send-keys wakes that session. Reuses the same --gateway URL + MESH_GATEWAY_TOKEN and the same --threshold staleness window as the local check.
swarph watchdog --check --peer researcher --gateway http://localhost:8788 --dm-wake --dm-wake-cooldown-sec 1800--dm-wake-cooldown-sec SEC(default1800/ 30 min) — no-spam gate: each stale peer is DM-woken at most once per window, so a peer that stays stale across many ticks is woken once, not every tick. Per-peer cooldown state lives at$XDG_STATE_HOME/swarph/dm_wake_state.json(falls back to~/.local/state/swarph/dm_wake_state.json).
Scope honesty (v1): the wake DM is wake + re-drain — the woken cell drains its inbox and resumes work; it does not resume the exact throttled in-flight task (per-cell task-checkpointing is future scope).
Exit codes:
| Code | Meaning |
|---|---|
0 |
no action (session healthy / no unread DMs queued) or install ok |
1 |
A1 fired (local tmux send-keys wake-prompt) |
2 |
A2 fired (local full respawn) |
3 |
watchdog acted/couldn't-read — either a cross-host wake DM (A1-DM) fired this tick (local was a no-op) or detection error (cursor unreadable / gateway unreachable). Both map to 3. |
4 |
configuration error (invalid args, no cell.yaml resolved); install needs sudo |
5 |
install error (file write failed / systemctl failed) |
Deploy: run --dm-wake on whichever always-on host you want to act as the mesh monitor — it watches every peer's health, not just its own, so one monitor covers the mesh.
Installs Claude Code hooks as content wired into ~/.claude/settings.json — a hook becomes an installable artifact (a script + its event/matcher bindings merged into your settings) with no swarph-cli version bump per hook, the same way watchdog --install-service ships systemd units as bundled data.
swarph hooks init # install the recommended bundled set (cell-resilience)
swarph hooks add cell-resilience # install one builtin by name
swarph hooks add ./my-hook # install a local bundle dir (hook.json + script)
swarph hooks list # builtins + install status (installed|available)
swarph hooks remove cell-resilienceTrust model. Three tiers: builtin (trusted, bundled with swarph-cli — installs without a prompt), local (a bundle dir you point at — shown then confirmed before any write), and published/@cell/name (fails closed in v1 — never installs another cell's unreviewed code). Signed-publisher identity plus a publish-time security gate is the v2 model.
Bundled cell-resilience. Binds StopFailure/rate_limit + Stop/(all) to a script that writes $XDG_STATE_HOME/swarph/idle_since.json ({"session","reason","hook_event","ts"}, reason=throttle|normal) — the push-side throttle detector the watchdog's --dm-wake can read instead of polling. Observational only: it never blocks the session and always exits 0 (jq if present, printf/sed fallback otherwise).
Activation caveat. A freshly-installed hook does not go live in the current session — Claude Code can't hot-load it. Reopen /hooks (or restart the session) once to activate.
The unified, typed install verb over the swarph commons — one command installs any commons artifact, routed by class. Where swarph hooks add installs only hooks, swarph add takes a single content-addressed URI and dispatches to the right per-class installer.
The URI ("magnet link"). An artifact is named by swarph://<class>/<publisher>/<name>[@<version>][#<sha256>]. The four classes are hook / mcp / skill / tool. The optional #sha256 is content-addressed: it pins the exact bytes of the artifact, so the install is tamper-evident and verifiable from any cell that serves the same content — the BitTorrent-magnet property (the URI, not a trusted host, is the source of truth).
swarph add swarph://hook/swarph-builtin/cell-resilience # install a builtin hook
swarph add swarph://mcp/swarph-builtin/everything # install the reference MCP server
swarph add swarph://skill/swarph-builtin/swarph-intro # install a builtin skill(tool is not yet implemented — it bridges to swarph-mesh's adapter registry as a follow-on.)
Trust model (v1). Builtin publishers (swarph-builtin) install; any other publisher fails closed — a published/untrusted URI never installs another cell's unreviewed code. Signed-publisher identity plus a per-class publish-time security gate is the v2 model. When a URI carries #sha256, the resolved artifact is hash-verified and refused on mismatch — nothing is written.
Content-addressed, not host-addressed. A swarph:// URI resolves to the artifact, not to a particular server: the CLI fetches it from any cell or registry that publishes it and hash-verifies against #sha256, so the same artifact can be served from anywhere — the BitTorrent-magnet property. Today the URI is copy-paste; a swarph:// OS protocol-handler for click-to-install — the way magnet: opens a torrent client — is a future UX layer.
Activation. Like hooks, freshly-installed hooks and skills are not hot-loaded into the running session — reopen /hooks (or restart the session) once to pick them up.
Onboarding splits into a mechanics phase (swarph onboard) that automates the boring parts (registry POST, scaffolding, token resolution) and a manual contract phase (the new peer composes the handshake DM in their own words). A witness peer judges the handshake and runs swarph ratify <peer> to flip ratified=true, gating task_claim server-side.
# New peer self-onboards
$ swarph onboard newpeer
[1/6] validate_node_name('newpeer') ok
[2/6] prepare peer-registry row ok
[3/6] resolve MESH_GATEWAY_TOKEN ok
[4/6] POST .../peers/register ok (registered_unratified=true)
[5/6] verify_subscription_setup() ok
[6/6] scaffold ~/swarph_state/newpeer/ ok
[manual] handshake template at /tmp/newpeer-handshake.md
Edit each section in your own words, then send to your witness peer.
# After peer composes + sends handshake, witness ratifies
$ SWARPH_WITNESS=alice swarph ratify newpeer \
--reason "handshake covers all four invariants in own words"
[1/6] validate_node_name('newpeer') ok
[2/6] verify witness 'alice' is ratified ok
[3/6] verify 'newpeer' is registered_unratified ok
[4/6] PATCH .../peers/newpeer ok
[5/6] verify peer_ratifications audit row ok (id=N reason='...')
[6/6] invalidate local TTL cache okServer-side gating (mesh-gateway PR A): unratified peers can read inbox + send DMs (so the handshake itself works) but task_claim returns 403. Witness must itself be ratified — no self-ratification, no unratified-witnesses-ratifying-others. Audit log (peer_ratifications) is append-only.
Interactive REPL against any of the five swarph-mesh adapters (gemini / deepseek / claude / openai / grok). Multi-turn conversation history accumulates in-memory; cumulative session cost + token totals tracked.
$ swarph chat --provider claude
swarph chat — Phase 5 REPL
provider=claude model=(adapter default) caller=cli.repl.ubuntu
Type a message and press Enter to send. Slash commands:
/help /clear /system /provider /model /history /cost /quit
Ctrl-D to exit.
> hello
Hi! How can I help...
# 8+12t $0 0.34s
> /provider gemini
[switched to provider=gemini; model reset to adapter default; history cleared]
> /cost
[turns=1 in=8 out=12 cost=$0]
> /quit
[swarph-chat] bye.Slash commands:
/help— print available commands/quit,/exit(or Ctrl-D) — exit/clear,/reset— clear history (keeps system prompt)/system [prompt]— set or clear system prompt/provider <name>— switch provider (resets history)/model <name>— switch model/history— print running message list/cost— cumulative session cost + tokens
Out of scope until Phase 5.6 (swarph daemon): inbox drain coroutine, /inbox and /reply slash commands. Streaming output ships alongside the cross-adapter stream() work in v0.5+ of swarph-mesh.
Session import is the knowledge half of onboarding — gives a memory-carrying peer (or a human migrating between CLIs) the substantive context they're bringing into the swarph, paired with the contract half (the handshake DM acknowledging the four invariants).
# Inspect what would be imported (lossy → honest framing)
$ swarph import ~/.claude/projects/.../X.jsonl --report-only
# Commit — writes ~/.swarph/sessions/<session-id>.jsonl
$ swarph import ~/.claude/projects/.../X.jsonl
# Refuse-with-error if target exists (protects continuation turns)
$ swarph import same-source.jsonl
swarph import: target /home/.../X.jsonl already exists (...)
To proceed:
--force overwrite (destroys continuation turns)
--target-session NAME write to a different fileWhat ports cleanly: plain user/assistant/system text, role tags, conversation order.
What's lossy (counted in report, kept as visible text where possible):
thinkingblocks (Anthropic-specific reasoning trace)tool_useblocks (call shape doesn't port across providers)tool_resultblocks (companion drop withtool_use)
What's dropped: attachments (would need re-upload), provider-side KV cache, conversation IDs, cache_control annotations.
Honest framing: teleport is "import + continue", not "freeze and resume" — the first turn after import on a new provider pays cold-cache cost.
$ swarph "say pong" --provider gemini
Pong!
# 3+26t $0.0000 0.73s caller=cli.oneshot.ubuntu provider=geminiCompress a machine-read context surface (memory index, manual, agent brief) to
reclaim always-loaded space/tokens. The principle: fluent natural language carries
redundancy a model infers for free — the decompressor is the model itself. Proven
by hand on the OMEGA swarm: MEMORY.md 37→16KB (58%), CLAUDE.md 241→21KB
always-loaded (91%, archival).
Opt-in by marker (fails safe). A file is compressible only if it carries an explicit marker; unmarked files are left untouched. The model's judgment is spent once, in-session, authoring the marker — runtime is pure-Python marker parsing, no model in the hot path.
<!-- swarph:compress lever=archival boundary="^## Session" -->
<!-- swarph:compress lever=shorthand pointer="](*.md)" floor=0.45 -->
Two levers, different risk classes:
| Lever | What | Loss class | Model? |
|---|---|---|---|
archival |
relocate the cold tail below boundary to <file>.archive.<ext> + leave a pointer |
lossless (nothing destroyed) | no — pure Python, zero tokens |
shorthand |
rewrite a pointer-bearing INDEX to telegraphic shorthand | lossy, bounded to index-over-preserved-source (recoverable by construction) | yes (claude -p subscription path) |
Shorthand is gated: redundancy-floor (refuse if already dense), links-superset
(every []() survives), index-over-source (every entry keeps a resolvable
pointer), and an adversarial verify-expand (an independent model hunts for a
dropped fact; one found → abort).
swarph compress MEMORY.md # dry-run: classify, propose, report savings
swarph compress MEMORY.md --apply # write (atomic tempfile→mv + .bak + verify-gate)
swarph compress MEMORY.md --verify-idempotent # assert compress(compress(x)) ≈ noopDry-run is the default — nothing mutates without --apply. Cron-friendly exit codes:
| Code | Meaning |
|---|---|
0 |
analyzed/savings-reported (or applied clean) |
2 |
no such file |
3 |
refused — unmarked (leave breathing) |
4 |
refused — archival: no boundary line matched |
5 |
refused — shorthand: below redundancy floor (already dense) |
6 |
refused — shorthand dropped a link / lost a pointer-to-source |
7 |
refused — adversarial verify-expand found a dropped fact |
8 |
refused — not idempotent (second pass kept cutting; signal-eating alarm) |
Design spec: docs/superpowers/specs/2026-06-11-swarph-context-compressor-design.md
(in the hedge-fund-mcp repo).
--json is a harness trigger, not a strict-validation gate. When set, swarph routes the response through the swarph-mesh JSON harness:
- A permissive
{"type": "object"}schema is synthesised when--schemais absent (Phase 5+ adds Pydantic validation). - The harness retries once with
[USER]-turn feedback on parse failure. - Malformed-JSON exits with code 1 + raw text on stdout for caller recovery. Useful for shell scripts:
if swarph "give me a trade" --json; then # parsed dict was on stdout ... fi
- Pretty-printed parsed dict on stdout when parse succeeds;
error_class=malformed_jsonshows up in the stderr attribution footer when it doesn't.
The swarph-mesh library is imported by any program that wants to drive the mesh against the Protocol directly — orchestrators, judges, automation. Those callers don't need the CLI surface or the console-script entry point. Keeping the CLI in a separate repo means library users pip install swarph-mesh without pulling argparse + REPL plumbing they'll never run, while pip install swarph-cli gives you the standalone swarph binary.
git clone https://github.com/BrainSurfing-tech/swarph-cli
cd swarph-cli
python -m venv venv && source venv/bin/activate
pip install -e ".[dev]"
pytest
swarph --versionMIT. Pierre Samson + Claude Opus, 2026.