Content
# agent-lan-relay
FastMCP LAN mailbox and task bus for coordinating AI agents running on separate
machines on the same local network.
This is not unrestricted remote control of Claude Code or Codex. It is a
durable shared coordination layer: messages, statuses, task ownership, and
optional allowlisted local agent run requests.
## Architecture

## Project Docs
- [License](LICENSE)
- [Contributing guide](CONTRIBUTING.md)
- [Security policy](SECURITY.md)
- [Release checklist](docs/release-checklist.md)
## Install
```bash
cd ~/src/agent-lan-relay
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```
The project requires `fastmcp>=3.3.1`.
For a first-time checkout:
```bash
git clone git@github.com:example-org/agent-lan-relay.git
cd agent-lan-relay
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```
## Agent Skills
Repo-owned agent skills live under `skills/`. The autonomous setup and worker
operating guide is:
```text
skills/autonomous-agent-relay/SKILL.md
```
Install or copy it into the local agent skill registry when a machine should be
able to bootstrap relay hosts/workers from the skill:
```bash
mkdir -p "$HOME/.agents/skills/autonomous-agent-relay"
cp skills/autonomous-agent-relay/SKILL.md \
"$HOME/.agents/skills/autonomous-agent-relay/SKILL.md"
```
## Roles
Run one Mac as the relay host. It owns the SQLite database and exposes the MCP
HTTP server. Other Macs should use the `remote-*` commands or MCP clients over
HTTP; they should not use the local SQLite watcher unless they intentionally
share the host database file.
The host can also serve a lightweight cockpit dashboard for monitoring and safe
actions:
```text
http://<host-lan-ip>:8788/dashboard
```
## Quick Start: Host Mac
```bash
cd ~/src/agent-lan-relay
source .venv/bin/activate
export AGENT_RELAY_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
export AGENT_RELAY_DB="$HOME/.agent-lan-relay/relay.db"
agent-lan-relay --host 0.0.0.0 --port 8787
```
Find the host Mac's LAN IP:
```bash
ipconfig getifaddr en0
```
Clients connect to:
```text
http://<host-lan-ip>:8787/mcp
```
Use bearer auth:
```text
Authorization: Bearer <AGENT_RELAY_TOKEN>
```
## Project Bootstrap Wizard
For a new repository, `agent-relay project init` generates a deterministic
onboarding plan instead of requiring hand-written config snippets:
```bash
agent-relay project init \
--project sample-project \
--repo-path "$HOME/projects/sample-project" \
--relay-url "http://<host-lan-ip>:8787/mcp" \
--token-env AGENT_RELAY_TOKEN \
--implementation-agent claude-impl \
--implementation-harness claude \
--reviewer-agent qa \
--reviewer-harness codex \
--security-agent security \
--security-harness codex \
--ticket-provider github \
--dry-run
```
The plan includes TOML for commands, agent profiles, review/security routing,
coworker blocks, harness defaults, autopilot rules, and ticket sync
placeholders. It also emits `agent-relay mcp install` / `agent-relay mcp check`
commands where the selected harness supports MCP, plus safe
`agent-relay harness launch --prompt-file` examples. The relay token is
referenced only by environment variable name; raw token values are never read,
printed, or persisted. See [docs/project-bootstrap.md](docs/project-bootstrap.md)
for the full onboarding flow and output path behavior.
For an agent-friendly setup bundle with runnable scripts, MCP JSON, role
prompts, and a project config file, use `agent-relay project setup`:
```bash
agent-relay project setup \
--project sample-project \
--repo-path "$HOME/projects/sample-project" \
--relay-url "http://<host-lan-ip>:8787/mcp" \
--node mac-signing \
--capabilities repo:sample-project,macos-signing,arm64 \
--implementation-agent claude-impl \
--implementation-harness claude \
--reviewer-agent qa \
--reviewer-harness codex \
--security-agent security \
--security-harness codex \
--ticket-provider github \
--output-dir "$HOME/.agent-lan-relay/sample-project-mac-signing" \
--write --yes
```
Copy `.env.example` to `.env`, set the shared relay token locally, then run the
generated `scripts/verify-setup.sh`. On the relay host, the generated
`scripts/start-relay-host.sh` starts the relay process. Start local SQLite
bridge/autopilot sessions only after explicitly setting `AGENT_RELAY_DB` and
running `AGENT_RELAY_ENABLE_LOCAL_DB_WORKERS=1 ./scripts/start-local-workers.sh`.
On other machines, leave `AGENT_RELAY_DB` unset and use the generated
`mcp/*.json` plus `scripts/launch-<agent>.sh` for interactive MCP-capable
agents, or run `apps/relay-harness` / `apps/openclaw-relay-harness` for fully
headless pull-mode workers over HTTP.
`--ticket-provider` supports `github`, `gitlab`, `jira`, `linear`, `custom`,
or `none`; generated configs use env-var references and safe placeholders.
Maintainers can dogfood the generated onboarding path without network access or
real tracker credentials:
```bash
uv run --extra dev python -m pytest tests/test_project_bootstrap_dogfood.py -q
```
## A2A Protocol
The relay also exposes a small Agent2Agent-compatible surface on the same host
and port as the MCP server. MCP remains the best interface for Claude/Codex
clients that already support MCP tools; A2A is the interoperability layer for
agents and harnesses that speak Google's Agent2Agent protocol.
Discovery:
```text
http://<host-lan-ip>:8787/.well-known/agent-card.json
```
Inbound task submission:
```bash
curl -sS \
-H "Authorization: Bearer <AGENT_RELAY_TOKEN>" \
-H "Content-Type: application/a2a+json" \
-d '{
"message": {
"role": "ROLE_USER",
"messageId": "msg-001",
"parts": [{"text": "Review branch phase-1 and report risks."}]
},
"metadata": {
"project": "sample-project",
"fromAgent": "claude",
"toAgent": "codex",
"topic": "review-request",
"commandName": "codex-review",
"repo": "example-org/sample-project",
"branch": "phase-1"
}
}' \
http://<host-lan-ip>:8787/message:send
```
`message:send` always writes a durable relay message. If `metadata.commandName`
is present, it also queues an allowlisted `agent_runs` request for the target
agent's local bridge watcher. If `commandName` is omitted, the relay creates a
coordination task assigned to `metadata.toAgent`.
Task lookup and listing:
```text
GET /tasks/run-<agent_run_id>
GET /tasks/task-<coordination_task_id>
GET /tasks?contextId=sample-project&targetAgent=codex&pageSize=20
```
A2A task responses map relay run/task status into A2A task states and include
relay IDs in `metadata`, so MCP clients, CLI users, and A2A clients can all
refer to the same underlying work.
For the full cockpit + local-services mode:
```bash
cd ~/src/agent-lan-relay
source .venv/bin/activate
export AGENT_RELAY_DB="$HOME/.agent-lan-relay/sample-project.db"
export AGENT_RELAY_TOKEN="<generated-relay-token>"
agent-lan-relay \
--host 0.0.0.0 \
--port 8787 \
--project sample-project \
--agent codex \
--local-services \
--dashboard \
--dashboard-host 0.0.0.0 \
--dashboard-port 8788 \
--notification-mode both
```
Open the cockpit at:
```text
http://<host-lan-ip>:8788/dashboard
```
Paste the same `AGENT_RELAY_TOKEN` into the dashboard connection panel. If the
token is wrong, Cockpit v2 shows the API error instead of failing silently.
## Identity and Auth Modes
The relay supports two authentication modes:
### Trusted-Local Mode (Shared Token)
The default single-token mode. Set `AGENT_RELAY_TOKEN` and any authenticated
caller can set `from_agent` to any value. Suitable for trusted local setups
where a single operator controls all agents.
### Per-Agent Token Mode
For multi-agent or multi-machine operation, bind each agent to its own token
with scoped capabilities. Add an `[identity]` section to your relay config:
```toml
[identity.principals.claude]
token_env = "AGENT_RELAY_TOKEN_CLAUDE"
scopes = ["read", "write", "request-run", "complete-run"]
[identity.principals.codex]
token_env = "AGENT_RELAY_TOKEN_CODEX"
scopes = ["read", "write", "request-run"]
[identity.principals.orchestrator]
token_env = "AGENT_RELAY_TOKEN_ORCHESTRATOR"
scopes = ["read", "write", "request-run", "complete-run", "admin"]
```
Each principal's token is referenced by environment variable name only; raw
tokens never appear in config files or logs.
**Scopes:**
| Scope | Permits |
|-------|---------|
| `read` | List messages, statuses, tasks, runs |
| `write` | Send messages, post status, create/claim tasks, ack |
| `request-run` | Request agent runs |
| `complete-run` | Complete tasks and runs |
| `admin` | Impersonate other agents (set `from_agent` to a different name) |
**Identity binding rules:**
- `from_agent` defaults to the authenticated principal's agent name
- A principal can always send as itself
- Sending as another agent requires the `admin` scope
- In shared-token mode, `from_agent` is required and any value is accepted
Generate per-agent tokens:
```bash
export AGENT_RELAY_TOKEN_CLAUDE="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
export AGENT_RELAY_TOKEN_CODEX="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
export AGENT_RELAY_TOKEN_ORCHESTRATOR="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
```
## Claude Code
On the other Mac:
```bash
claude mcp add --transport http agent-lan-relay http://<host-lan-ip>:8787/mcp
```
If Claude Code's MCP config supports headers in your installed version, set:
```text
Authorization: Bearer <AGENT_RELAY_TOKEN>
```
If the client cannot attach HTTP MCP auth headers, use the session-scoped stdio
client MCP below.
### Session-Scoped Client MCP
For Claude, Kiro, Gemini, or any client where you want relay tools plus
automatic "something happened" awareness, run the local client MCP over stdio.
It proxies to the host HTTP relay and starts a lightweight watcher inside the
same MCP process. When the LLM session closes and the MCP process exits, the
watcher exits too. No launchd daemon is installed by default.
On the client Mac:
```bash
cd ~/src/agent-lan-relay
git pull
source .venv/bin/activate
export AGENT_RELAY_TOKEN="<generated-relay-token>"
```
Add the client MCP:
```bash
claude mcp add --transport stdio agent-lan-relay-client -- \
/bin/zsh -lc 'cd "$HOME/src/agent-lan-relay" && PYTHONPATH=src exec .venv/bin/python -m agent_lan_relay.client_server --url http://<host-lan-ip>:8787/mcp --token "$AGENT_RELAY_TOKEN" --project sample-project --agent claude'
```
For scoped autonomous relay work in a trusted repo workspace, start Claude with
bypassed local permissions so read-only research, tests, and repo inspection do
not repeatedly stall on terminal prompts:
```bash
claude \
--model us.anthropic.claude-opus-4-8 \
--permission-mode bypassPermissions \
--mcp-config "$HOME/.agent-lan-relay/<project>-claude-mcp.json" \
--strict-mcp-config \
--name <agent-name>
```
Keep the relay task prompt and review gate conservative: no merge, release,
destructive data changes, secret rotation, or production approval without
explicit delegation. Use `--permission-mode auto` for untrusted repos or
exploratory sessions where local tool prompts are desired.
Default watcher behavior is visual notification only and no auto-ack. To hear
messages too, add:
```bash
--notification-mode both
```
To disable the session watcher while keeping the proxy tools, add:
```bash
--no-watch
```
The client MCP exposes scoped tools such as `send_message`, `list_messages`,
`ack_message`, `list_agent_runs`, `list_status`, `post_status`,
`report_working`, `report_input_required`, `report_done`,
`orchestrate_once`, `client_watch_state`, and `client_info`.
The `report_*` tools are the client-side companion for Symphony Light. Agents
should call:
- `report_working` when continuing a long task, with `run_id` or `task_id` when
known
- `report_input_required` when blocked, with `needed_from` and `question`
- `report_done` when the current relay work is finished
- `orchestrate_once` when the session wants the host relay to immediately route
blocked/stale work instead of waiting for the next orchestrator interval
Validate the watcher from inside Claude after restarting the MCP session:
1. Call `client_info`.
- Expect the configured `url`, `project`, and `agent` to match the relay.
2. Call `client_watch_state`.
- Expect `enabled: true`, `running: true`, and the intended
`notification_mode`.
3. Call `list_messages` with `project=sample-project`, `agent=claude`,
`include_acked=true`, and a small `limit`.
- Expect recent relay messages to appear. This proves the client can read
from the host relay.
4. From Codex or the host machine, send a test message to Claude:
```bash
AGENT_RELAY_TOKEN="<generated-relay-token>" \
agent-relay remote-send \
--url http://<host-lan-ip>:8787/mcp \
--project sample-project \
--from codex \
--to claude \
--topic watcher-smoke-test \
--body "Watcher smoke test from Codex."
```
Claude should receive a macOS notification within one watcher interval. Then
call `list_messages` again to confirm the new message is visible. If
`--ack` was not passed, the smoke-test message should remain unacked until
Claude explicitly calls `ack_message`.
## CLI
The CLI reads/writes the same relay database directly. It is useful for smoke
tests and for clients that cannot yet attach HTTP MCP auth headers.
```bash
agent-relay send --project sample-project --from codex --to claude --topic hello --body "ping"
agent-relay inbox --project sample-project --agent claude
agent-relay status --project sample-project --agent codex --status "strict build failing"
```
## Watch Events (Unified Polling API)
The `watch-events` API provides cursor-based polling across all relay streams
(messages, tasks, statuses, and runs) with a single call. Events are ordered by
timestamp and type, supporting stable resumption via opaque cursor strings.
**Why watch-events instead of per-stream polling?** Separate calls to
`list_messages`, `list_tasks`, `list_status`, and `list_agent_runs` require 4×
the HTTP round trips and 4× the permission prompts. Watch-events merges all
streams in a single cursor-based call, reducing latency and UX friction while
guaranteeing chronological event ordering across streams.
```bash
agent-relay watch-events \
--project sample-project \
--agent codex \
--max-events 50 \
--timeout 30
```
Returns JSON:
```json
{
"events": [
{
"type": "message",
"id": 123,
"cursor": "{\"messages\":123,\"tasks\":0,\"statuses\":0,\"runs\":0}",
"timestamp": 1234567890.0,
"item": {"id": 123, "body": "...", ...}
}
],
"next_cursor": "{\"messages\":123,\"tasks\":0,\"statuses\":0,\"runs\":0}"
}
```
Useful flags:
- `--cursor <string>`: Resume from a previous poll's `next_cursor`.
- `--max-events <int>`: Maximum events to return (default 100, capped at 1000).
- `--timeout <float>`: Polling timeout in seconds (default 30, capped at 120).
- `--include-acked`: Include acknowledged messages (default false).
- `--status <value>`: Filter by status (repeatable; applies to tasks and runs).
- `--priority <value>`: Filter by priority (repeatable; applies to messages and tasks).
## Watcher
For the older split-terminal workflow, run a local watcher so an agent/human can
see new relay messages without remembering to poll manually:
```bash
agent-relay watch \
--project sample-project \
--agent codex \
--interval 10 \
--notify \
--say
```
Useful flags:
- `--include-existing`: print existing messages before watching for new ones.
- `--ack`: acknowledge messages after printing them.
- `--once`: poll once and exit.
- `--json`: print compact JSON lines.
For a background watcher:
```bash
nohup agent-relay watch --project sample-project --agent codex --interval 10 --notify \
>> "$HOME/.agent-lan-relay/codex-watch.log" 2>&1 &
```
## Agent Loop
`agent-relay agent-loop` is the lightweight pull loop for autonomous agents that
can already act on relay work from their own terminal. It scans the local
database for unacked inbox messages, assigned open tasks, pending agent runs,
and active blockers where `needed_from` matches the agent, then prints compact
JSON actions:
```bash
agent-relay agent-loop \
--project sample-project \
--agent codex \
--mode once
```
Action types are `claim_task`, `ack_message`, `review_request`,
`answer_blocker`, and `no_action`. By default the loop is advisory only: it does
not acknowledge messages or claim tasks. Opt in explicitly when the surrounding
agent harness is ready for that behavior:
```bash
agent-relay agent-loop \
--project sample-project \
--agent codex \
--mode watch \
--auto-ack \
--auto-claim
```
State persists under
`~/.agent-lan-relay/agent-loop/<project>-<agent>.json` so repeated runs suppress
duplicates. Use `--state-file` to isolate experiments or one-off sessions.
Claude startup snippet:
```bash
cd ~/src/agent-lan-relay
source .venv/bin/activate
agent-relay agent-loop --project sample-project --agent claude --mode watch
```
Codex startup snippet:
```bash
cd ~/src/agent-lan-relay
source .venv/bin/activate
agent-relay agent-loop --project sample-project --agent codex --mode watch
```
## Remote Watcher
Machines that do not host the relay database can watch the HTTP MCP server
directly. This is the recommended setup for the second Mac:
```bash
agent-relay remote-watch \
--url http://<host-lan-ip>:8787/mcp \
--project sample-project \
--agent claude \
--interval 10 \
--notification-mode visual
```
Notification modes are `none`, `visual`, `audio`, or `both`. The older
`--notify` and `--say` flags still work for existing scripts.
Remote send and inbox commands use the same URL/token:
```bash
agent-relay remote-send \
--url http://<host-lan-ip>:8787/mcp \
--project sample-project \
--from claude \
--to codex \
--topic hello \
--body "ping"
agent-relay remote-inbox \
--url http://<host-lan-ip>:8787/mcp \
--project sample-project \
--agent claude
```
Remote autopilot can queue allowlisted local runs through the host MCP server:
```bash
agent-relay remote-autopilot \
--url http://<host-lan-ip>:8787/mcp \
--project sample-project \
--agent claude \
--config "$HOME/.agent-lan-relay/config.toml"
```
`remote-*` commands read `AGENT_RELAY_TOKEN` from the environment when
`--token` is omitted.
## ACP-Style Bridge
The bridge is the safe v0 of cross-machine agent control. A remote agent can
request work through MCP, but execution only happens on the local machine when
`agent-relay bridge` or the integrated server local-services mode is running
and the requested command is allowlisted in `~/.agent-lan-relay/config.toml`.
Example config:
```toml
[commands.codex-review]
agent = "codex"
argv = [
"codex",
"exec",
"--cwd",
"$HOME/projects/sample-project",
"{prompt}"
]
cwd = "$HOME/projects/sample-project"
timeout_seconds = 900
preflight_git = true
fallback_command = "gemini-review"
[commands.claude-note]
agent = "claude"
argv = [
"claude",
"-p",
"{prompt}"
]
cwd = "$HOME/projects/sample-project"
timeout_seconds = 900
[commands.gemini-review]
agent = "codex"
argv = [
"gemini",
"--prompt",
"""
You are Gemini CLI acting as the emergency fallback for Codex because the
primary Codex run hit a token, quota, rate-limit, or context-limit restriction.
Continue the same relay task and produce the concise reply that should be sent
back to Claude.
For Swift, macOS app, installer, Xcode, or tests under tools/sample-project-app/,
use macOS-app specialist guidance. If the local Codex Build macOS Apps skills
are readable, consult the relevant SKILL.md files under:
$HOME/.codex/plugins/cache/openai-curated/build-macos-apps/<version>/skills
Prefer the macOS SwiftPM/Xcode build, run, debug, signing, and test-triage
workflows from those skills before falling back to generic shell-only review.
Original relay task:
{prompt}
""",
"--approval-mode",
"auto_edit",
"--output-format",
"text"
]
cwd = "$HOME/projects/sample-project"
timeout_seconds = 900
[[autopilot.rules]]
from_agent = "claude"
topic = "*"
command_name = "codex-review"
ack = true
prompt = """
Claude sent relay message #{id} on topic {topic}.
Repo: {repo}
Branch: {branch}
{body}
Review the request, inspect the repo if needed, and reply through the relay.
"""
```
#### Review-request topic aliases
Autonomous workers are instructed to send review requests on the canonical
topic `relay/review-request`, but they occasionally pick a near-miss spelling
such as a bare `review-request`. By default an autopilot rule matches its
`topic` exactly (or via the `*` wildcard), so a mis-topiced request would never
launch the configured QA review until a human forwarded it.
Set `review_aliases = true` on a review rule to opt that rule into a small,
closed set of known aliases for the canonical review lanes:
```toml
[[autopilot.rules]]
from_agent = "claude"
topic = "relay/review-request"
command_name = "codex-review"
review_aliases = true
```
With the flag on, a message on `review-request` (and other explicit aliases of
`relay/review-request`) launches the same review command as the canonical
topic. The behavior is deliberately narrow:
- Only the explicit aliases listed in `agent_lan_relay.topic_aliases` match —
there is no body sniffing or fuzzy "contains the word review" matching, so an
ordinary chat/status message can never be coerced into a review run.
- The request lane and result lane never bridge: a `relay/review-request` rule
will not match a `relay/review-result` message.
- Exact-topic rules and `*` wildcards keep their existing semantics; the flag
is a no-op for rules whose `topic` is not a canonical review lane.
Each queued run records how it matched in its metadata — `source_topic` (the
message's original topic), `matched_topic` (the canonical lane), and
`topic_match_reason` (`exact`, `wildcard`, or `review_alias`) — so the audit
trail shows when an alias was accepted.
For autopilot runs, the bridge sends the child agent's final output back as a
relay message. The child agent does not need direct SQLite or HTTP relay access.
When `fallback_command` is set, the bridge reruns the same prompt with that
allowlisted command only if the primary command fails with a quota, rate-limit,
context-length, or token-limit style error. Ordinary test/build/review failures
are sent back as failures and do not trigger Gemini. The Gemini command should
include any specialist instructions it needs, such as the Build macOS Apps skill
path for Sample Project macOS reviews.
A ready-to-adapt Sample Project config lives at
`examples/sample-project-config.toml`.
Single-command mode runs the MCP server, message notifications, autopilot, and
the bridge runner together:
```bash
AGENT_RELAY_DB="$HOME/.agent-lan-relay/sample-project.db" \
AGENT_RELAY_TOKEN="<generated-relay-token>" \
agent-lan-relay \
--host 0.0.0.0 \
--port 8787 \
--project sample-project \
--agent codex \
--local-services \
--dashboard \
--dashboard-host 0.0.0.0 \
--notification-mode both
```
For repeatable startup, copy and edit the example env/config files:
```bash
mkdir -p "$HOME/.agent-lan-relay"
cp examples/sample-project.env.example "$HOME/.agent-lan-relay/sample-project.env"
cp examples/sample-project-config.toml "$HOME/.agent-lan-relay/config.toml"
```
Split-terminal mode is still available. Start a bridge watcher on the machine
that owns the target agent:
```bash
AGENT_RELAY_DB="$HOME/.agent-lan-relay/sample-project.db" \
agent-relay bridge --project sample-project --agent codex --interval 10
```
To run multiple agent loops in parallel, prefer a single bridge with
`--workers N` (see [Worker pool](#worker-pool-parallel-runs-per-bridge) below)
over launching N separate bridge processes.
For same-machine or multi-machine routing, give each agent node a stable name.
A bridge started with `--node` executes pending runs with no node metadata, plus
runs whose `metadata.node` matches that node:
```bash
AGENT_RELAY_DB="$HOME/.agent-lan-relay/sample-project.db" \
agent-relay bridge \
--project sample-project \
--agent codex \
--node relay-host-1 \
--interval 10
```
The integrated server accepts the same node identity:
```bash
agent-lan-relay \
--project sample-project \
--agent codex \
--node relay-host-1 \
--local-services
```
For heterogeneous fleets where nodes have different tools (Docker, GPUs, repo
clones), label the bridge with one or more `--capability` values. A bridge with
capabilities accepts pending runs whose `metadata.capability` is unset or whose
required set is a subset of the bridge's set. Pass capabilities as repeated
flags, a comma-separated `--capabilities` list, or both:
```bash
agent-relay bridge \
--project sample-project \
--agent codex \
--node relay-host-1 \
--capability docker \
--capability gpu \
--interval 10
# Or, equivalently:
agent-relay bridge \
--project sample-project \
--agent codex \
--capabilities docker,gpu
```
The integrated server accepts the same flags, plus `AGENT_RELAY_CAPABILITIES`
as a comma-separated env fallback:
```bash
AGENT_RELAY_CAPABILITIES=docker,gpu \
agent-lan-relay \
--project sample-project \
--agent codex \
--node relay-host-1 \
--local-services
```
### Worker pool: parallel runs per bridge
By default a bridge executes one run at a time. To run several agent loops in
parallel from a single process, pass `--workers N`: one bridge process then
runs up to `N` agent runs concurrently for `--agent`. This replaces the older
pattern of launching `N` separate `agent-relay bridge` processes — you get a
single log, less claim-race churn, and one process to manage. `--workers 1`
(the default) preserves the exact sequential behavior.
```bash
AGENT_RELAY_DB="$HOME/.agent-lan-relay/sample-project.db" \
agent-relay bridge \
--project sample-project \
--agent codex \
--workers 4 \
--interval 10
```
Each concurrent run claims its own row via the atomic lease, so workers never
collide on the same run; cross-process races (multiple hosts) stay handled too.
Every store call opens its own short-lived SQLite connection with the default
5s busy timeout, which absorbs brief write contention — keep `N` modest
(`N ≤ 8`) for a single SQLite-backed bridge.
The integrated server accepts the same flag, plus `AGENT_RELAY_WORKERS` as an
env fallback:
```bash
AGENT_RELAY_WORKERS=4 \
agent-lan-relay \
--project sample-project \
--agent codex \
--node relay-host-1 \
--local-services
```
This relates to the fleet view: `fleet_status`'s per-node `running` count
already reflects up to `N` concurrent runs (it is derived from the `node`
column on `agent_runs`). To make the `max_slots` *denominator* in the dashboard
equal `N`, advertise `max_slots` via the node's existing `node-online` status
(harness app / `node_caps`) — the bridge itself does not post node status (v1),
so the worker cap and the observational fleet view stay decoupled.
Request a run locally:
```bash
agent-relay request-run \
--project sample-project \
--requested-by claude \
--target-agent codex \
--command-name codex-review \
--repo example-org/sample-project \
--branch phase-0-v7-clone-swift6 \
--prompt "Review the Phase 1 plan and summarize blockers."
```
Or request the same thing from MCP with `request_agent_run`.
Inspect results:
```bash
agent-relay runs --project sample-project --target-agent codex --status all
```
Safety model:
- MCP can create run requests, but cannot execute arbitrary commands.
- Autopilot can convert matching messages to run requests, but still uses the
same allowlisted commands.
- For autopilot runs, the bridge sends the child agent's final output back as a
relay message.
- Local execution uses argv lists, not shell strings.
- Unknown commands are marked `rejected`.
- Commands are scoped to an agent with `agent = "codex"`, `agent = "claude"`,
or `agent = "both"`.
- Runs can include `metadata.node`. Routing rules:
- A run with no `metadata.node` is unscoped — any bridge will pick it
up (single-machine setups: start a bridge with no `--node` and it
consumes everything not pinned to a host).
- A run with `metadata.node` is host-scoped — only a bridge whose
`--node` value exactly matches will pick it up.
- A bridge started without `--node` will **not** pick up host-scoped
runs, so a misconfigured fleet member cannot silently steal work
pinned to another host. Set `--node <host>` on every bridge in a
multi-machine deployment.
- Runs can include `metadata.capability` as a string or list of strings; a
bridge started with `--capability`/`--capabilities` only picks up runs whose
required capabilities are a subset of the bridge's set, plus runs with no
capability metadata. Bridges without `--capability` ignore the field.
- Commands can set `preflight_git = true` to make the bridge run
`git fetch origin`, check out the run branch, and `git pull --rebase` in the
command `cwd` before launching the child agent. If preflight fails, the run
fails closed and replies with the Git error instead of letting a sandboxed
child agent review stale code.
- Template fields available in `argv` and `cwd`: `{id}`, `{run_id}`,
`{project}`, `{requested_by}`, `{target_agent}`, `{command_name}`,
`{prompt}`, `{repo}`, and `{branch}`.
- Every claimed run is fenced with a one-time `lease_token`. Claiming a
pending run via `mark_agent_run_running` (or POST `/api/runs/<id>/claim`)
is an atomic compare-and-set: only one bridge or harness can win, and
the row records `leased_by`, `lease_expires_at` (default 900s, override
with `lease_seconds`), and a `heartbeat_at` timestamp. The winner must
pass the same token back to `complete_agent_run`,
`release_agent_run`, or `heartbeat_agent_run` (POST
`/api/runs/<id>/complete|heartbeat`); a stale worker that lost its
lease cannot overwrite a successor's result. If a lease expires, any
worker can re-claim the run, which issues a fresh token and rejects
the original holder. Token-less completion is preserved only for runs
that were never claimed (e.g. unknown-command rejections, dry-runs).
- **Lease tokens are returned only to the worker that issued or extended
the lease.** Broad list/read surfaces redact `lease_token` so a caller
with read access cannot complete/heartbeat/release another worker's
run. `list_agent_runs`, `list_workflow_agent_runs`, `get_agent_run`,
the dashboard `/api/runs` and `/api/runs/<id>` endpoints, the MCP
`list_agent_runs` tool, the CLI `agent-relay runs` output, and the
A2A task surfaces all return `lease_token = null` when a lease is
active and add `has_active_lease: true` for observability. The other
lease metadata (`leased_by`, `lease_expires_at`, `heartbeat_at`) is
preserved. The token is only handed back from `mark_agent_run_running`
/ POST `/api/runs/<id>/claim` and `heartbeat_agent_run` / POST
`/api/runs/<id>/heartbeat` — the workers that actually own the lease.
## Symphony Light Orchestrator
Symphony Light is the relay-native policy loop for blocked or stale autonomous
work. It does not replace MCP, A2A, the bridge, or autopilot. It watches the
same durable relay database, dispatches metadata-defined workflow stages, and
pushes nudges when an agent is waiting for input or a run appears stuck.
Run one pass:
```bash
agent-relay orchestrator --project sample-project --once --json
```
Run continuously:
```bash
agent-relay orchestrator \
--project sample-project \
--interval 10 \
--pending-timeout 300 \
--running-timeout 900 \
--nudge-repeat-interval 600 \
--nudge-max-attempts 3 \
--escalate-to human
```
By default the orchestrator sends a single high-priority nudge per
`input_required` status (`--nudge-max-attempts 1`,
`--nudge-repeat-interval 0`). To send a bounded ladder, raise
`--nudge-max-attempts` **and** set `--nudge-repeat-interval` to a positive
number of seconds — that interval is the minimum gap between attempts, so
values `<= 0` keep only the first nudge regardless of the cap. When the cap is
reached and `--escalate-to` is set to a different agent (typically `human`),
Symphony Light sends one `orchestrator/input-required-escalated`
blocker-priority message. The same values can be supplied via
`AGENT_RELAY_NUDGE_REPEAT_INTERVAL`, `AGENT_RELAY_NUDGE_MAX_ATTEMPTS`, and
`AGENT_RELAY_ESCALATE_TO` for the integrated server.
The integrated server includes Symphony Light when `--local-services` is used.
It can also be enabled alone:
```bash
agent-lan-relay \
--host 0.0.0.0 \
--port 8787 \
--project sample-project \
--orchestrator
```
For agents, the important convention is explicit blocked-state reporting. When
an agent needs another agent or a human to answer something, it should post:
```json
{
"project": "sample-project",
"agent": "codex",
"status": "input_required",
"detail": "Need the target branch before QA can continue.",
"metadata": {
"needed_from": "claude",
"question": "Which branch should Codex review?",
"task_id": 12,
"run_id": 44
}
}
```
Symphony Light sends one idempotent high-priority message to `needed_from`:
```text
from: orchestrator
to: claude
topic: orchestrator/input-required
```
It also detects stale runs:
- pending runs older than `--pending-timeout` get
`orchestrator/run-pending` nudges to the target agent
- running runs older than `--running-timeout` get
`orchestrator/run-status-request` nudges asking the agent to post `working`
or `input_required`
It also runs an **assignment watchdog** for tasks that have an assignee but no
claimant. When `--assignment-stale-after` is greater than zero, any task with
`status='open'`, `assigned_to` set, and `claimed_by` null whose `created_at`
is older than the threshold produces an `orchestrator/assignment-stale`
high-priority message to the assigned agent. The message metadata carries
`orchestrator_event=assignment_stale`, `source_task_id`, `assigned_to`,
`age_seconds`, backwards-compatible `next_action=claim_task`, and
`next_actions=[claim_task, report_working]`, so a coworker daemon or terminal
actuator can act on the full pickup contract without polling. Repeat behavior matches the
input-required nudge ladder: `--assignment-repeat-interval` is the minimum
gap between attempts, `--assignment-max-attempts` caps how many nudges fire
for one task, and `--assignment-escalate-to` (typically `human`) receives a
single `orchestrator/assignment-stale-escalated` blocker-priority message
once the cap is reached. The same values can be supplied via
`AGENT_RELAY_ASSIGNMENT_STALE_AFTER`,
`AGENT_RELAY_ASSIGNMENT_REPEAT_INTERVAL`,
`AGENT_RELAY_ASSIGNMENT_MAX_ATTEMPTS`, and
`AGENT_RELAY_ASSIGNMENT_ESCALATE_TO` for the integrated server. Completed,
cancelled, and already-claimed tasks are skipped.
MCP clients can trigger a single orchestration pass with `orchestrate_once`.
The tool returns the actions it took, including target agent and relay message
IDs. The `orchestrate_once` tool also accepts `nudge_repeat_interval_seconds`,
`nudge_max_attempts`, `escalate_to_agent`,
`assignment_stale_after_seconds`, `assignment_repeat_interval_seconds`,
`assignment_max_attempts`, and `assignment_escalate_to_agent` for matching
the CLI nudge and assignment ladders.
To inspect just the active blocker view (the input_required statuses Symphony
Light considers unresolved — i.e. no later non-blocking status for the same
run/task and the underlying agent run is still non-terminal), call the
`active_blockers` MCP tool, or run:
```bash
agent-relay blockers --project sample-project --json
```
### Durable Coordinator Supervisor
`agent-relay orchestrator` runs the planning pass and can loop, but it has no
durable *ownership* — start two of them and both dispatch (split-brain), and a
restart re-decides from scratch. `agent-relay coordinator-supervisor` is the
**always-on supervision layer** for autonomous projects (issue #99). It wraps
the same orchestration pass (so it inherits all the routing, stale-detection,
and escalation behavior above) and adds three things:
- **Project ownership lease.** Exactly one supervisor owns a project at a time.
The owner heartbeats a relay-backed lease each tick, proving possession of the
current one-time **lease token** on every renewal. A second supervisor — on
the same host or another, *even if it reuses the same `--owner` string* —
*stands down* (posts a single `coordinator_suppressed` status) while the lease
is live, because it cannot present the incumbent's current token. It only
takes over once the lease expires without a heartbeat. Reusing an owner string
is never enough to seize a live lease, so two supervisors never both dispatch
in the same window. No split-brain, no duplicate dispatch.
- **Persisted cursor.** The supervisor records the highest AG-UI event id it has
observed on its lease row, *not* in the owner identity. A long-lived process
renews in place (token-gated) and keeps its cursor. The lease token is held
only in memory, so a **restarted** process has lost it: it waits for the prior
lease to expire, then takes over and resumes from the persisted cursor. A
bounce therefore does not replay old decisions, and this holds regardless of
whether the restart reuses the old owner string or gets a fresh per-process
one — the resume point lives on the durable lease row.
- **Decision audit.** Every tick posts a `coordinator_tick` status whose
metadata classifies that tick's actions into `dispatch`, `escalation`,
`suppression`, and `no_op` counts (plus `idle_slots` from the fleet view), so
an operator sees what the supervisor decided in relay statuses without tmux
spelunking.
Start supervision on the primary relay host:
```bash
agent-relay coordinator-supervisor \
--project agent-lan-relay-autonomous \
--interval 10 \
--lease-seconds 60
```
`--owner` defaults to `<hostname>:<pid>`, a **per-process** identity, which is
the recommended setting: each restart gets a distinct owner, takes over only
after the prior lease expires, and resumes from the persisted cursor. You *may*
pass an explicit stable value (e.g. `--owner primary-host:relay`) purely as a
human-readable audit label, but it grants **no** takeover advantage — a stable
owner cannot reclaim its own live lease after a restart, because the one-time
lease token is held only in memory and is not persisted. A restarted supervisor
(stable or per-process owner) always waits for the lease to expire before taking
over. The lease TTL is auto-raised to at least 3× the interval so a slow tick
never lets a standby steal ownership.
Run a single pass (CI / smoke check); this releases the lease on exit so a
standby takes over immediately:
```bash
agent-relay coordinator-supervisor --project agent-lan-relay-autonomous --once --json
```
**Stopping:** send `SIGINT`/`SIGTERM` (Ctrl-C, `kill`, or `systemctl stop`). The
supervisor finishes the in-flight tick, **releases its lease** (posts a
`coordinator_released` status), and exits — a standby can take over at once
rather than waiting for the lease to expire. If the process is killed
ungracefully (`SIGKILL`, host crash), the lease is simply allowed to expire after
`--lease-seconds` and the next supervisor takes over.
**Running a hot standby:** start a second supervisor with the same `--project`
on another host. It will post `coordinator_suppressed` each tick and dispatch
nothing until the primary's lease lapses, at which point it seamlessly assumes
ownership. Stale-status thresholds and escalation targets are read from
`[review_routing]` (and any `[coordinator_supervisor]` overrides) so the
supervisor and a standalone orchestrator behave identically.
Optional config (`relay-config.toml`):
```toml
[coordinator_supervisor]
# owner defaults to a per-process <hostname>:<pid>; leave it unset (recommended)
# so a restart takes over only after lease expiry. Set a stable string ONLY as a
# human-readable audit label — it grants no takeover advantage.
# owner = "primary-host:relay"
interval_seconds = 10
lease_seconds = 60
```
### Autonomous Coworker Daemon
`agent-relay coworker` is the **recommended default** for running a desktop
agent autonomously. It is one supported runtime that composes the three
primitives below — terminal companion, inbox delivery, and the safe actuator —
so an operator no longer has to compose `companion` + `inbox-deliver` +
`actuator` by hand. Each pass, for every configured agent, it:
1. **Observes** the agent's tmux pane and reports local blocked/pending/working
state upstream into the relay (`input_required` for a blocked pane,
optionally `working` heartbeats). It never approves permission prompts.
2. **Wakes** the pane for actionable inbox messages using the exact
submit-confirmed delivery path the actuator uses (relay-backed claim/lease,
just-in-time heartbeat, `send_tmux_nudge` with submit-confirmation retries).
3. **Gates** the wake on the observed pane state: if the pane is sitting on a
permission/continue prompt or has un-submitted typed input, the wake is
*deferred* (audited as `coworker_suppressed` / `reason = "pane_blocked"`)
so a new packet is never pasted on top of a pending prompt. The deferred
message stays unacked and is retried on a later tick once the pane clears,
and the defer does **not** count against the per-message attempt cap. When
a pass wakes more than one message, the pane is **re-captured and
re-observed before each subsequent message** so a prompt left behind by an
earlier wake gates the later ones (a mid-pass re-capture failure fails safe
and defers).
```bash
agent-relay coworker \
--project sample-project \
--config "$HOME/.agent-lan-relay/config.toml" \
--state-file "$HOME/.agent-lan-relay/coworker-state.json" \
--interval 15 \
--watch
```
Routing is **opt-in per agent** via `[coworker.<agent>]` blocks. There is no
global default; an agent with no block is not run. A single daemon on the host
that owns the tmux sessions can drive several local agents.
```toml
[coworker.claude-impl]
session_backend = "tmux"
tmux_session = "sample-project-claude-impl"
# Optional explicit overrides — defaults shown:
# enabled = true
# priority_allowlist = ["blocker", "high"]
# topic_allowlist = [] # empty/unset means "any topic"
# from_agent_allowlist = [] # empty/unset means "any sender"
# cooldown_seconds = 300
# max_attempts = 3
# max_body_chars = 800
# delivery_lease_seconds = 60
# submit_retries = 2 # extra Enter keystrokes if a paste stays pending
# settle_seconds = 1.5 # wait before each submit-confirmation retry
# observe_lines = 120 # pane tail captured for state detection
# companion_cooldown_seconds = 60 # throttle for repeated upstream status posts
# needed_from = "human"
# report_working = false # also post working heartbeats upstream
# leased_by = "coworker-host-a" # multi-machine identity
# node = "mac-studio" # stamped into audit metadata
[coworker.codex]
session_backend = "tmux"
tmux_session = "sample-project-codex"
```
**Audit statuses.** Every wake decision writes a structured status so an
operator can see why the pane was or was not woken:
- `coworker_nudged` — verified submission; the relay message was acked.
- `coworker_ignored` — the message did not match the priority/topic/from
filters (deliberately not high-signal for this agent), distinct from
suppressed so "not for me" is visible apart from "holding back".
- `coworker_suppressed` — held back before any tmux side effect.
`metadata.reason` is one of `agent_disabled`, `not_addressed_to_agent`,
`duplicate`, `pane_blocked`, `cooldown`, `attempt_cap`, `claim_contended`,
or `lease_lost_pre_nudge`.
- `coworker_failed` — the tmux call ran but did not verify
(`verification_failed`), `send_tmux_nudge` raised (`nudge_exception`, payload
redacted to the exception class / return code), or the lease was reassigned
during the call (`claim_reassigned`, with `duplicate_side_effect_risk`).
- `coworker_escalated` — emitted exactly once per message id when the
per-message attempt cap is reached; subsequent ticks fall through to
`coworker_suppressed` / `reason = "attempt_cap"`.
Upstream pane observations reuse the terminal-companion vocabulary
(`input_required` / `working`) and carry `metadata.source = "terminal_companion"`.
**Same-machine and multi-machine.** Run one `agent-relay coworker` process on
each host that owns at least one tmux session. The relay-backed
`inbox_deliveries` lease (shared with `inbox-deliver`/`actuator`) is the single
point of mutual exclusion: the losing caller sees `coworker_suppressed` /
`reason = "claim_contended"`. Set `leased_by` and `node` per host so the audit
records which host won the claim and which node observed/woke the pane. The
same bounded at-least-once caveat as the actuator applies — keep
`delivery_lease_seconds` strictly greater than the worst-case `send_tmux_nudge`
duration.
**Bootstrap cutoff.** Per-agent `since_id` cursors persist in the state file so
a restart does not replay an old backlog into the pane. `--start-fresh`
snapshots each agent's current max inbox id on first run (no-op if a cutoff is
already persisted), `--since-id N` sets an explicit cutoff, and
`--drain-backlog` clears the cutoff to deliberately replay history. The three
flags are mutually exclusive.
`--dry-run` exercises the full observe / filter / claim / audit chain without
injecting into tmux — useful for validating a `[coworker.<agent>]` block before
letting it write into a pane.
**Split commands remain available for debugging.** The `agent-relay companion`,
`agent-relay inbox-deliver`, and `agent-relay actuator` commands documented
below are still supported and are the right tool when you want to run, inspect,
or troubleshoot a single stage in isolation. The coworker daemon is the
composed default; the split recipe is the debugging path.
### Terminal Companion
Some agent CLIs can still pause outside the relay, especially on local tool or
command permission prompts. The terminal companion reports those local blocked
states back into the relay as `input_required` statuses. It does not approve
prompts or type into the agent session.
`tmux` is the default adapter because it provides reliable pane capture and
works across local terminals and SSH sessions. Other terminals can use the
`process-log` adapter by launching the agent through a wrapper that writes a
log file.
When you need to wake a tmux-hosted agent, use the relay nudge helper instead
of raw `tmux send-keys`. It sends literal text and Enter as separate tmux
operations, then verifies that the text is not still sitting at the prompt:
```bash
agent-relay nudge \
--tmux-session sample-project-claude \
--message "Please check agent-lan-relay-client and claim task #14."
```
The command returns JSON with `sent`, `submitted`, `verified`,
`pending_input_after`, and `submit_attempts`. Use `--no-submit` only when
deliberately staging text for manual editing.
**Submit-confirmation retries (no manual nudge).** Pasting a packet into a
harness sometimes lands the text at the prompt without the harness accepting
submission on the first Enter — Claude collapses a large paste into a
`[Pasted text #N]` chip, and that chip can sit unsubmitted. To stop a human
from having to press Enter, the nudge can retry submission: after the first
Enter it recaptures the pane and, while the original packet is still pending,
waits a short settle window, **recaptures and re-checks the pane, and only
then presses Enter again** — up to a configured limit:
```bash
agent-relay nudge \
--tmux-session sample-project-claude \
--message "Please check agent-lan-relay-client and claim task #14." \
--submit-retries 2 \
--settle-seconds 1.5
```
`submit_attempts` in the JSON records how many Enter keystrokes were actually
sent. The re-check after the settle wait is a safety boundary: if the first
Enter was accepted late and the pane moved on during the window (a permission
or continue prompt, a fresh input box), the retry Enter is **not** sent, so a
stray `C-m` cannot auto-confirm a later prompt. If prompt input is still
pending after the final attempt, `submitted` stays `false` so the caller can
**fail closed** instead of treating an un-sent prompt as delivered. The raw
`nudge` command defaults to single-shot (`--submit-retries 0`); the delivery
loops below default to retrying.
When a settle window is configured, the capture taken the instant after the
first Enter is treated as **provisional**: Claude can briefly clear the pasted
chip and then redraw it once the harness finishes the keypress, so an immediate
"cleared" capture is not yet trustworthy. The nudge settles once and recaptures
to confirm before declaring success — a delayed chip redraw resurfaces as
pending and feeds the retry loop, while a genuinely submitted pane stays clear
and **no extra Enter is sent**. This closes the issue #72 false-negative where
a delivery reported `inbox_delivered` with `submit_attempts=1` while the chip
still sat pending until a human pressed Enter. The single-shot default
(`--settle-seconds 0`) keeps the raw debug nudge capture-is-authoritative.
Run one tmux check:
```bash
agent-relay companion \
--project sample-project \
--agent claude \
--adapter tmux \
--tmux-session sample-project-claude \
--needed-from codex \
--once \
--json
```
Run tmux continuously:
```bash
agent-relay companion \
--project sample-project \
--agent claude \
--tmux-session sample-project-claude \
--needed-from codex \
--interval 10 \
--cooldown 60
```
Run against a process log for iTerm, Warp, Ghostty, or another terminal started
through a logging wrapper:
```bash
agent-relay companion \
--project sample-project \
--agent claude \
--adapter process-log \
--log-file "$HOME/.agent-lan-relay/sample-project-claude.log" \
--needed-from codex \
--interval 10
```
Use `--report-working` if you also want pane activity to refresh `working`
heartbeats. Without it, the companion only reports blocked states.
The companion also detects typed input left at the agent prompt, such as a
message that was sent but not submitted. If that pending input remains visible
for longer than the companion cooldown, it reports `input_required` with
`metadata.reason = "pending_terminal_input_not_submitted"`.
#### Inbox Delivery (relay -> tmux)
The terminal companion reports blocked states **upstream** into the relay. The
opposite direction — pushing high-signal messages **down** into a Claude/Codex
tmux pane so an idle session actually wakes when QA or a blocker arrives — is
handled by `agent-relay inbox-deliver`. It polls unacked messages from the
relay inbox, evaluates a safe filter chain, and injects approved messages via
the same `send_tmux_nudge` helper that `agent-relay nudge` uses.
```bash
agent-relay inbox-deliver \
--project sample-project \
--agent claude-impl \
--tmux-session sample-project-claude \
--state-file "$HOME/.agent-lan-relay/inbox-delivery-claude-impl.json" \
--priority blocker,high \
--topic 'qa/*,blocker/*' \
--interval 15
```
Defaults:
- Priority allowlist: `blocker, high`. Pass `--priority` (repeatable or
comma-separated) to override.
- Optional `--from-agent` and `--topic` allowlists. `--topic` accepts fnmatch
globs (`qa/*`, `blocker/*`).
- Body truncation at `--max-body-chars` (default 800) with a footer pointing
back to the full relay message id.
- Per-message cooldown `--cooldown 300s` and attempt cap `--max-attempts 3`,
both persisted to `--state-file` so a restart does not re-inject already
delivered messages or reset attempt counts.
- Submit-confirmation retries `--submit-retries 2` and `--settle-seconds 1.5`
(defaults). Within a single delivery, if the pasted packet is still pending
at the prompt after the first Enter, the loop waits the settle window and
presses Enter again up to `--submit-retries` more times before giving up.
This is distinct from `--max-attempts`, which bounds *separate* delivery
ticks across cooldowns. Set `--submit-retries 0` to restore single-shot
submission.
- Use `--dry-run` to validate filters end-to-end without ever touching tmux —
audit statuses and dedup state are still written.
A relay message is acked only after `send_tmux_nudge` reports a verified
submission. Failed verification (typed text still sitting at the prompt, even
after the configured submit-confirmation retries) leaves the message unacked
and **fails closed** — the relay-backed claim is released and the next tick
retries it within the cooldown / attempt budget. The `inbox_delivered` and
`inbox_delivery_failed` audit rows record `metadata.submit_attempts` (how many
Enter keystrokes were sent) plus `submit_retries` / `settle_seconds`, so the
retry state is observable without logging the body. Every decision posts an audit `status` (`inbox_delivered`,
`inbox_delivery_suppressed`, or `inbox_delivery_failed`) so operators can see
exactly why a message was or was not pushed into the pane. Failure-path
payloads are redacted before persistence: the failed audit row records only
the exception class name (`metadata.error_class`) and, for
`subprocess.CalledProcessError`, the return code in `detail`, never the
default `repr`/`str` (which carries the full tmux argv and therefore the
formatted relay body). The same redacted summary is written to
`inbox_deliveries.last_error`. `metadata.pending_input_after` is a boolean
(presence only), not the typed substring.
**Multi-actuator safety.** The actuator queries a true inbox view
(`to_agent = agent OR to_agent = 'both'`) so it never injects an agent's own
outbound traffic back into the same pane. Before any tmux side effect, the
actuator wins an atomic relay-backed claim against the new `inbox_deliveries`
table (`claim_inbox_delivery` issues a fencing-style lease token; the second
caller for the same `(project, message_id, agent)` is rejected until the first
completes or releases). Two actuators on different machines that pick up the
same unacked message therefore agree on a single live writer in the common
case — the loser sees `inbox_delivery_suppressed` with
`metadata.reason = "claim_contended"` and never invokes the nudge.
`--delivery-lease-seconds` (default 60s) controls how long the claim is held
before another actuator can take over after a crash; keep it longer than the
worst-case nudge+verify but short enough that a dead actuator does not block
delivery for long.
Delivery semantics for `inbox-deliver` are **bounded at-least-once**, not
exactly-once. Unlike the safe actuator path (`agent-relay actuator`), the
older `inbox-deliver` loop does NOT heartbeat the lease just before
invoking `send_tmux_nudge`, so the lease can expire either between
`claim_inbox_delivery` and the nudge call, or during a hung
`send_tmux_nudge` itself. If that happens, a second actuator can reclaim
the row and inject the same relay message a second time. The original
caller is then rejected at `complete_inbox_delivery` and surfaces
`inbox_delivery_failed` with `metadata.reason = "claim_reassigned"`. To
keep the duplicate-injection window observably tiny, operators MUST keep
`--delivery-lease-seconds` strictly greater than the worst-case
`send_tmux_nudge` duration, and single-writer-per-agent (one
`agent-relay inbox-deliver` process per agent host) is the recommended
posture; multi-host fanout is supported but accepts this bounded
duplicate-injection risk under pathological nudge hangs. The newer
`agent-relay actuator` dispatcher narrows the same race further with a
just-in-time `heartbeat_inbox_delivery` call before the nudge — see the
Safe Terminal Actuator section below for details.
**Bootstrap cutoff (fresh start vs. backlog drain).** A fresh `inbox-deliver`
process pointed at a long-running project would otherwise blast every
unacked historical blocker into the pane. The actuator carries a persisted
`since_id` cutoff (stored alongside the dedup map in `--state-file`) and
the relay-side query already filters with `id > since_id`, so pre-cutoff
rows never enter the per-message filter/cooldown loop. Three mutually
exclusive flags drive the cutoff:
- `--start-fresh` snapshots `latest_inbox_message_id` at startup and seeds
the cutoff. It is a no-op if the state file already has a cutoff, so
restarting after a crash never silently advances past messages that
arrived during the outage.
- `--since-id <int>` overrides any persisted cutoff with an explicit value.
Use it to seek the actuator past a known-handled message without losing
later traffic.
- `--drain-backlog` clears the persisted cutoff so historical messages are
deliberately replayed (e.g. an operator wants to deliver a backlog of
blockers after rolling out the actuator).
Without any of those flags the actuator preserves the legacy behaviour of
considering every unacked inbox message — recommended only when the inbox
is small or the project is brand new.
**No-manual-nudge E2E proof.** The deterministic push-mode scenario is covered
without real tmux, Claude/Codex credentials, or network calls:
```bash
python -m pytest tests/e2e/test_no_manual_nudge.py -q
```
The test creates an assigned-but-unclaimed task, runs the assignment watchdog,
delivers the resulting `orchestrator/assignment-stale` inbox message through a
fake nudge adapter, simulates the agent claiming/completing the task, and
asserts that a reviewer handoff reaches the reviewer inbox.
#### Safe Terminal Actuator
`agent-relay actuator` is the multi-agent dispatcher that closes the loop
between the assignment watchdog and the local agent pane. The orchestrator
emits `orchestrator/assignment-stale` messages when an assigned task sits
unclaimed; the actuator reads those messages, routes them to a per-agent
session backend, and performs the configured terminal action — today only
`assignment_nudge`, which formats the relay message and injects it into the
agent's tmux pane via the same `send_tmux_nudge` helper used by
`agent-relay nudge`.
```bash
agent-relay actuator \
--project sample-project \
--config "$HOME/.agent-lan-relay/config.toml" \
--state-file "$HOME/.agent-lan-relay/actuator-state.json" \
--interval 15 \
--watch
```
Routing is **opt-in per agent** via `[actuator.<agent>]` blocks in the relay
TOML config. There is no global default; if an agent has no block, that
agent is not actuated.
```toml
[actuator.claude-impl]
session_backend = "tmux"
tmux_session = "sample-project-claude-impl"
# Optional explicit overrides — defaults shown:
# enabled = true
# priority_allowlist = ["blocker", "high"]
# action_allowlist = ["assignment_nudge"]
# topic_allowlist = [] # empty/unset means "any known topic"
# from_agent_allowlist = [] # empty/unset means "any sender"
# cooldown_seconds = 300
# max_attempts = 3
# max_body_chars = 800
# delivery_lease_seconds = 60
# submit_retries = 2 # extra Enter keystrokes if a paste stays pending
# settle_seconds = 1.5 # wait before each submit-confirmation retry
# leased_by = "actuator-host-a" # for multi-machine deployments
[actuator.codex]
session_backend = "tmux"
tmux_session = "sample-project-codex"
```
Conservative defaults:
- `priority_allowlist = ["blocker", "high"]` so a noisy `low`/`normal`
traffic stream cannot trigger pane interruptions.
- `action_allowlist = ["assignment_nudge"]`. The actuator will not approve
permission prompts (a separate `permission_policy` evaluator handles that
and is intentionally not yet wired into the dispatcher), will not auto-claim
tasks, and will not run shell commands.
- `enabled = false` keeps a row visible in the audit (one
`actuator_suppressed` per pending message with
`metadata.reason = "agent_disabled"`) without firing the action.
**Same-machine deployment.** Run a single `agent-relay actuator` process on
the host that owns the agent's tmux session. Each `[actuator.<agent>]`
block points at the local tmux target, so one process can drive multiple
local agents. Pair with `agent-relay companion` so the upstream side
(pane → relay) and downstream side (relay → pane) share the same host.
**Multi-machine deployment.** Run one `agent-relay actuator` process on each
host that owns at least one tmux session. Either give each host a config
that lists only the agents it owns, or share a single config across hosts.
The relay-backed `inbox_deliveries` lease (the same one used by
`inbox-deliver`) is the single point of mutual exclusion between two actuator
processes that pick up the same `(project, message_id, agent)`: the second
live caller sees `actuator_suppressed` with `metadata.reason = "claim_contended"`.
Set `leased_by` per host (e.g. `leased_by = "actuator-host-a"`) so the audit
identifies which host won the claim.
Delivery semantics are **bounded at-least-once**, not exactly-once. The
actuator heartbeats the lease immediately before invoking `send_tmux_nudge`,
so a lease expiring while the dispatcher is between claim and nudge is caught
and the late writer suppresses with `metadata.reason = "lease_lost_pre_nudge"`
*before* any tmux side effect. The remaining race is a nudge that itself
outlives `delivery_lease_seconds` (a hung tmux call): the lease can expire
while the side effect is in flight, a second actuator can reclaim, and two
tmux injections can happen for the same relay message. Operators MUST keep
`delivery_lease_seconds` strictly greater than the worst-case
`send_tmux_nudge` duration (default lease is 60s; the verify/poll loop is
sub-second on a healthy pane). When this race does fire, the original caller
is rejected at `complete_inbox_delivery` and surfaces `actuator_failed` with
`metadata.reason = "claim_reassigned"` and
`metadata.duplicate_side_effect_risk = true` so duplicates are observable
rather than silent. Single-writer-per-agent (one `agent-relay actuator`
process per agent host) is still the recommended posture; multi-host fanout
is supported but accepts this bounded duplicate-injection risk during
nudge_fn pathological hangs.
**Audit statuses.** Every decision writes a structured status to the relay
so operators can see why a message was or was not delivered:
- `actuator_nudged` — `send_tmux_nudge` reported a verified submission and
the relay message was acked.
- `actuator_suppressed` — filtered out *before* any tmux side effect.
`metadata.reason` is one of `agent_disabled`, `not_addressed_to_agent`,
`duplicate`, `unknown_action`, `action_not_allowed`, `filtered`,
`cooldown`, `attempt_cap`, `claim_contended`, or `lease_lost_pre_nudge`
(the just-in-time heartbeat caught an expired lease before the nudge
ran, so the late writer suppressed without injecting; another actuator
is now the rightful writer).
- `actuator_failed` — tmux call ran but did not verify (`verification_failed`)
or `send_tmux_nudge` raised (`nudge_exception`). The message stays unacked
so the next tick can retry within the cooldown / attempt budget. A third
variant, `claim_reassigned`, is post-nudge: the side effect already
happened on this host but `complete_inbox_delivery` rejected the lease
token because it expired during `send_tmux_nudge` and another actuator
reclaimed. When this fires, `metadata.duplicate_side_effect_risk = true`
so operators can grep audit for the bounded duplicate-injection window.
Exception payloads are redacted before persistence: the actuator records
only the exception class name (and return code for
`subprocess.CalledProcessError`) in `detail`, `metadata.error_class`, and
`inbox_deliveries.last_error`. The default `repr` for
`CalledProcessError` carries the full argv — which contains the formatted
relay-message body — so it is intentionally never persisted.
`metadata.pending_input_after` is a boolean (presence only), not the
typed substring, for the same reason.
- `actuator_escalated` — emitted exactly once per message id when the
per-message attempt cap is reached. Subsequent ticks fall through to
`actuator_suppressed` with `metadata.reason = "attempt_cap"` so the cap
signal stays observable without filling the audit log.
Every audit row carries `metadata.action`, `metadata.session`,
`metadata.session_backend`, `metadata.source_message_id`, and (when the
source message exposes it) `metadata.source_task_id` so an operator can
correlate the actuator decision back to the relay event that triggered it.
`--dry-run` exercises the full filter/claim/audit chain without invoking
tmux — useful for validating a new `[actuator.<agent>]` block before
letting it write into a pane.
#### Permission Prompt Policy
The terminal companion only reports permission prompts; it never approves
them. `permission_policy.py` adds a pure declarative evaluator so an
operator can opt narrow, audited rules into auto-approving low-risk
prompts (e.g. `gh repo view`) without unlocking destructive actions.
**Default behaviour is unchanged.** With no rules configured, every
permission prompt still escalates as `input_required`. Rules are added
to `~/.agent-lan-relay/config.toml` under `[permission_policy.<rule>]`:
```toml
# Example only — NOT enabled by default. Copy into your config.toml and
# review carefully before relying on it. Each rule should be the
# narrowest scope that does what you need.
[permission_policy.gh-repo-view]
agent = "claude-impl"
reason = "gh repo view is read-only and safe for public repo browsing"
action = "approve_once"
command_prefix = "gh repo view"
require_read_only = true
max_per_window = 30
window_seconds = 300
[permission_policy.gh-api-public-contents]
agent = "claude-impl"
reason = "gh api repos/.../contents is read-only public-doc fetch"
action = "approve_once"
command_regex = '^gh api repos/[^/]+/[^/]+/contents/'
require_read_only = true
max_per_window = 60
window_seconds = 300
[permission_policy.curl-raw-githubusercontent]
agent = "claude-impl"
reason = "public docs from raw.githubusercontent.com only"
action = "approve_once"
command_regex = '^curl(\s+-[a-zA-Z]+)*\s+https://raw\.githubusercontent\.com/'
require_read_only = true
max_per_window = 30
window_seconds = 300
```
Field reference:
- `agent` — the relay agent identity the rule applies to. A rule for
`claude-impl` is silently skipped when evaluating a Codex prompt.
- `reason` — required free-form string. Operator-visible in every
audit entry; useful in code review when adding/removing rules.
- `action` — `approve_once` or `deny`. `approve_always` is rejected
by the parser. Each approval is for exactly one prompt; the next
matching prompt re-runs the evaluator.
- `command_prefix` *or* `command_regex` — exactly one is required for
`approve_once`. `command_prefix` is matched as an argv-token prefix
(both the prefix and the command are `shlex`-parsed, then compared
element-wise), so `command_prefix = "gh repo view"` does not approve
`gh repo viewevil` or `gh repo view-stuff`. `command_regex` is
searched against the joined command and the raw prompt text — anchor
it tightly with `^`.
- `require_read_only` — default `true`. When set, the evaluator refuses
to approve any command whose text contains a known-destructive token
(`rm`, `--force`, `git reset`, `gh repo delete`, `kill`, `chown`,
etc.) even if the matcher would otherwise allow it. The token list
lives in `permission_policy.KNOWN_DESTRUCTIVE_TOKENS` and is
intentionally conservative — when in doubt, take the manual approval
rather than expanding the list.
- `max_per_window` / `window_seconds` — rolling rate limit per rule.
`max_per_window = 0` disables the limit; otherwise the rule denies
additional approvals once the window is full and emits
`denied_rate_limited` in the audit. **The counter is advisory and
process-local**: a restart, a second concurrent actuator, or a
second `PermissionPolicy` instance each get their own budget, so the
effective ceiling can be exceeded across processes. Treat this as a
soft cap that bounds runaway loops within a single actuator. Run
inbox-delivery / future actuation single-writer per agent until
relay-backed atomic counters land in a follow-up issue.
- `enabled` — default `true`. Disable a rule with `enabled = false` to
retire it without losing audit history; matched-but-disabled prompts
emit `denied_disabled_rule`.
Each call returns a `PolicyDecision` with `outcome` ∈ `approved_once`,
`denied_by_rule`, `denied_default`, `denied_disabled_rule`,
`denied_unsafe_command`, `denied_rate_limited`, `denied_invalid_prompt`,
or `no_matching_rule`. The `audit_metadata` dict is shaped to be
embedded directly in a relay status / message metadata block so an
operator can review every approval the actuator made on their behalf.
**Security guidance.** Avoid wide allowlists like `command_prefix = ""`,
`command_prefix = "git"`, or `command_regex = ".*"` — those defeat the
point of the policy. Prefer the smallest prefix that captures the safe
shape, keep `require_read_only = true`, and review rule additions like
you would review a CI permission grant.
`command_prefix` matching is **argv-aware** (parsed via `shlex`), so a
rule of `command_prefix = "gh repo view"` will *not* approve
`gh repo viewevil` or `gh repo view-stuff` — every prefix token must
match the corresponding command token verbatim. `command_regex`
authors must anchor their pattern explicitly (e.g. with `^`); regexes
are searched against both the command and the raw prompt text, so a
loose pattern like `view` would accidentally approve any pane that
mentions the word "view" anywhere. The destructive-token sweep
normalizes executable basenames (`/bin/rm` → `rm`) and splits grouped
short flags (`-rf` → `-r`, `-f`), so `/bin/rm -rf /tmp/x` is rejected
even when the matcher would otherwise allow it. The deliberate
omission of `approve_always` is part of the safety contract; do not
work around it.
This release ships the evaluator and config schema only. Wiring the
evaluator into a future terminal actuator (so an approved decision
actually types `y` into the pane) is tracked separately so the policy
layer can be reviewed in isolation first.
#### Structured Harness Adapters
For harnesses that already speak a structured event protocol over stdout, the
companion can skip terminal/pane scraping and consume JSONL frames directly.
Two adapters are built in:
- `kiro-acp` parses Kiro ACP frames (`session/start`, `agent/message`,
`tool/call`, `tool/result`, `session/input_required`, `session/complete`,
`session/error`).
- `pi-rpc` parses Pi RPC/JSON frames keyed by `method`
(`agent/started`, `agent/output`, `agent/tool` with `phase: call|result`,
`agent/input_required`, `agent/result`, `agent/error`).
Both adapters normalize lifecycle events into the same relay statuses the
terminal companion produces. `input_required` and `failed` events post
`input_required` (with `metadata.needed_from`, `metadata.question`, and
`metadata.event_kind`); `started`, `output`, `tool_call`, and `completed`
post `working` only when `--report-working` is set. Status metadata is
stamped with `source: structured_adapter` and `adapter: kiro-acp|pi-rpc`
so the existing Symphony Light orchestrator routes nudges unchanged.
Run against a Kiro ACP binary:
```bash
agent-relay companion \
--project sample-project \
--agent kiro \
--adapter kiro-acp \
--harness-argv 'kiro-cli acp --session sample-project' \
--harness-cwd "$HOME/projects/sample-project" \
--needed-from codex
```
Run against a Pi RPC binary:
```bash
agent-relay companion \
--project sample-project \
--agent pi \
--adapter pi-rpc \
--harness-argv '["pi", "rpc", "--session", "sample-project"]' \
--needed-from codex
```
`--harness-argv` accepts either a JSON array of strings (recommended for
spaces or quoting) or a shell-style string parsed with `shlex`. Use
`--harness-cwd` to set the working directory for the spawned process, and
`--task-id` / `--run-id` to stamp the corresponding ids into status
metadata for downstream correlation.
For deterministic tests or replay, point at a JSONL fixture instead of a
process:
```bash
agent-relay companion \
--project sample-project \
--agent kiro \
--adapter kiro-acp \
--input-stream tests/fixtures/kiro_session.jsonl \
--needed-from codex
```
`--harness-argv` and `--input-stream` are mutually exclusive; one of them
is required when `--adapter` is `kiro-acp` or `pi-rpc`. The terminal
companion remains the fallback for harnesses that do not emit structured
frames yet.
### Autonomous Workflow Tasks
Create a task with `metadata.workflow = "autonomous"` and a stage list to let
Symphony Light dispatch a coder/QA/reviewer chain without manual routing:
```json
{
"project": "sample-project",
"title": "Implement feature",
"body": "Build the feature, run focused tests, and report risks.",
"created_by": "claude",
"metadata": {
"workflow": "autonomous",
"repo": "example-org/sample-project",
"branch": "feature/autonomy",
"stages": [
{"name": "code", "agent": "codex", "command": "codex-work", "node": "relay-host-1", "capability": ["docker", "gpu"]},
{"name": "qa", "agent": "codex", "command": "codex-qa"},
{"name": "review", "agent": "claude", "command": "claude-review"}
]
}
}
```
On each orchestration pass, Symphony Light:
1. Queues the first missing stage as an allowlisted `agent_runs` request.
2. Waits while any stage run is `pending` or `running`.
3. Dispatches the next stage after the prior stage completes, including the
previous stage output in the prompt.
4. Completes the task after the final stage completes.
5. Sends `orchestrator/workflow-failed` to the task creator if a stage fails or
is rejected.
Stage commands must still be allowlisted in the target machine's bridge config.
This keeps autonomous routing separate from local execution permission.
The relay store keeps workflow scheduling fields (`workflow_task_id`,
`workflow_stage_index`, `workflow_stage_name`, `workflow_total_stages`,
`source_run_id`, `retry_of_run_id`) in indexed `agent_runs` columns rather
than a JSON blob. Stage lookup runs against a composite index instead of a
full-scan `json_extract(...)`, and a partial unique index prevents two
concurrent orchestrator ticks from dispatching the same
`(workflow_task_id, workflow_stage_index)` while it is still pending or
running. Terminal rows (completed/failed/rejected) are excluded from the
guard, so retrying a failed stage stays legal. Existing SQLite databases are
migrated in place: new columns are added with `ALTER TABLE`, and a JSON-typed
backfill copies pre-existing metadata values into the columns. The
`metadata_json` blob keeps the same keys for backwards compatibility.
### Workflow Templates
Inline `metadata.stages` keeps working unchanged, but for repeatable pipelines
you can define reusable templates in the same `~/.agent-lan-relay/config.toml`
that powers the bridge:
```toml
[workflow_templates.review]
description = "Coder + QA review pipeline"
repo = "example-org/sample-project"
branch = "main"
default_title = "Run review pipeline"
default_body = "Implement, QA, then review."
[[workflow_templates.review.stages]]
name = "code"
agent = "claude"
command = "claude-work"
node = "relay-host-1"
capability = "docker"
[[workflow_templates.review.stages]]
name = "qa"
agent = "qa"
command = "qa-review"
capabilities = ["docker", "gpu"]
```
Each stage table accepts the same fields as inline workflows: `name`, `agent`,
`command`, `prompt`, `repo`, `branch`, `node`, plus `capability` (string or
list) and/or `capabilities` (list). Templates are validated when loaded — a
malformed template raises a clear error before any task is created.
#### Agent Profiles
Repeating `agent = "claude"` + `command = "claude-impl"` (and a model
preference, harness, capabilities…) on every stage is busy-work. V1 of
the autonomous profile registry (GitHub #31, #33) lets you pull those
routing facts up into a named bundle and reference it from a stage:
```toml
[agent_profiles.implementation]
role = "implementation_engineer"
agent = "claude"
command = "claude-impl"
model = "us.anthropic.claude-opus-4-8"
fallback_models = ["us.anthropic.claude-opus-4-7", "gpt-5.4"]
harness = "claude-code"
capabilities = ["repo:relay", "python"]
prompt_template = "implementation/default"
system_prompt_template = "system/relay-agent"
[agent_profiles.qa]
role = "qa_reviewer"
agent = "codex"
command = "codex-qa"
fallback_profiles = ["implementation"]
[workflow_templates.review]
[[workflow_templates.review.stages]]
name = "code"
profile = "implementation"
[[workflow_templates.review.stages]]
name = "qa"
profile = "qa"
# Stage-level fields override the profile when both are present:
capability = "gpu"
```
Profile resolution is **static** (config-time): the profile's `agent`,
`command`, `role`, `model`, `fallback_models`, `harness`,
`prompt_template`, and `system_prompt_template` are merged into the
stage dict, and the profile's `capabilities` list flows through to the
stage's `capability` field. A stage may override any field by setting
it directly. Stages without a `profile = "..."` key behave exactly as
before — the registry is opt-in.
`fallback_models` flows from the profile through to the resolved
stage dict so the V2 dispatcher (live retry / capability fan-out) can
read it without a schema change. `fallback_profiles` is parsed and
retained on the `AgentProfile` object only — V1 deliberately does not
copy it into the stage dict, since the dispatcher has no notion of
inter-profile fallback yet and pulling it through now would lock in a
stage-shape choice V2 might regret. The V1 dispatcher does not yet act
on either field.
A stage that names a profile that is not defined in
`[agent_profiles.<name>]` raises a clear error at `load_templates`
time, before any task is created.
CLI:
```bash
agent-relay workflow list
agent-relay workflow show review
agent-relay workflow start \
--project sample-project \
--template review \
--created-by human \
--task-title "Implement Phase 1" \
--task-body "Implement the Phase 1 plan and report findings."
agent-relay workflow status --project sample-project
agent-relay workflow status --project sample-project --task-id 42
```
`workflow start` materializes the template into the task's `metadata.stages`,
so the orchestrator dispatches it through the same autonomous-workflow path
documented above. The created task carries `metadata.workflow_template = "<name>"`
so `workflow status` can label it.
### Workflow Retry
When a workflow stage fails or is rejected, the orchestrator escalates the
failure once and stops dispatching further stages. To recover, queue an explicit
retry of the failed stage:
```bash
agent-relay workflow retry \
--project sample-project \
--task-id 42 \
--from-stage qa \
--reason "address QA findings"
```
`--from-stage` accepts either a stage name or a numeric index. Numeric values
are zero-based by default (`--from-stage 1` retries the second stage). Pass
`--one-based` to use one-based indexing instead. Pass `--reason` to record a
short human-readable note in the retry run's metadata and prompt.
A retry creates a new agent run for the same `workflow_stage_index` with these
metadata fields stamped on it:
- `retry_of_run_id` — the run id whose failure/rejection triggered the retry
- `retry_attempt` — 1-based attempt counter for the stage (the original counts as 1)
- `retry_reason` — the optional `--reason` text (omitted when not provided)
- `workflow_task_id`, `workflow_stage_index`, `workflow_stage_name`,
`workflow_total_stages` — same identifiers used by the orchestrator
- `node` and `capability` are propagated from the template stage when set
Older runs are never deleted or mutated. `workflow status` returns every run in
order (including failed/rejected attempts) and tags the retried runs with their
`retry_of_run_id`, `retry_attempt`, and `retry_reason` so reviewers can read the
full lineage. Retries refuse to queue while the latest run for the stage is
still `pending` or `running`, and refuse to "retry" a stage whose latest run is
`completed`.
The orchestrator considers only the **latest** run per stage when deciding
whether to advance the workflow, so once a retry completes successfully the
next stage dispatches automatically — workflows without retries behave exactly
as before.
## Harness Registry
The harness registry is the stable contract that future bridge/orchestration
work (issues #4–#8) will use to drive heterogeneous agent harnesses without
hard-coding per-tool launch logic in the bridge. This release introduces the
**registry/config model and inspection CLI only** — actual MCP setup, ACP/RPC
adapters, Slack notifications, ticketing sync, and cloud-runtime/gateway
plumbing are tracked separately and will land on top of this contract.
Harnesses are declared as `[harnesses.<name>]` tables in the same
`~/.agent-lan-relay/config.toml` that powers the bridge and workflow templates,
so the relay still loads from a single stdlib-`tomllib` file.
### Supported families
The `kind` field is required and validated against this enum:
- `terminal` — Claude, Codex, Pi, Kiro running as plain CLI processes
- `mcp-capable` — Claude, Codex, Pi, Kiro driven through MCP (stdio or HTTP)
- `acp` — Kiro Agent Communication Protocol harness
- `rpc-json` — Pi or other JSON-RPC harnesses
- `framework` — LangGraph, Strands, or other Python framework runners
- `cloud-runtime` — Bedrock Agents, Bedrock AgentCore, or similar managed runtimes
- `gateway` — deprecated compatibility adapter for OpenClaw/MeshClaw
gateway-style facades. Prefer relay-harness pull mode for MeshClaw and
OpenClaw.
### Schema
Common fields per harness:
- `kind` (required) — one of the families above
- `agent` (required) — the relay-side agent id this harness backs (e.g.
`claude`, `codex`, `kiro`, `pi`, `bedrock-agent`, `openclaw`)
- `description` — free-form one-liner shown by `harness list`/`show`
- `mode` — adapter mode hint for later issues (e.g. `mcp-stdio`, `acp-stdio`,
`rpc-json`, `langgraph`, `bedrock-agents`, `openclaw`)
- `enabled` (default `true`) — disable a harness without removing the table
- `node` — bridge node identity this harness should run on
- `capabilities` — string or list of strings; deduped at parse time
- `command` and/or `argv` — required for `terminal`/`mcp-capable`/`acp`/
`rpc-json`/`framework` (one of the two must be present)
- `cwd` — optional working directory for command-backed harnesses
- `env` — table of `KEY = "value"` strings; numbers/booleans rejected
- `endpoint` — required for `cloud-runtime` and deprecated `gateway` kinds
- `[harnesses.<name>.mcp]` table for MCP-capable harnesses, with
`config_path` and `scope` (`session`, `project`, or `user`)
Example config covering several families:
```toml
[harnesses.claude]
kind = "mcp-capable"
agent = "claude"
description = "Claude Code with relay client MCP"
command = "claude"
mode = "mcp-stdio"
node = "relay-host-1"
capabilities = ["docker"]
[harnesses.claude.mcp]
config_path = "~/.agent-lan-relay/sample-project-claude-mcp.json"
scope = "session"
[harnesses.kiro]
kind = "acp"
agent = "kiro"
description = "Kiro ACP harness"
command = "kiro"
mode = "acp-stdio"
[harnesses.langgraph_demo]
kind = "framework"
agent = "langgraph"
command = "python"
argv = ["-m", "demo.langgraph_runner"]
mode = "langgraph"
[harnesses.bedrock_agent]
kind = "cloud-runtime"
agent = "bedrock-agent"
mode = "bedrock-agents"
endpoint = "https://bedrock-agent-runtime.us-east-1.amazonaws.com"
```
Deprecated gateway compatibility config:
```toml
[harnesses.openclaw]
kind = "gateway" # deprecated; prefer apps/openclaw-relay-harness pull mode
agent = "openclaw"
mode = "openclaw"
endpoint = "https://openclaw.example.com/api"
```
Deprecated optional block for gateway taskrunners that require a remote
spec-file path instead of accepting inline `spec_content` POSTs:
```toml
[harnesses.meshclaw.spec_file]
host = "gateway.example.com"
dir = "/tmp"
```
When this block is set, the relay writes the spec to
`<host>:<dir>/relay-spec-*.md` over SSH and POSTs the path. If the block is
omitted and the gateway returns 400 to the inline POST, the run fails with a
clear configuration error instead of falling back to any hardcoded host. New
MeshClaw/OpenClaw setups should use the relay-harness apps instead, because
they pull runs from the relay and execute locally on the engine host without
gateway POST routing or relay-managed SSH spec writes.
### CLI
```bash
agent-relay harness list [--config ...]
agent-relay harness show <name> [--config ...]
agent-relay harness check [<name>] [--config ...]
```
`harness list` and `harness show` emit JSON with the parsed fields. `harness
check` parses + validates the config and reports per-harness `ok` plus any
non-fatal `warnings` (disabled harness, missing `cwd`, missing
`mcp.config_path`, deprecated gateway config). All three commands fail loudly
with a clear message when the config has structural errors — invalid `kind`,
missing `command`/`argv` for command-backed harnesses, missing `endpoint` for cloud-runtime/gateway,
non-string `capabilities`/`argv`/`env` values, or unknown `mcp.scope`.
### Security checks
Run focused gateway tests and security scans before changing the deprecated
gateway path:
```bash
uv run --with pytest python -m pytest tests/test_gateway.py -q
uv run --with bandit bandit -r src/agent_lan_relay/gateway.py -q
uv run --with pip-audit pip-audit --desc off
```
For a broader source scan, use the tuned command below and inspect any new
findings introduced by the change:
```bash
uv run --with bandit bandit -r src -q --severity-level medium --confidence-level high --skip B310
```
`B310` is skipped in the broad command because the repo has multiple
intentional HTTP API clients; gateway call sites validate `http`/`https`
schemes before `urlopen` and carry local `# nosec B310` annotations. An
untuned broad Bandit scan may also report `B105` on constants such as
`LINEAR_API_KEY`, `GITLAB_TOKEN`, and `JIRA_API_TOKEN`; those are environment
variable names documented for operators, not embedded secret values.
### Launching a safe autonomous session (`harness launch`)
Launching an autonomous agent by concatenating its prompt into an inline shell
command is unsafe: the shell expands the prompt before the harness ever sees
it. Dogfooding (issue #77) hit this twice — a backtick'd
`` `agent-relay tickets list|sync|status` `` was command-substituted by zsh,
and a `[Pasted text #1 +10 lines]` placeholder tripped zsh globbing
(`zsh: bad pattern`). `harness launch` removes the shell from the path
entirely: it reads the prompt from a **file or stdin** (never argv) and builds
an argv vector handed to `tmux new-session -- <argv...>`, which `execvp`s the
program directly. Backticks, brackets, pipes, quotes, `$VARS`, and multi-line
pasted text all reach the harness verbatim.
```bash
# Dry-run first: inspect the resolved argv/session/env with secrets redacted.
agent-relay harness launch \
--session-name claude-worker \
--agent-name claude-worker \
--prompt-file ./task-prompt.md \
--mcp-config ~/.config/claude/relay.mcp.json --strict-mcp-config \
--permission-mode bypassPermissions \
--cwd "$PWD" \
--model us.anthropic.claude-opus-4-8 \
--fallback-model us.anthropic.claude-opus-4-7 \
--dry-run
# Then launch for real (drop --dry-run). Pipe the prompt via stdin instead:
some-generator | agent-relay harness launch --session-name claude-worker \
--prompt-stdin --permission-mode bypassPermissions
```
Key flags:
- `--prompt-file PATH` / `--prompt-stdin` — exactly one; the prompt source.
- `--mcp-config PATH` (repeatable), `--strict-mcp-config` — MCP wiring.
- `--permission-mode {default,acceptEdits,plan,bypassPermissions}` — validated;
use `bypassPermissions` for unattended runs.
- `--cwd`, `--model`, `--fallback-model` (requires `--model`),
`--session-name` (no `:`/`.`), `--agent-name`.
- `--env KEY=VALUE` (repeatable) — extra session env; secret-shaped values are
redacted in output.
- `--replace` — kill an existing session of the same name first; without it, an
existing session is a hard error so a stray duplicate never shadows a running
agent.
- `--tmux-bin` / `--claude-bin` — override executables.
**Secrets.** The relay token is read by name from the launching process's own
environment (`--token-env`, default `AGENT_RELAY_TOKEN`) and forwarded into the
session via `tmux -e`. Its value is **never printed**; `--dry-run` shows it (and
any secret-shaped `--env`) as `[REDACTED]`. One honest caveat: `tmux -e
NAME=VALUE` places the value in the `tmux new-session` process argv, so it is
briefly visible to `ps` on the local host during launch. For an argv-free path,
omit the token here and instead pre-seed it into tmux's session environment
(`tmux set-option -g update-environment AGENT_RELAY_TOKEN` so a value already
exported in your shell is copied into new sessions), or set it from a profile
the harness reads at startup.
**Other harnesses (Codex and beyond).** Only Claude Code is wired end to end
today — its flag set is what issue #77 enumerated. For Codex and other
harnesses, the same safety principle holds: keep the prompt in a file and start
the session with an argv vector (e.g. `tmux new-session -d -s sess -- codex …
"$(cat prompt.md)"` is **not** safe — the `$(…)` is shell-expanded; instead let
the harness read the file directly, or extend `session_launcher.py` with an
argv builder for that harness). `harness launch` rejects an unsupported
`--harness` rather than guessing at an argv that might mis-handle the prompt.
Existing manual `tmux` launch patterns still work; prefer `harness launch` for
anything that carries an agent-authored prompt.
### What this PR does *not* do
This is intentionally a registry-only PR. The harness registry does **not**
yet:
- launch processes or proxy stdio for command-backed harnesses
- speak ACP, RPC-JSON, or any framework's protocol
- create or refresh MCP config files (issue #4)
- post to Slack or sync tickets (issues #6, #7)
- call Bedrock Agents, AgentCore, or OpenClaw endpoints (issues #5, #8)
Those land in follow-up issues that consume this same registry.
## Relay-Harness Apps
Relay-harness pull mode is the preferred path for MeshClaw and OpenClaw.
The legacy gateway adapter remains functional for compatibility, but
`kind = "gateway"` and `[harnesses.<name>.spec_file]` are deprecated. New
setups should run a relay-harness app on the same machine as the engine.
Each app polls the relay's run endpoints, claims pending runs targeted at
its agent identity, executes them locally with the engine's native command
shape, and reports the outcome back via `/api/runs/<id>/complete`.
| App | Engine | Execution |
| ------------------------------------ | --------- | ------------------------------------------------------------------------------- |
| `apps/relay-harness/` | Pluggable | Runs `claude`, `codex`, `meshclaw`, or `kiro` with hardened git delivery policy |
| `apps/meshclaw-relay-harness/` | MeshClaw | Compatibility pointer to `apps/relay-harness/` with `AGENT_BACKEND=meshclaw` |
| `apps/openclaw-relay-harness/` | OpenClaw | Runs `openclaw agent --agent <id> --message <prompt> --json --timeout <s>` |
Both apps share the same env vars (`RELAY_URL`, `RELAY_TOKEN`,
`RELAY_PROJECT`, `RELAY_AGENT`, `POLL_INTERVAL`) and bind their local
HTTP `/health` and `/status` endpoints to `127.0.0.1` only — they are
intended to run on the same machine as the engine and never exposed to
the LAN or public internet. Reach the relay over loopback, an SSH
tunnel, or a private mesh (Tailscale, WireGuard). See each app's
`README.md` for a loopback smoke-test walkthrough and the full env-var
table. See `docs/gateway-to-relay-harness-migration.md` for converting
legacy gateway config to MeshClaw/OpenClaw relay-harness apps.
Gateway removal criteria for a future major/versioned release:
- MeshClaw and OpenClaw relay-harness apps cover the gateway use cases,
including local spec-file execution and result reporting.
- The current release and at least one prior release document the deprecation,
emit actionable config/runtime warnings, and include migration examples.
- Maintainers confirm there are no known production configs still requiring
bridge-to-gateway POST dispatch.
- The release notes name the first version where `kind = "gateway"` is
rejected or removed.
## Prompt Templates
Repo-owned, versioned operating prompts for each relay role. The library
is the V1 implementation of GitHub issues #32 and #60 and lives at
`src/agent_lan_relay/prompts/templates/<role>.md`. Templates are plain
Markdown with a YAML-style front matter (required keys `id`, `name`,
`role`, `version`; optional `description`) and simple `${name}`
placeholder substitution — no DSL, no runtime branching, no model or
vendor names baked in.
### Available templates
The bundled set covers initial roles (#32) and broad autonomous-work
roles (#60):
| Role | Purpose |
| --- | --- |
| `coordinator` | Routes work and tracks completion across agents. |
| `implementer` | Performs allowlisted code changes on a feature branch. |
| `reviewer` | Reviews diffs for correctness, simplicity, and policy gates. |
| `qa` | Independently verifies behavior with reproducible evidence. |
| `security` | Scans changes; enforces hard-gate action classes. |
| `researcher` | Read-only research grounded in primary sources. |
| `client-agent` | Session-scoped local relay client. |
| `subagent` | Harness-neutral worker spawned by another agent. |
| `ticket-sync-agent` | Pulls external tickets into relay tasks. |
| `product-manager` | Owns scope and acceptance criteria. |
| `architect` | Produces design plans and reviews invariants. |
| `support-agent` | Triages user-facing requests and routes them. |
| `data-engineer` | Builds pipelines/schemas; destructive ops gated. |
| `data-scientist` | Analyzes read-only extracts; never productionizes. |
| `sre-aiops` | Investigates incidents; production actions gated. |
| `devops-platform` | Maintains build/CI/deploy plumbing; deploys gated. |
| `documentation-release` | Authors release notes and user-facing docs. |
Every template carries a shared `## Hard-gate action classes` block at
the bottom that enumerates the seven normative classes —
`irreversible`, `external_effect`, `destructive`, `production_or_deploy`,
`secret_read`, `merge_or_release`, `payment_like` — and the default
"requires explicit recorded HITL approval" stance for each. The catalog
test
`tests/test_prompts.py::BundledTemplateCatalogTests::test_each_template_enumerates_all_seven_hard_gate_classes`
verifies every shipped template literally names all seven, so the
README claim is mechanically enforced rather than aspirational.
In addition, every template covers the same baseline shape: relay-first
behavior, `working`/`input_required`/`done` status reporting, subagent
delegation policy, and explicit escalation criteria.
### Subagent delegation policy
Primary agents may use harness-specific subagents (Claude Code
subagents, Codex subagents, or future equivalents) when the work is
safe to split and the primary agent can still integrate the result.
Good uses include independent research, review, docs audit, test
expansion, architecture comparison, and log analysis.
Implementation-edit delegation is conditional on the primary role's own
authority. If the primary role is explicitly authorized to edit code for
the task, coding subagents need clear disjoint file/module ownership and
a reminder that they are not alone in the codebase: they must
accommodate concurrent edits and avoid reverting others.
Read-only or gatekeeper roles, including reviewer, QA, security,
ticket-sync, research, and data-analysis profiles, must restrict
subagents to read-only research, review, and log analysis. If remediation
requires code changes, route the work through the implementer profile
instead of delegating coding subagents directly.
Do not delegate tightly coupled edits, ambiguous ownership, urgent
blocking work on the critical path, or shared-file changes unless the
primary agent first coordinates ownership explicitly and is authorized to
edit. A subagent is an execution helper, not an accountability transfer:
the primary agent remains responsible for integration, verification,
relay status, and review handoff, and must inspect the subagent output
before marking work complete.
Every subagent should return changed files, tests run, findings, risks,
and unresolved questions. Read-only subagents should report sources
consulted instead of changed files/tests.
Future runtime metadata should remain harness-neutral and additive. When
an agent uses subagents, relay status/message metadata may carry:
```json
{
"subagents_used": true,
"subagent_count": 2,
"subagent_roles": ["research", "test-expansion"]
}
```
`subagent_roles` is a list of short role labels, not vendor/model names.
Older clients can ignore these keys; newer dashboards and reports can
use them to explain parallel work without coupling the relay contract to
one subagent implementation.
### How profiles reference a template
Today's `[harnesses.<name>]` and `[workflow_templates.<name>.stages]`
TOML blocks accept agent + command pointers, not prompts. The intent is
that a future `AgentProfile.prompt_template` field (the registry
described in `docs/research/2026-05-28-native-relay-orchestration-architecture.md`)
references a template by `id`, e.g.
```toml
[agent_profiles.claude-implementer]
agent = "claude"
prompt_template = "implementer.v1"
```
Until that wiring lands, the bundled templates are still the canonical
operating contract that an agent prompt MUST satisfy. Project autopilot
rules and stage-prompt strings should keep their language consistent
with the relevant template — the README's roles section, the
`agent-relay autopilot show` output, and the dispatch wrappers all
behave as if the implementer / reviewer / QA / security / coordinator
templates already shape the agent's expectations.
### Loader API
```python
from agent_lan_relay.prompts import (
list_templates, load_template, render_template, render_role,
)
# Discover the catalog (used by tests + future CLI surfaces).
templates = list_templates()
# Load a single role and render it strictly.
template = load_template("coordinator")
prompt = render_template(template, {
"project": "sample-project",
"agent_id": "claude",
"task_id": "42",
})
# One-call convenience for "give me the rendered prompt".
prompt, template = render_role("implementer", {
"project": "sample-project",
"agent_id": "codex",
"task_id": "43",
})
```
Errors are typed (every error inherits `ValueError` for compatibility):
- `MissingTemplateError` — no template file for that role.
- `TemplateMetadataError` — front matter missing/malformed; required
keys absent; duplicate role across the directory.
- `TemplateBodyError` — placeholder syntax invalid (e.g. `${1bad}`,
`${}`, unclosed `${...`).
- `MissingPlaceholderError` — strict render and a referenced
placeholder was not supplied.
### Conventions for adding a template
- Keep templates concise and role-scoped.
- Bump the `version` (and the trailing `.vN` in `id`) on any behavior
change downstream agents can observe.
- Do not embed vendor names (`claude` / `codex` / `gpt-*`) — pick
models via the AgentProfile registry.
- Do not include credentials, internal hostnames, or customer
identifiers; templates are open-source reviewable.
- Every change ships with a test update in `tests/test_prompts.py` —
new placeholders MUST appear in the variable supply set, and any
catalog drift fails the bundled-catalog test.
## Ticketing Providers
Symphony Light treats external trackers as the authoritative work intake.
The first shipping provider is **GitHub Issues**: `agent-relay tickets
sync` imports issues into relay tasks (idempotent, keyed on `(provider,
ticket_provider_config, external_id)` in task metadata, with a fallback
for older rows that only have `(provider, external_id)`), and
`agent-relay tickets status` posts a comment back to the originating
issue when a relay task is claimed, completed, or cancelled. **Linear**,
**GitLab**, **Jira Cloud**, and constrained **custom HTTP** providers are
also built in. GitLab/Jira/custom still support deterministic fixture
mode for local tests and dry runs.
The CLI is provider-agnostic — all built-in adapters are
implementations of the `TicketProvider` interface (see
[Provider interface](#provider-interface) below), and additional trackers
register against the same abstraction without changing the
`tickets list|sync|status` surface.
### Config
Declare one or more `[tickets.<name>]` blocks in
`~/.agent-lan-relay/config.toml`:
```toml
[tickets.github]
provider = "github"
project = "nova-relay"
repo = "example-org/agent-lan-relay"
labels = ["relay-backlog"] # optional, ANDed by gh
state = "open" # open | closed | all
assignee = "example-org" # optional
limit = 50 # max issues per sync pass
# Optional: map issue labels to relay project names. The first matching
# label wins. The mapped project is stamped into task metadata as
# `mapped_project` for downstream routing.
[tickets.github.project_mapping]
mobile = "mobile-app"
relay = "nova-relay"
```
For **Linear**, set `provider = "linear"` and supply a Linear team key.
Linear talks GraphQL over HTTPS (stdlib `urllib`, no extra deps); the API
token is read at call time from the environment variable named by
`token_env` and is **never** stored in config, task metadata, the outbox,
or any error message:
```toml
[tickets.linear-eng]
provider = "linear"
project = "relay-tasks" # relay project synced tasks land in
team = "ENG" # Linear team key (required)
labels = ["agent-ready"] # optional label filter (any-of)
state = "Todo" # optional workflow-state name filter
assignee = "dev@example.com" # optional assignee email or display name
token_env = "LINEAR_API_KEY" # env var holding the API token (default)
# workspace = "acme" # optional, recorded in task metadata
# api_url = "https://api.linear.app/graphql" # override endpoint
# assign_to = "claude-impl" # optional relay agent to auto-assign
# priority = "high" # low | normal | high | blocker
```
Linear issues are deduped on `(provider, ticket_provider_config,
external_id)` where `external_id` is the human identifier (e.g.
`ENG-123`) and `ticket_provider_config` is the `[tickets.<name>]` block
name. Status write-back resolves that identifier to the issue UUID and
posts a comment via the Linear `commentCreate` mutation.
For **GitLab**, set `provider = "gitlab"` and identify the project with
either `repo = "namespace/project"` (URL-encoded before API calls) or
`project_id = 12345`. `base_url` defaults to GitLab.com and can point at
self-managed GitLab. Status updates and upstream assignment are opt-in:
configure `status_mapping` and `upstream_assignee_id` only when the sync
identity is allowed to mutate issues.
```toml
[tickets.gitlab-relay]
provider = "gitlab"
project = "relay-tasks"
repo = "group/subgroup/project" # or project_id = 12345
base_url = "https://gitlab.com" # optional for GitLab.com
state = "opened" # opened | closed | all
labels = ["agent-ready", "backend"]
assignee = "devuser" # username or numeric GitLab user id
limit = 50
token_env = "GITLAB_TOKEN"
[tickets.gitlab-relay.status_mapping]
completed = "close"
open = "reopen"
# Optional upstream issue assignment on sync; must be numeric.
# upstream_assignee_id = 42
```
For **Jira Cloud**, set `provider = "jira"`, a Jira site `base_url`, and
a JQL query. The adapter uses Jira REST v3 search with a small default
field list (`summary`, `description`, `status`, `labels`, `assignee`,
`project`) and converts simple Atlassian Document Format descriptions and
comments to safe plain text for relay tasks. Workflow transitions and
assignment are opt-in through transition/account-id config.
```toml
[tickets.jira-relay]
provider = "jira"
project = "relay-tasks"
base_url = "https://acme.atlassian.net"
jql = "project = REL AND labels = agent-ready ORDER BY updated DESC"
fields = ["summary", "description", "status", "labels", "assignee", "project"]
limit = 50
email_env = "JIRA_EMAIL"
token_env = "JIRA_API_TOKEN"
[tickets.jira-relay.transition_mapping]
completed = "31" # Jira workflow transition id
# Optional upstream issue assignment on sync.
# upstream_account_id = "712020:abcd..."
```
For **custom HTTP**, configure fixed hook URLs and JSON/body templates.
The provider never runs shell commands. URLs must be `http` or `https`
without userinfo credentials, methods must be one of `GET`, `POST`,
`PUT`, `PATCH`, or `DELETE`, and auth is an env-var reference only.
```toml
[tickets.custom-live]
provider = "custom"
project = "relay-tasks"
limit = 50
# Private, loopback, and link-local hook hosts are rejected by default.
# Set only for trusted local/internal deployments.
# allow_private_hosts = true
[tickets.custom-live.auth]
token_env = "CUSTOM_TICKETS_TOKEN"
scheme = "Bearer" # Authorization: Bearer $token
[tickets.custom-live.hooks.list]
method = "GET"
url = "https://tracker.example.test/api/tickets?label=agent-ready"
response_path = "tickets" # dot path to the ticket array
[tickets.custom-live.hooks.comment]
method = "POST"
url = "https://tracker.example.test/api/tickets/{external_id}/comments"
json = { body = "{body}" }
[tickets.custom-live.hooks.status]
method = "PATCH"
url = "https://tracker.example.test/api/tickets/{external_id}"
json = { status = "{relay_status}" }
[tickets.custom-live.hooks.assign]
method = "POST"
url = "https://tracker.example.test/api/tickets/{external_id}/assignee"
json = { assignee = "{assignee}" }
[tickets.custom-live.hooks.link]
method = "POST"
url = "https://tracker.example.test/api/tickets/{external_id}/links"
json = { task_id = "{task_id}", url = "{url}" }
# Optional upstream assignment on sync; rendered into the assign hook.
# upstream_assignee = "dev@example.test"
```
Custom HTTP hook URLs form a trust boundary: by default the provider
rejects private, loopback, link-local, multicast, reserved, and
unspecified IP targets, including literal and numeric IPv4 forms such as
`127.0.0.1`, `127.1`, `127.000.000.001`, and `0x7f.0.0.1`, plus local/internal
hostnames such as `localhost`, single-label hosts, `.local`, and
`.internal`, to reduce SSRF risk. The provider does not resolve arbitrary
DNS names during validation, so trusted operators should still enforce
network egress controls or allowlists against DNS rebinding and hostnames
that resolve privately. Set `allow_private_hosts = true` only for trusted
local or internal tracker deployments where those hosts are intended.
All live providers reject raw secret-looking credential fields such as
`api_token`, `password`, `secret`, or inline `Authorization` values.
Use env-var references such as `token_env`, `email_env`, or `auth_env`;
env-var references must match `[A-Za-z_][A-Za-z0-9_]*` and must not be
secret-shaped values. Error messages may name the config field and safe
env var, but not the secret value.
Fixture mode remains available for GitLab, Jira, and custom providers.
Configure either inline `fixture_data` or a JSON `fixture_file`; no
network calls are made, and write hooks record deterministic payloads in
memory for tests.
```toml
[tickets.gitlab-fixture]
provider = "gitlab"
project = "relay-tasks"
repo = "namespace/project"
fixture_file = "fixtures/gitlab-tickets.json" # relative to this config file
[tickets.jira-fixture]
provider = "jira"
project = "relay-tasks"
fixture_data = [
{ key = "REL-7", title = "Fix parser", body = "Details", state = "To Do", labels = ["agent-ready"] }
]
[tickets.custom-fixture]
provider = "custom"
project = "relay-tasks"
fixture_data = [
{ external_id = "INT-1", title = "Internal request", body = "Details", status = "ready" }
]
```
Every provider normalizes upstream tickets through the provider-neutral
schema:
```text
provider, external_id, title, body, state/status, assignee, labels,
url, project, repo, metadata
```
The relay task metadata records the source `provider`,
`ticket_provider` / `ticket_provider_config`, `external_id`, source
`url`, `project`, `repo`, normalized `state`/`status`, labels,
assignee, and the created `relay_task_id`. External ticket title/body
copies in metadata are marked `trust_level = "untrusted"` /
`trust_source = "<provider>:<config-name>"`; downstream prompt builders
must treat them as data, not instructions.
State mapping is provider-neutral and optional. By default it preserves
current GitHub/Linear behavior; custom mappings can stamp relay-facing
state/action metadata and can skip fixture imports deterministically:
```toml
[tickets.custom-fixture.state_mapping]
ready = { relay_state = "open", relay_action = "import" }
done = { relay_state = "completed", relay_action = "skip" }
```
`relay_state` must be one of `open`, `claimed`, `completed`, or
`cancelled`. `relay_action` is `import`, `skip`, or `noop`.
### Autonomous backlog intake (`workflow_template`)
By default `tickets sync` creates **plain** relay tasks: they are claimable,
but the always-on coordinator supervisor only *dispatches* tasks that carry
autonomous workflow metadata (`metadata.workflow = "autonomous"` plus
`stages`). To make a synced backlog self-driving, point a `[tickets.<name>]`
block at a `[workflow_templates.<name>]` block with `workflow_template`:
```toml
[tickets.github]
provider = "github"
project = "nova-relay"
repo = "example-org/agent-lan-relay"
labels = ["relay-backlog"]
state = "open"
# gh_bin is config-driven (defaults to `gh` on PATH). Set it here or pass
# `--gh-bin <path>` at the CLI — never hardcode a gh path in code.
# gh_bin = "~/.local/bin/gh"
workflow_template = "default-dev" # promote each ticket to an autonomous task
[workflow_templates.default-dev]
description = "Implement then review a backlog ticket end to end."
[[workflow_templates.default-dev.stages]]
name = "implement"
profile = "implementation"
[[workflow_templates.default-dev.stages]]
name = "review"
profile = "reviewer"
review_routing = true
```
With `workflow_template` set, each newly synced ticket is promoted via
`workflow_templates.build_task_metadata()`: the provider metadata (`provider`,
`external_id`, `url`, `labels`, trust annotations, …) is preserved verbatim and
the autonomous `workflow` / `workflow_template` / `stages` keys are layered on
top. `repo` resolves to the template's `repo`, else the ticket's source repo,
else the provider `repo`; `branch` uses the template's `branch` when set,
otherwise a deterministic per-ticket branch
(`relay/ticket-<slug>-<hash>`, where `<slug>` is a git-ref-safe rendering of
provider/external-id and `<hash>` is a short stable digest of the raw
`(provider, ticket_provider_config, external_id)` identity) so promoting a whole
backlog in one pass never collides — even when two external IDs normalize to the
same slug (e.g. `ABC/123` vs `ABC-123`) — and ref-hostile fragments like `..`
cannot leak into the branch name. The result still dedupes
on `(provider, ticket_provider_config, external_id)`, so re-running sync is
safe. Leave `workflow_template` unset to keep the legacy plain-ticket behavior.
### CLI
```bash
agent-relay tickets list # show parsed providers
agent-relay tickets sync --provider github [--dry-run] [--gh-bin <path>]
agent-relay tickets sync --provider linear-eng [--dry-run]
agent-relay tickets sync --provider gitlab-relay [--dry-run]
agent-relay tickets sync --provider jira-relay [--dry-run]
agent-relay tickets sync --provider custom-live [--dry-run]
agent-relay tickets status --provider gitlab-relay [--dry-run]
```
`tickets sync` lists provider tickets using the configured provider
filters, creates relay tasks for new issues, and skips ones that already
have a relay task with matching metadata. The output JSON
returns `fetched`, `created`, `skipped`, and `errors` so re-running is
safe and observable.
`tickets status` walks the project's relay tasks, picks the ones that
came from this provider and have transitioned to `completed`,
`claimed`, or `cancelled`, and posts a one-comment summary back to the
originating issue (`Relay task #<id> — <status>`, with
branch/commit/result when present). GitLab/Jira/custom can also run
their configured status/transition hooks before posting the comment.
`--dry-run` short-circuits writes (`store.create_task`, comments,
transitions, assignments, and custom hooks) but still returns the
payloads the run would have written.
### Auth
For **GitHub**, tickets sync shells out to `gh`. Make sure `gh auth
status` is green on the host running the sync — the CLI inherits
whatever auth `gh` has configured. The relay never sees a GitHub token
directly.
For **Linear**, export a personal API key (Linear → Settings → API →
Personal API keys) into the environment variable named by `token_env`
(default `LINEAR_API_KEY`) before running sync/status:
```bash
export LINEAR_API_KEY="lin_api_…"
agent-relay tickets sync --provider linear-eng
agent-relay tickets status --provider linear-eng
```
For **GitLab**, export the token named by `token_env` (default
`GITLAB_TOKEN`). For **Jira Cloud**, export both the email named by
`email_env` (default `JIRA_EMAIL`) and API token named by `token_env`
(default `JIRA_API_TOKEN`). For **custom HTTP**, export the token named
by `[tickets.<name>.auth].token_env` or `auth_env` when configured.
Tokens are read from the environment at call time only. They are never
written to `config.toml`, copied into task metadata or durable outbox
payloads, or echoed into log lines/error messages. A missing credential
reports only the env var *name* (for example `GITLAB_TOKEN`), never any
value.
### Symphony Light usage
Run `tickets sync` periodically (cron, systemd timer, or as a
follow-up step in an autonomous workflow) so newly labelled issues
become claimable relay tasks. Run `tickets status` after a workflow
completes a task so reviewers see the outcome on the originating
issue without manual cross-posting.
### Provider interface
`agent_lan_relay.tickets.TicketProvider` is the abstract base every
adapter implements. The sync/status loops only ever touch this
interface — they never reach into provider-specific code paths — so
adding a new tracker is local to one class.
```python
from agent_lan_relay.tickets import (
TicketProvider,
TicketProviderConfig,
register_provider,
)
class GitLabTicketProvider(TicketProvider):
provider_id = "gitlab"
def parse_config(self, *, name, raw): ... # validate [tickets.<name>]
def list_tickets(self, cfg, **options): ... # fetch upstream tickets
def ticket_to_metadata(self, cfg, ticket): ... # -> {"provider", "external_id", ...}
def update_status(self, *, cfg, external_id, relay_status, task, **options): ...
def assign_ticket(self, *, cfg, external_id, assignee, **options): ...
def link_relay_task(self, *, cfg, external_id, task, **options): ...
def post_status_comment(self, *, cfg, external_id, body, **options): ...
register_provider(GitLabTicketProvider())
```
The shipped `LinearTicketProvider` (in `tickets.py`) is a complete,
non-GitHub reference implementation of this exact shape.
Contract notes:
- `parse_config` takes the raw TOML table for one `[tickets.<name>]`
block and returns a `TicketProviderConfig`. Use `_parse_neutral_fields`
for the provider-agnostic knobs (project/labels/assignee/limit/
priority/assign_to); GitHub-style adapters layer on `state`/`gh_bin`
via `_parse_common_fields`. The Linear adapter uses the neutral helper
and adds its own `team`/`token_env`/`api_url` keys into `extra` so it
doesn't inherit GitHub's open/closed/all `state` semantics.
- `ticket_to_metadata` MUST emit at least
`{"provider": <provider_id>, "external_id": "<id>"}`. Sync adds the
provider config name to drive `(provider, ticket_provider_config,
external_id)` dedupe, while retaining a legacy fallback for older tasks
that lack `ticket_provider_config`. Built-in providers also stamp
`ticket_provider`, `ticket_provider_config`, `url`, `project`, `repo`,
`title`, `body`, `state`/`status`, `assignee`, `labels`,
`relay_state`, `relay_action`, and `relay_task_id` after creation so
downstream tooling can walk back to the source.
- `ticket_to_task_fields` has a default that builds
`"#<external_id> <title>"`; override it for richer formatting.
- `post_status_comment` receives the comment body built by
`comment_for_task` (provider-agnostic). Optional `update_status`,
`assign_ticket`, and `link_relay_task` hooks are no-ops by default;
fixture providers record their payloads in `cfg.extra["records"]`.
Raise a `TicketProviderError` subclass (or `ValueError`) on failure;
both are caught into `result.errors` so a single bad ticket does not
poison the run. `list_tickets` should raise `TicketProviderError`
too — the sync loop catches the base class, so any provider-specific
subclass (e.g. `LinearAPIError(TicketProviderError)`) is routed into
`result.errors` automatically. `GitHubCLIError` is the GitHub
adapter's concrete subclass.
Unknown providers fail with a `TicketsConfigError` that names the
missing id, the available registry, and the `register_provider`
extension point — so a misconfigured `[tickets.linear]` block tells
the user exactly how to add support.
**Linear** is implemented over GraphQL. **GitLab** uses GitLab REST v4
project issues and notes. **Jira** uses Jira Cloud REST v3 search,
comments, assignment, and transitions. **Custom** uses fixed HTTP hook
URLs and templates. Each provider registers itself at import time; the
CLI surface does not change.
## MCP Setup Automation
`agent-relay mcp install` writes a session-scoped `agent-lan-relay-client`
entry into a target harness's `mcpServers` JSON file, preserving any
unrelated entries. `agent-relay mcp check` validates an existing entry.
Both subcommands use stdlib only (`json`); no new dependencies.
Supported harnesses and scopes:
| Harness | Scopes | Path |
| --- | --- | --- |
| `pi` | `global` | `~/.pi/agent/mcp.json` |
| `pi` | `project` | `<project-path>/.pi/mcp.json` |
| `kiro` | `global` | `~/.kiro/settings/mcp.json` |
| `kiro` | `workspace` | `<project-path>/.kiro/settings/mcp.json` |
| `kiro` | `agent` | `<project-path>/.kiro/agents/<agent>/mcp.json` (or `--config-path` override) |
| `claude` | `user` | `~/.agent-lan-relay/claude-mcp.json` |
| `claude` | `project` | `<project-path>/.agent-lan-relay/claude-mcp.json` |
| `codex` | `user` | `~/.agent-lan-relay/codex-mcp.json` |
| `codex` | `project` | `<project-path>/.agent-lan-relay/codex-mcp.json` |
Claude Code and Codex do not consume `mcpServers` JSON files directly the
way Pi and Kiro do — those files are written under `~/.agent-lan-relay/`
specifically so they can be passed to `claude --mcp-config <path>
--strict-mcp-config` (or the equivalent Codex flag) without touching the
host's primary MCP config. The CLI is conservative on purpose for Claude
and Codex; per-target wiring beyond that lives in the launching wrapper.
The generated entry runs the relay client over stdio:
```json
{
"command": "/path/to/.venv/bin/python",
"args": [
"-m",
"agent_lan_relay.client_server",
"--url",
"http://127.0.0.1:8787/mcp",
"--project",
"sample-project",
"--agent",
"claude"
],
"env": {
"AGENT_RELAY_TOKEN": "${env:AGENT_RELAY_TOKEN}"
}
}
```
The token is referenced via env placeholder (`${env:AGENT_RELAY_TOKEN}`),
never written as a literal value. `mcp check` rejects entries whose
`env.AGENT_RELAY_TOKEN` is a raw string.
### Install
```bash
# Pi global config
agent-relay mcp install \
--harness pi --scope global \
--project sample-project --agent claude \
--url http://127.0.0.1:8787/mcp
# Pi project config
agent-relay mcp install \
--harness pi --scope project \
--project sample-project --agent claude \
--url http://127.0.0.1:8787/mcp \
--project-path "$HOME/projects/sample-project"
# Kiro workspace config
agent-relay mcp install \
--harness kiro --scope workspace \
--project sample-project --agent kiro \
--url http://127.0.0.1:8787/mcp \
--project-path "$HOME/projects/sample-project"
# Kiro agent config (defaults to <project-path>/.kiro/agents/<agent>/mcp.json)
agent-relay mcp install \
--harness kiro --scope agent \
--project sample-project --agent alpha \
--url http://127.0.0.1:8787/mcp \
--project-path "$HOME/projects/sample-project"
# Kiro agent config at an explicit path (any JSON file you want to receive
# the mcpServers entry — useful when the agent config does not live under
# .kiro/agents/<agent>/)
agent-relay mcp install \
--harness kiro --scope agent \
--project sample-project --agent alpha \
--url http://127.0.0.1:8787/mcp \
--config-path "$HOME/projects/sample-project/agents/alpha-mcp.json"
# Claude / Codex sidecar config (consumed via --mcp-config)
agent-relay mcp install \
--harness claude --scope user \
--project sample-project --agent claude \
--url http://127.0.0.1:8787/mcp
```
`--config-path` is honored only for `--harness kiro --scope agent`. For
every other harness/scope the path is fully derived from `project-path`
and the harness's documented layout, so passing `--config-path` there
errors out instead of silently writing somewhere unexpected.
Use `--dry-run` to print the resolved path and entry without touching the
file. Use `--token-env ""` to omit the env block when the launching shell
already exports the token globally. Use `--server-name <name>` to write a
non-default key into `mcpServers` (default: `agent-lan-relay-client`).
### Check
```bash
# By explicit path
agent-relay mcp check --path "$HOME/.pi/agent/mcp.json"
# By harness/scope (resolves the same path mcp install would write to)
agent-relay mcp check --harness pi --scope global
# Kiro agent scope: derived path from project-path + agent
agent-relay mcp check \
--harness kiro --scope agent \
--project-path "$HOME/projects/sample-project" \
--agent alpha
# Kiro agent scope at an explicit JSON file path
agent-relay mcp check \
--harness kiro --scope agent \
--config-path "$HOME/projects/sample-project/agents/alpha-mcp.json"
```
`mcp check` returns `ok: true` only when the JSON file parses, contains an
`mcpServers` table with the expected server entry, the entry runs
`python -m agent_lan_relay.client_server` over stdio, and `--url`,
`--project`, `--agent` are all present with non-empty values. It rejects a
literal token in `env.AGENT_RELAY_TOKEN`. A missing `env` block is a
warning, not an error — useful when the launching shell exports the token
globally.
### What this PR does *not* do
- launch processes or speak any harness's protocol (still tracked in #5–#8)
- create or refresh non-`mcpServers` config files (Slack credentials,
ticketing tokens)
- modify a Pi/Kiro/Claude/Codex installation's primary configuration
beyond the file at the resolved path
## Smoke Tests
```bash
.venv/bin/pytest -q
PYTHONPATH=src AGENT_RELAY_DB=/tmp/agent-relay-smoke.db \
python3 -m agent_lan_relay.cli send \
--project sample-project --from codex --to claude --topic smoke --body ping
```
An in-process FastMCP client smoke is included in `tests/test_mcp_tools.py`.
## Tools
- `send_message`
- `list_messages`
- `ack_message`
- `post_status`
- `list_status`
- `create_task`
- `claim_task`
- `complete_task`
- `list_tasks`
- `request_agent_run`
- `list_agent_runs`
- `orchestrate_once`
- CLI only: `watch`
- CLI only: `remote-watch`, `remote-inbox`, `remote-send`, `remote-autopilot`
- CLI only: `bridge`, `autopilot`, `orchestrator`, `run-once`, `request-run`, `runs`, `config`
## Dashboard
Start the cockpit with the host relay:
```bash
agent-lan-relay \
--host 0.0.0.0 \
--port 8787 \
--project sample-project \
--agent codex \
--local-services \
--dashboard \
--dashboard-host 0.0.0.0 \
--dashboard-port 8788
```
Open:
```text
http://localhost:8788/dashboard
```
or from another Mac on the LAN:
```text
http://<host-lan-ip>:8788/dashboard
```
If `AGENT_RELAY_TOKEN` is set, the dashboard API requires that token. Paste it
into the dashboard connection panel.
Cockpit v2 supports:
- connection health with visible API/auth errors
- activity feed across messages, runs, tasks, and statuses
- inbox inspection, replies, sending messages, and acknowledgements
- run inspection and failed/rejected run retry
- task creation, claim, and completion
- status posting and status history
- diagnostics for database, auth, config, bridge, and autopilot readiness
- copy/paste setup snippets for host and remote watchers
- read-only config viewing
The dashboard is still intentionally conservative: it can inspect and request
safe actions, but bridge execution still goes through allowlisted commands in
the config file.
## Architecture Documentation
- **[Autonomous Relay Design](docs/architecture/autonomous-relay-design.html)** —
standalone HTML design doc with visual flows for setup, ticket sync → task claim,
implementation/review/security/QA gates, coworker terminal loop, autopilot/bridge
loop, HITL escalation, multi-machine dispatch, MCP/A2A/ACP/AG-UI protocol boundaries,
memory/learning future path, and dashboard observability. Open from disk (no server
needed). Tracks current state vs. remaining work with issue references.
- **[Architecture overview](docs/architecture.md)** — V1 architecture narrative.
- **[Memory backend registry](docs/architecture/memory-backends.md)** —
provider-neutral config model for local memory placeholders, hosted AgentCore
Memory-style services, Neptune graph/analytics backends, custom adapters,
capability metadata, env-var credential references, and prompt-injection-safe
retrieval policies.
- **[AG-UI event schema](docs/architecture/agui-event-schema.md)** — event taxonomy,
redaction contract, cursor/resume model.
- **[Runtime contract v1](docs/architecture/runtime-contract-v1.md)** — runtime
invariants and contract reference.
- **[Autonomy stack](docs/architecture/autonomy-stack.md)** — the driver, review
gate, iterate-on-fail self-correction loop, and per-node run tracking that drive
backlog items through design → implement → review autonomously.
## Future Roadmap
These are planned directions, not current behavior.
### Symphony-style orchestration
Use [openai/symphony](https://github.com/openai/symphony) as a north-star
pattern for turning project-board work into auditable agent runs. The relay
should remain the LAN transport and operator cockpit, while a higher-level
orchestration layer owns work selection, run lifecycle, proof, and acceptance.
Planned capabilities:
- GitHub Project ingestion for issues, project items, milestones, labels, and
custom fields.
- Structured implementation runs with owner, repo, branch, prompt, status,
started/completed timestamps, output, errors, and retry lineage.
- Proof artifacts for each run: test commands, build results, PR links, review
findings, screenshots, logs, and final acceptance notes.
- Dashboard controls to dispatch a project item to Claude, Codex, or both;
pause/resume runs; mark a run accepted/rejected; and compare agent outputs.
- Reviewer workflow that can request independent QA before a run is accepted.
- Durable run history that can survive session restarts and let a new agent
pick up the exact project state without chat archaeology.
- Project-board writeback so completed relay runs can update GitHub issues or
project fields instead of relying on manual status copying.
The intended architecture is:
```text
GitHub Project / issues
-> relay orchestration queue
-> allowlisted Claude/Codex runs over LAN
-> proof + review artifacts
-> dashboard acceptance
-> GitHub writeback
```
## Troubleshooting
### Dashboard opens but nothing changes
Paste the bearer token into the Connection panel and click Apply. If the token is
wrong or missing, the connection banner should show the API failure.
### Claude Mac cannot reach the host
From Claude's Mac, check the host address:
```bash
curl -i http://<host-lan-ip>:8787/mcp
```
If that cannot connect, confirm the host command used `--host 0.0.0.0`, confirm
both machines are on the same LAN, and check macOS firewall settings.
### Remote watcher is silent
For the session-scoped client MCP, first confirm Claude loaded the local client
and that its watcher thread is alive:
1. In Claude, call `client_info`.
2. In Claude, call `client_watch_state`.
If `client_watch_state` reports `enabled: false`, remove `--no-watch` from the
MCP command and restart Claude's MCP session. If it reports `running: false`,
restart the MCP session and check the MCP server logs for startup errors.
If the client tools work but notifications are silent, send a new smoke-test
message after the MCP session starts. The watcher intentionally ignores old
messages by default, so pre-existing messages do not prove notification
delivery.
For the older split-terminal `remote-watch` workflow, run one poll:
```bash
AGENT_RELAY_TOKEN="<generated-relay-token>" \
agent-relay remote-inbox \
--url http://<host-lan-ip>:8787/mcp \
--project sample-project \
--agent claude \
--include-acked
```
If this works, restart the watcher with the preferred notification mode:
```bash
AGENT_RELAY_TOKEN="<generated-relay-token>" \
agent-relay remote-watch \
--url http://<host-lan-ip>:8787/mcp \
--project sample-project \
--agent claude \
--interval 10 \
--notification-mode visual
```
### Autopilot or bridge does not run
Open the dashboard Diagnostics tab. The config check should show at least one
allowlisted command for the local agent and at least one autopilot rule if
autopilot is expected. Unknown commands are rejected by design.
### Token changed after restart
Use one shared token for the host, dashboard, and every remote client. If you
generate a new token on restart, every remote watcher and MCP client must be
restarted with the new token.
## Storage
Default database:
```text
~/.agent-lan-relay/relay.db
```
Override with:
```bash
export AGENT_RELAY_DB=/path/to/relay.db
```
## Security
This is intended for trusted local networks. Use a strong token, bind to
`127.0.0.1` when testing locally, and avoid exposing the port to the internet.
Treat bridge allowlists like automation permissions: keep commands narrow,
prefer repo-specific `cwd` values, and do not allow shell wrappers that execute
untrusted prompt text.
Connection Info
You Might Also Like
everything-claude-code
Complete Claude Code configuration collection - agents, skills, hooks,...
markitdown
MarkItDown-MCP is a lightweight server for converting URIs to Markdown.
cc-switch
All-in-One Assistant for Claude Code, Codex & Gemini CLI across platforms.
servers
Model Context Protocol Servers
servers
Model Context Protocol Servers
Time
A Model Context Protocol server for time and timezone conversions.