herdr-spawn
Click on "Install 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
Hand 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.
One tool. spawn_agent.
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.Find or create the
inboxworkspace,--no-focus.Create a tab there and start the agent in its pane.
Resolve the claude.ai Remote Control id — Claude Code only.
Submit the prompt, under a per-pane lock.
Append a record to
~/.local/state/herdr-spawn/spawns.jsonl.
A spawn never steals focus. Nothing is ever closed that this service did not create.
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.
Install
Requires Python 3.13+, uv, herdr, and Claude Code.
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 Claude Code. Start Claude there once by hand and accept the trust dialog. Otherwise the spawned pane just sits on that dialog forever, looking alive and doing nothing. Preflight checks this and refuses rather than letting it happen.
Agents
herdr drives 21 coding agents — pi claude codex gemini cursor devin agy cline omp mastracode opencode copilot kimi kiro droid amp grok hermes kilo qodercli maki
(agy is Antigravity CLI). This exposes all of them, but advertises only the ones
you actually have.
At startup each enabled host is asked two questions: what kinds does its herdr support
(parsed from agent start --help, because the set grows between releases), and which of
those are on its PATH. The agent enum is the union of the answers. So a fleet with
Claude and Codex on one box and Pi and Devin on a laptop advertises four values, not
twenty-one — the schema is paid for on every request forever, and listing agents nobody
has installed buys nothing but tokens.
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
(~/.claude.json vs ~/.codex/config.toml, same parent-walking lookup), 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
The registry authenticates management calls with an OAuth2 client-credentials (M2M)
token from its identity provider — Keycloak by default. The bootstrap script writes the
client secret to .oauth-tokens/<realm>-m2m.json in the gateway checkout.
Note the ceiling before you plan around it: an M2M client can register and toggle, but not patch or remove — those return 403. Fixing a bad record means editing the registry's MongoDB directly and restarting the registry container to reload. Get it right the first time.
4. Register the backend
Write a registration JSON:
{
"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 registry's health check only initialises an MCP session when it does; without it the server registers and then 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" — which is correct here, because the backend is only reachable from the gateway. 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. Read your existing entries before assuming
you need to add one — a group with server: '*', tools: ['all'] already grants every
new server the moment it registers, including this one. That is worth a deliberate
decision for a tool that starts autonomous agents, not a default you inherit. Give it
its own block if you want it narrower:
my-group:
- server: herdr-spawn
methods: [tools/list, tools/call]
tools: [spawn_claude]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 — so the prompt a caller
actually sent is nowhere in the gateway's logs. spawns.jsonl is the only record of it
on the box, and even there only as a SHA-256 (the prompt body itself is kept only when it
was large enough to be written to a handoff file). Know which of those you want before
you need them.
One last thing to check on your own gateway
Look at what else the gateway publishes on the host. An internal proxy or admin port
published on 0.0.0.0 can be an unauthenticated path straight to your backend, right
past the OAuth edge you just configured. Enumerate with ss -ltn, and confirm each
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. Doing that from this side would be 75 ssh handshakes; the loop is sent
over and returns once. Same reason the two preflight checks share a single round trip.
And the prompt reaches the remote herdr through shlex.join, so arbitrary prose —
quotes, backticks, $, newlines — arrives as one argument and never reaches 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.
cwd is optional, and defaults to a per-host sandbox. Omit it and the spawn lands in
that host's default_cwd, so a caller that doesn't know your layout doesn't have to
guess — and a guess just gets refused. The default must itself sit under
cwd_allowlist; a misconfigured one outside it is still refused rather than trusted.
An explicit cwd is validated the same way, and a host with no default_cwd refuses a
spawn that names none.
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. This server is meant to be
enabled in every chat and every Claude Code session, so its definition is paid for on
every request. Pydantic's per-field title and anyOf: [T, null] expansion cost ~a
quarter of that and tell a calling model nothing. Stripping them takes the tool from
357 to 172 tokens. It 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 installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Build and deploy websites, Telegram and Discord bots from chat via the DreamAgent platform.
Operate Linux, macOS and Windows from your LLM. Every action runs through an auditable allowlist.
Deploy a multi-user web app from your agent: hosting, auth, database, and permissions.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/nytafar/herdr-spawn'
If you have feedback or need assistance with the MCP directory API, please join our Discord server