herdr-spawn
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@herdr-spawnspawn claude on production-vm with prompt: investigate the memory leak"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
herdr-spawn
An MCP server with one tool. spawn_agent hands a prompt you composed in a chat to a
real coding agent on one of your own machines. With Claude Code that means Remote
Control is on and you get back a link that opens the session in the Code tab; with the
other agents you get a live pane you take over in the terminal.
The chat does what a chat is good at — thinking the task through with you — and then the actual work happens in a real terminal, on a real checkout, with your real credentials, on the machine that has them. You keep the conversation; the agent keeps the filesystem.
Built on herdr, a terminal workspace manager for AI coding
agents, which supplies the workspaces, tabs, panes and the agent start /
agent prompt verbs this drives.
What it does
spawn_agent(host, prompt, [agent], [cwd], [label], [session_name], [workspace], [wait])
-> {status, host, agent, session_name, workspace_id, tab_id, pane_id, cwd,
session_id, agent_status, remote_url | continue_with}Preflight — host enabled, the agent available there,
cwdresolves under an allowed root and is already trusted by that agent, herdr answering on that host. Nothing is created until all of it holds: a failure after a tab exists would leave an orphaned pane sitting on a dialog, which is worse than a clean refusal.Find or create the
inboxworkspace and a tab in it,--no-focus.Start the agent in the tab's pane, with that agent's own flags.
Resolve the claude.ai Remote Control id — Claude Code only.
Append a record to
~/.local/state/herdr-spawn/spawns.jsonl.Submit the prompt, under a per-pane lock.
A good spawn comes back in a few seconds:
{
"status": "spawned",
"host": "xl",
"agent": "claude",
"session_name": "chat-login-fix-0821-1642",
"cwd": "/home/you/scratch/webapp",
"remote_url": "https://claude.ai/code/session_01ABC…"
}— plus the workspace, tab and pane ids, so you can always find the terminal it landed in. A spawn never steals focus. Nothing is ever closed that this service did not create. And once the tab exists, a failure returns its coordinates and the pane tail rather than cleaning up silently — a visible broken tab beats a vanished one.
Prompts over 2 000 characters are written to <cwd>/.herdr-handoff/<session>.md and
handed over as an @-reference instead — terminal injection of multi-kilobyte text is
fragile, and the file doubles as a record of what was actually asked.
Related MCP server: cursor-agent-bridge
Install
Requires Python 3.13+, uv, and herdr on every host you enable; Claude Code wherever you want the claude.ai link.
git clone https://github.com/nytafar/herdr-spawn.git ~/mcp/herdr-spawn
cd ~/mcp/herdr-spawn
uv sync
mkdir -p ~/.config/herdr-spawn
cp config.toml.example ~/.config/herdr-spawn/config.toml
$EDITOR ~/.config/herdr-spawn/config.toml # at minimum: paths + cwd_allowlist~/.config/herdr-spawn/config.toml is the only place your machine names, addresses
and paths live. It sits outside the repo on purpose, and the repo's .gitignore also
refuses a stray copy of it.
Then either use it locally over stdio:
claude mcp add herdr-spawn -- uv run --directory ~/mcp/herdr-spawn python -m herdr_spawn.serveror run it as a service and put it behind a gateway — see below.
Before a host will work
Two things must already be true on any host you enable, and neither is something this service can do for you:
herdr is running there.
herdr workspace listmust print JSON.the cwd is trusted by the agent you will start. Start it there once by hand and accept the trust dialog. An untrusted directory does not fail — the spawned pane just sits on that dialog forever, looking alive and doing nothing. Preflight reads the agent's own trust store (
~/.claude.jsonfor Claude,~/.codex/config.tomlfor Codex, a trusted parent covering its children in both) and refuses rather than letting that happen. An agent whose store it does not know cannot be pre-empted; the pane tail is then the only warning.
Agents
herdr can drive 21 coding agents today, and the set grows between releases — 0.8.2
added a kind 0.8.0 did not have. So the supported kinds are parsed from
agent start --help at startup rather than hardcoded, each enabled host is asked which
of those are on its PATH, and the agent enum advertised to callers is the union of
the answers. Agents nobody has installed are not advertised: the schema is paid for on
every request forever, and they could only ever be refused. On this fleet — three
hosts, four agents — the entire tools/list payload comes to 875 characters, roughly
215 tokens, which is what every conversation pays for having the tool enabled whether
or not it spawns anything.
The per-host matrix stays out of the schema entirely, because it is the single most expensive thing you could put there and it changes whenever you install something. Instead a wrong guess gets a refusal that carries the answer:
'devin' is not installed on xl. Available there: claude, codex
herdr on xl cannot start 'dsh'. It supports: agy, amp, claude, cline, codex, …Two different problems, said differently — one is a missing binary, the other is an agent this herdr has never heard of.
A host that was asleep at startup comes back as unknown rather than empty, and unknown permits everything: a closed laptop must not have its agents hidden until the next restart. The spawn goes through and the real error comes back from the real attempt.
Only Claude Code returns a link. Nothing else has a claude.ai bridge — no session
registry, no bridgeSessionId, nothing to link to. Other agents return
continue_with: "herdr agent prompt <session_name>" rather than a null remote_url
that reads like a failure, and the RC poll is skipped entirely rather than burning 15
seconds reaching a foregone conclusion.
Per-agent quirks live in agents.py, and there are only three that matter: the launch
flags (Claude's --dangerously-skip-permissions is Codex's
--dangerously-bypass-approvals-and-sandbox; an agent with no profile gets no
flags rather than someone else's dangerous one), where each keeps its trust store, and
whether it has Remote Control. Adding an agent is a few lines there; an agent with no
profile at all still starts fine.
Remote hosts, over ssh
A transport = "ssh" host runs the herdr CLI on the far side. There is no socket
forwarding and no agent installed over there — if this works, so does everything else:
ssh -o BatchMode=yes <ssh_target> /path/to/herdr workspace listBatchMode=yes is the real test: authentication must be non-interactive, because
nothing here can answer a passphrase or a host-key prompt. Point ssh_target at a
Host alias from your ~/.ssh/config and let ssh resolve the address, identity and
options — an alias whose host key you have already accepted fails closed if it ever
stops resolving, which is what you want from a target that starts agents.
Everything else follows: herdr_bin and home describe the remote filesystem
(/opt/homebrew/bin/herdr and /Users/you on a mac), and cwd_allowlist is checked
against remote paths. Long prompts are written to the handoff file on the remote side
too. Both preflight checks and the Remote Control lookup happen over the same
connection, so a remote spawn returns the same claude.ai/code link a local one does.
ssh's own failures are reported as themselves — 255 as a connection failure naming
the target, 127 as a missing herdr_bin — rather than as a herdr error, so you are
not sent debugging a herdr server that was never contacted.
Configure
Every [hosts.<name>] table becomes a value of the tool's host parameter — the enum
is built from your config at startup, so nothing about your machines is in the source.
Hosts you define but leave enabled = false are still advertised, deliberately: a
refusal that names the missing prerequisite is more useful to a calling model than the
value simply not existing.
The settings that matter:
Key | Why it matters |
| The blast-radius control. A spawn's cwd must resolve under one of these roots — fully resolved, so |
| Where a spawn lands when the caller names no cwd. Must itself be under |
| Never closed, never spawned into. |
| Per-host only, never a tool parameter. An unattended agent with permissions skipped, started from a chat message, is the one combination that can quietly do real damage. Applied with each agent's own flag, and not at all for agents whose flag isn't known. |
See config.toml.example for the full annotated set.
Run it as a service
cp deploy/service.env.example ~/.config/herdr-spawn/service.env
$EDITOR ~/.config/herdr-spawn/service.env # port, bind, docker network
cp deploy/herdr-spawn.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now herdr-spawn
loginctl enable-linger "$USER" # survive logout and rebootIt must run as your own uid, via systemctl --user — not as a system unit and not
as a dedicated service user. The herdr socket is srw------- inside a 0700 home and
~/.claude/sessions is drwx------; no other uid can reach either. The unit uses
systemd's %h specifier throughout, so it carries no absolute paths.
deploy/run.sh binds a single private address. Set HERDR_SPAWN_BIND explicitly, or
set HERDR_SPAWN_DOCKER_NETWORK and let it discover that network's bridge IP at start
(compose files rarely pin the IPAM subnet, so a hardcoded address can go stale). If
discovery fails it falls back to loopback, never 0.0.0.0 — this tool starts
autonomous coding agents, so anything that can reach the port can start one, and on a
host without a firewall the bind address is the entire security boundary.
Putting it behind an MCP gateway
This is the part that turns it from a local tool into something a chat on your phone can
call. The pattern below is written against
agentic-community/mcp-gateway-registry
— a self-hosted registry that fronts many backend MCP servers behind one OAuth-gated
origin, so each backend ends up at https://gateway.example.com/<path>/mcp — but steps
1, 2 and 6 apply to any HTTP MCP gateway.
The shape to keep in mind: your backend never faces the internet. It binds a private address the gateway can reach, and the gateway owns TLS, OAuth and per-group authorisation at the edge.
1. Speak streamable HTTP, statelessly
HERDR_SPAWN_TRANSPORT=streamable-http HERDR_SPAWN_BIND=<bridge-ip> HERDR_SPAWN_PORT=8230Run stateless. Gateways commonly proxy each MCP request independently, and an older
proxy hop may drop the Mcp-Session-Id header entirely — a stateless server does not
care. A fire-and-forget spawn needs no session continuity anyway.
2. Bind where the gateway can reach you, and nowhere else
If the gateway runs in Docker and your server does not, bind that compose network's bridge gateway IP:
docker network ls # find the network name
docker network inspect <network> -f '{{(index .IPAM.Config 0).Gateway}}'Containers on that network reach it; the LAN and your VPN do not. Verify from both sides — the second command is the one that matters:
docker exec <a-gateway-container> curl -sf http://<bridge-ip>:8230/mcp -X POST \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}'
curl -sf --max-time 5 http://<your-lan-ip>:8230/mcp # MUST failIf your server runs in Docker too, skip all of this and just join it to the gateway's
network — then the target is http://<container-name>:8230/mcp.
3. Get an admin token
Management calls take an OAuth2 client-credentials (M2M) token from the registry's
identity provider — Keycloak by default; the bootstrap script writes the client secret
to .oauth-tokens/<realm>-m2m.json. Know the ceiling before you plan around it: an
M2M client can register and toggle, but not patch or remove — fixing a bad record
means editing the registry's MongoDB directly and restarting the registry container.
Get it right the first time.
4. Register the backend
{
"server_name": "herdr-spawn",
"description": "Start a Claude Code session on one of my hosts and hand it a prompt.",
"path": "/herdr-spawn",
"proxy_pass_url": "http://<bridge-ip>:8230/mcp",
"auth_scheme": "none",
"tags": ["agents", "automation"],
"num_tools": 1
}cd <gateway-checkout>
uv run python api/registry_management.py register --config /path/to/herdr-spawn.json
uv run python api/registry_management.py list # confirm it is thereThree details that each cost an hour if you get them wrong:
proxy_pass_urlmust end in/mcp— the health check only initialises an MCP session when it does; without it the server sits permanently unhealthy with no useful error.Leave
mcp_endpointunset — setting it yields a broken relative path.auth_scheme: "none"means no auth between gateway and backend — correct here, since only the gateway can reach it. The edge OAuth is still fully in force; it does not mean the tool is public.
5. Enable it, and grant it explicitly
Registration is not enablement:
curl -X POST https://gateway.example.com/api/servers/toggle \
-H "Authorization: Bearer $TOKEN" \
-d 'path=/herdr-spawn' -d 'new_state=enabled'Then authorise it in auth_server/scopes.yml — and read what is already there first: a
group with server: '*', tools: ['all'] grants every new server the moment it
registers, including this one. For a tool that starts autonomous agents that is worth
a deliberate decision, not a default you inherit. Narrower:
my-group:
- server: herdr-spawn
methods: [tools/list, tools/call]
tools: [spawn_agent]6. Verify the edge, both ways
curl -s -o /dev/null -w '%{http_code}\n' https://gateway.example.com/herdr-spawn/mcp # expect 401
curl -s https://gateway.example.com/herdr-spawn/mcp -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'The unauthenticated 401 is the half people skip, and it is the half that matters.
7. Audit what the gateway does not
Gateways typically log tool names but not arguments — the prompt a caller
actually sent is nowhere in the gateway's logs. spawns.jsonl holds it only as a
SHA-256 (the body survives only when it was long enough to become a handoff file).
Know which record you want before you need it.
And look at what else the gateway publishes on the host: an internal proxy or admin
port on 0.0.0.0 can be an unauthenticated path straight past the OAuth edge to your
backend. Enumerate with ss -ltn and confirm every published port either requires
auth or is bound to loopback — before you point a tool that starts agents at it.
Things that are the way they are for a reason
No --until on agent prompt. Passing it replaces herdr's default match set
(idle, done, blocked) rather than adding to it, dropping done — which is
exactly the state a backgrounded spawn settles on. The documented
--until idle --until blocked hangs until the full timeout while the agent sits
there having already answered. Bare --wait returns in seconds.
A lock per pane, held across submit+settle. Two agent prompt calls issued
back to back concatenate into one submitted line and lose the first Enter —
and both still return rc=0. For a chat-driven service, two messages inside one
second is ordinary traffic. The tests assert two rapid prompts produce two turns.
The remote poll runs remotely. Waiting for bridgeSessionId takes up to 15 s at
200 ms intervals — from this side that would be 75 ssh handshakes, so the loop is sent
over and returns once. And the prompt reaches the remote herdr through shlex.join:
quotes, backticks, $ and newlines arrive as one argument and never reach the remote
shell as syntax.
No pane scraping for the Remote Control id. Claude Code writes
bridgeSessionId to ~/.claude/sessions/<pid>.json roughly 1.2 s before
herdr agent start even returns. Scraping the banner works at default pane width and
fails below ~123 columns, where Ink hard-breaks the line. The registry read has no
race, no regex, and no width dependence — but it is undocumented internals, so every
spawn records the observed Claude Code version and the resolver warns when it changes.
"Ready" does not mean "working", and no preflight can fix it. A spawned Codex
reported interactive_ready: true, then idle, then done — and was entirely
non-functional: "Your access token could not be refreshed." Worse, codex login status cheerfully answered "Logged in using ChatGPT" while the stored token was
five weeks stale and failing to refresh. So an auth preflight built on each agent's own
status command would confidently report health that does not exist. With one daily-driver
agent stale credentials are rare; across several agents on several machines they are the
normal failure. The honest answer is that the pane tail is the only ground truth, which
is why every non-clean outcome returns one.
Agents update themselves and exit. A first-run Codex printed "Update ran
successfully! Please restart Codex" and quit — after agent start had already reported
it ready. The retry succeeded. Nothing here can prevent that; it is worth knowing that a
spawn's first attempt on a long-idle host may land on an empty shell.
The pane's Claude process is not foreground_processes[0]. Claude spawns children —
caffeinate, one process per configured MCP server — and they sort ahead of it: on a
real pane it came back last of seven, behind caffeinate. Reading the session
registry for a child's pid finds no record, so Remote Control reports itself unavailable
on a spawn whose bridge connected perfectly well. The foreground process group id is
the job leader, which is claude. (argv0 is the second opinion; name is no help — it
holds the version string.)
TZ=UTC on the remote ps. The pid-recycle guard compares the session record's
procStart against the live process. On macOS that field is a ctime string rather than
Linux clock ticks — and Claude Code writes it in UTC while ps -o lstart= prints
local time. Anywhere off UTC the two differ by exactly the offset, and the guard
rejects every good spawn with "pid reused". The remote probe also picks its format per
platform: /proc/<pid>/stat where that exists, ps where it does not.
RC failing is not the spawn failing. bridgeSessionId only appears once the
bridge connects. Offline, unauthenticated or rate-limited, it never does — and
agent start still reports interactive_ready. That returns rc_unavailable with
the pane details rather than failing: a local Claude Code session with no phone
handle is still a working session.
Absolute path to the herdr binary, HOME always set. A systemd --user unit's
PATH has no ~/.local/bin, and with a stripped environment herdr silently falls
back to /tmp/herdr/herdr.sock and reports server_not_running — which blames the
wrong thing entirely and sends you debugging a healthy server.
rc=2 is plain text. herdr returns JSON on stdout at rc=0 and JSON on stderr at
rc=1, but rc=2 (e.g. an unsupported --kind) prints a bare sentence. A wrapper that
unconditionally json.loads(stderr) crashes there instead of reporting the problem.
The advertised tool schema is slimmed after generation. Pydantic emits a title
for every field and expands str | None into a two-armed anyOf; neither tells a
calling model anything it cannot read off the parameter name, and together they are
about a quarter of the whole definition. Collapsing the union makes the advertised
schema stricter than what is actually accepted, which is the safe direction: a
client that omits the parameter still works, and one that sends null still validates.
The slimming edits parameters (what clients see), never fn_metadata (what
validates), so the two cannot desync — and if a future SDK moves the attribute, it
logs and serves the verbose schema instead.
Layout
src/herdr_spawn/
config.py host registry, cwd allowlist, path-traversal-safe resolution
agents.py per-agent launch flags and trust stores; per-host availability discovery
herdr.py CLI wrapper; the three return conventions
rc.py Remote Control id resolution (registry poll, pid+procStart join)
state.py append-only JSONL + teardown guard
server.py preflight, spawn sequence, MCP wiring
deploy/
herdr-spawn.service systemd --user unit (%h throughout, no absolute paths)
service.env.example site-specific settings -> ~/.config/herdr-spawn/service.env
run.sh bind discovery + launchTests
uv run pytest -qThe ones that matter: …-evil must not pass a …/scratch root, symlinks must not
escape, rc=2 must not crash the wrapper, --until must never be sent, the teardown
guard must refuse tabs it did not create and any protected workspace, and
RC-unavailable must not be treated as failure.
Licence
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
Read a project's prompts, logs and agents, and send new work to the agent on your own machines.
Build and deploy websites, Telegram and Discord bots from chat via the DreamAgent platform.
Embed an AI chat widget on your website from your coding agent: provision, configure, get snippet.
Build and supervise fleets of agents from Claude Code, Codex or Cursor. Connects over OAuth.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables Claude Code to delegate prompts to an OpenCode agent session for cheaper executor-role work, supporting different providers and session persistence.17,832 npmMIT
- AlicenseNot gradedqualityBmaintenanceEnables MCP clients like Claude Code to delegate coding tasks to the local Cursor Agent CLI, with persistent per-workspace sessions that resume across calls.12 npmMIT
- AlicenseAqualityCmaintenanceBridges a main agent (e.g., Codex) to a separate execution model in Claude Code Haha Desktop, enabling delegated coding tasks with file modifications, test runs, and change auditing.61MIT
- FlicenseNot gradedqualityBmaintenanceEnables Claude conversations to send tasks to a local Claude Code CLI instance and retrieve results, bridging chat to laptop-based builds via stdio or HTTPS/tunnel modes.1 npm-