| name | spawn-agent |
| description | Create a new sub-agent — no Terraform, no Docker by default. Starts as a systemd service, patched into the bridge immediately so @agent works right away. |
Skill: spawn-agent
Spawn a new sub-agent. The default path is: ask what it's for, scaffold it,
start it as a plain systemd service, register it on the bridge. No Terraform,
no Docker, no container — the agent is reachable via @<name> in a message
as soon as this finishes. Self-heal, starter skills, a full numbered-rules
constitution, and Docker isolation are all opt-in flags layered on top —
never part of the default pass.
Usage
spawn-agent <agent-name> <one-line-purpose>
[--mode=docker] # opt in to Terraform + container isolation (advanced)
[--with-self-heal] # wire in the self-heal escalation skill
[--with-skill=<name>] # scaffold one starter skill from a template
[--full-constitution] # write the old-style numbered-rules MEMORY.md
If invoked conversationally ("create an agent that does X"), ask only for the
agent name and a one-line purpose — nothing else — then run straight through
without per-step confirmation.
Default flow — service mode (no Terraform, no Docker)
Every agents/... path below is relative to the repo checkout, not to Iris's
own working directory (/iris/data, where she actually runs from) — cd
there first, same convention as skills/github/SKILL.md /
skills/terraform/SKILL.md:
cd "${IRIS_REPO_DIR:-/iris/repo}"
Skipping this sends every relative path below looking in the wrong place
(seen in production: ls agents/lib/ and cat agents/service-bootstrap.template.sh
came back empty from /iris/data, and the actual files were only found after
a find /iris -name ... turned up /iris/repo/agents/...).
Step 1 — Scaffold
Create agents/<name>/:
agents/<name>/
├── MEMORY.md ← name + the one-line purpose (a paragraph, not a constitution)
├── README.md ← agent keeps this updated
└── skills/ ← empty unless --with-skill / --with-self-heal was given
--full-constitution writes the old numbered-rules/what-it-can't-do-without-confirmation
form of MEMORY.md instead of the one-paragraph default.
Step 2 — Commit (only if a GitHub PAT is configured)
if agents/lib/register-bridge.sh has-pat; then
github-commit "agents/<name>/" "feat: scaffold <name> agent"
else
echo "No GitHub PAT configured — skipping commit, agent stays local-only for now."
fi
No PAT means no point calling gh/git tooling — skip cleanly, don't attempt
and fall back. The agent still runs and is bridge-reachable either way;
persisting the scaffold into git (so it survives a VM rebuild) is a separate
concern from making it exist right now.
Step 3 — Start it as a systemd service
PORT=$(agents/lib/register-bridge.sh next-port)
cp agents/service-bootstrap.template.sh agents/<name>/bootstrap.sh
sed -i "s/<your-agent>/<name>/g; s/<port>/${PORT}/g" agents/<name>/bootstrap.sh
bash agents/<name>/bootstrap.sh
Use one sed pass for both placeholders, not two separate edits to the same
file — two concurrent edits to bootstrap.sh race on the read-modify-write,
and the loser's substitution gets silently clobbered back to the placeholder
(seen in production: AGENT_NAME reverted to <your-agent>, producing
Invalid unit name "iris-agent-<your-agent>" on start).
This reuses the same already-built iris-runtime binary Iris herself runs
(see bootstrap.sh's "Build iris-runtime" step) — no per-agent build, no
container. The unit (iris-agent-<name>.service) is active in about a
second. service-bootstrap.template.sh already resolves and embeds the
current IRIS_PROVIDER/IRIS_MODEL/LLM key and symlinks models.json, so
the agent can generate a response with no further action — but see below for
any other secret a skill you're attaching needs.
Decide which secrets this agent needs, from the skills it's actually
getting. If --with-skill=<name> scaffolded a skill (or a starter skill was
copied by hand) whose SKILL.md frontmatter declares a secrets: list — e.g.
search-web's secrets: [PERPLEXITY-API-KEY] — that secret must reach this
agent or the skill fails at the exact moment it's invoked, not at spawn time.
Don't guess or skip this: read every attached skill's frontmatter and collect
its secrets: entries. For each one, in an env-mode install, resolve it from
/iris/.env and add it to agents/<name>/bootstrap.sh as its own literal
Environment= line — the same pattern the template already uses for the LLM
key, never EnvironmentFile=/iris/.env (see the template's own warning for
why). In a store/proxy-mode install, skip the literal env var and instead pass
it through the secrets allow-list in the next step — the agent resolves it
itself via get-secret/the internal broker route, credential-store agnostic
either way.
Step 4 — Patch it into the bridge (always, unconditional)
agents/lib/register-bridge.sh register "<name>" "http://127.0.0.1:${PORT}" "<one-line-purpose>" "" "<SECRET-A,SECRET-B>"
The 5th argument is the comma-separated secrets allow-list from the step
above (omit or leave empty if no attached skill declares any) — it's what lets
this agent's own get-secret calls resolve them at all; without it, GET /secrets/:name 403s regardless of which backend (store, proxy, external
broker, Key Vault) the install uses. This writes/merges the entry into
/iris/data/agents.json under an flock, without disturbing any other
agent's entry. There is no flag to skip this step; it's what makes @<name>
work immediately — on Slack/Telegram, a leading @<name> prefix is
matched deterministically against this registry and bypasses Iris's own LLM
turn entirely for that message (parseAgentMention() in
iris-runtime/src/engine/bridge.ts, wired into slack.ts/telegram.ts); an
unmatched or non-leading @name falls through to Iris's normal intent-based
routing instead (her system prompt already tells her to delegate by inferred
intent, no @mention required — see engine/agent.ts). Either way, the
sub-agent's own process never touches Slack/Telegram directly — whichever
transport received the message posts the reply itself, on the same
channel/thread.
Step 5 — Verify
systemctl status iris-agent-<name> --no-pager
journalctl -u iris-agent-<name> -n 20
curl -s http://127.0.0.1:$((<port>+100))/health
/health is served by the internal API (engine/api.ts), which listens on
IRIS_API_PORT (BRIDGE_PORT+100, set by the template) — not <port>
itself. <port> is the bridge server, which only implements POST /bridge
and 404s on anything else, including GET /health.
Done. @<name> works in the next message.
Advanced: --mode=docker (Terraform + container isolation)
Only use this when the agent specifically needs container isolation
(untrusted, public-facing, or higher-blast-radius work) — never the default.
Steps 1–2 are identical. Then, instead of steps 3–4:
Step 3 (docker) — Provision via Terraform
Set enable_docker_agents = true once (terraform.tfvars or -var) if this
is the first docker-mode agent — it gates the shared image build off by
default so service-mode-only installs never pay that cost. Then add to
terraform/agents.tf:
module "<name>_agent" {
source = "./modules/agent"
agent_name = "<name>"
key_vault_name = var.key_vault_name
iris_api_url = "http://172.18.0.1:3000"
bridge_port = <port> # agents/lib/register-bridge.sh next-port
image_dependency = null_resource.iris_runtime_image[0].id
}
github-commit "terraform/agents.tf" "infra: provision <name> agent container"
terraform-apply
The image build (npm run build && docker build) now runs at most once
across all docker-mode agents — see terraform/main.tf's
null_resource.iris_runtime_image — instead of once per agent.
Step 4 (docker) — Patch it into the bridge
Same agents/lib/register-bridge.sh register call as the default flow
(mode-agnostic). Pass the module's api_token output as the 4th arg if
unique_api_token = true was set.
Step 5 (docker) — Verify
docker ps | grep iris-<name>
docker logs iris-<name> --tail 20
docker exec iris-<name> curl -s http://127.0.0.1:3000/health
The module only publishes bridge_port to the host (see
terraform/modules/agent/main.tf's docker run -p) — the internal API's
IRIS_API_PORT (default 3000 inside the container) is never mapped out, so
curl localhost:<port>/health from the host can't reach it either way; check
from inside the container instead.
Optional flags, added at creation time
--with-self-heal: copies skills/self-heal/SKILL.md into
agents/<name>/skills/self-heal/ and adds the env vars it needs to
escalate (IRIS_API_URL, IRIS_EVENTS_DIR) — as Environment= lines in
the systemd unit, or container -e flags in docker mode.
--with-skill=<name>: scaffolds one starter skill from a template into
agents/<name>/skills/<name>/SKILL.md.
--full-constitution: see Step 1.
None of these are asked about or invoked unless explicitly requested.
Notes
- Default mode is
service — a systemd unit, not a container. It shares
Iris's host user/process/filesystem (no isolation boundary); use
--mode=docker when that blast radius isn't acceptable for this agent.
- Bridge registration (
agents/lib/register-bridge.sh register) always runs,
regardless of mode — "Iris exposes it through the bridge" is not optional.
agents/lib/register-bridge.sh next-port auto-assigns the bridge port by
scanning existing bridge_url entries in /iris/data/agents.json,
starting at 4200 — no need to pick one by hand.
- Container name (docker mode):
iris-<agent-name>. Service name (default
mode): iris-agent-<agent-name>.
- Bridge-only agents (the default, both modes) never need Slack/Telegram
credentials — the bridge is a plain HTTP listener. If an agent should also
connect directly to Slack/Telegram itself (Pattern A,
agents/README.md),
mint it a separate bot — never reuse Iris's own IRIS_SLACK_APP_TOKEN /
IRIS_SLACK_BOT_TOKEN / TELEGRAM_BOT_TOKEN. Identical credentials on two
processes means both fight over the same Socket Mode connection / Telegram
getUpdates poll, and the symptom is the agent intermittently not
responding rather than a clear error. --mode=docker's Terraform module
always clears these three by default (empty -e override) specifically to
stop --env-file /iris/.env from leaking Iris's own tokens into a
bridge-only container — see docs/sub-agents.md.