agent-dispatch

mcp
Security Audit
Pass
Health Pass
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Community trust — 26 GitHub stars
Code Pass
  • Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
  • Permissions — No dangerous permissions requested

No AI report is available for this listing yet.

SUMMARY

MCP server + CLI that lets Claude Code agents delegate tasks to agents in other project directories. Multi-agent orchestration: parallel dispatch, sessions, async jobs, agent dialogues.

README.md

agent-dispatch

PyPI
CI
Python
License

MCP server that lets Claude Code agents delegate tasks to agents in other project directories.

agent-dispatch mascot

Each agent runs as a separate claude -p session in its own project directory — inheriting that project's MCP servers, CLAUDE.md, and tools. The calling agent just gets the result back.

Works with OAuth, API key, and Claude subscription authentication.

AI agents: this README is the canonical doc for using the tool — setup: Quick Start (every step has a deterministic verify), first call: dispatch, tool selection: Which Tool to Use, failure handling: Error Recovery. Working on this repo instead? See AGENTS.md.

Quick Start

Prerequisite: the Claude Code CLI must be installed and authenticated. Check first:

claude --version   # must print a version — if it fails, install Claude Code before continuing

Then:

pip install agent-dispatch   # or: pipx install agent-dispatch

# 1. Create config + register the MCP server with Claude Code (user scope)
agent-dispatch init

# 2. Register project directories as agents — REPLACE the example paths with
#    real directories on your machine; they must exist (~ is expanded, relative
#    paths are resolved). Descriptions are auto-generated from project files.
#    No second project handy? Use the zero-setup block below instead.
agent-dispatch add infra ~/projects/infra
agent-dispatch add backend ~/projects/backend

# 3. Smoke test — dispatches a real task to the agent added in step 2 and prints
#    the answer; exit 0 on success. Default task when none given:
#    "What project is this? Describe in one sentence."
agent-dispatch test infra

# 4. Verify the whole install — prints "All checks passed." and exits 0 on success
agent-dispatch doctor

Zero-setup alternative for steps 2–3 (no second project needed — registers the current directory):

agent-dispatch add self . && agent-dispatch test self "Say hello"

Every Claude Code session now has the dispatch tools. Independent check: claude mcp list must print a line starting with agent-dispatch:. From inside a Claude Code session, the first MCP calls are list_agents(), then dispatch(...).

If init fails to register the MCP server (prints a warning instead of Registered MCP server), register manually:

claude mcp add-json agent-dispatch "{\"type\":\"stdio\",\"command\":\"$(which agent-dispatch)\",\"args\":[\"serve\"]}" --scope user

If test fails with a permission error (error_type: "permission"), grant tool access and re-test:

agent-dispatch update infra --allowed-tools "Bash,Read,Grep"      # least privilege
# or, if the agent needs everything (see SECURITY.md for the trade-off):
agent-dispatch update infra --permission-mode bypassPermissions

When to Dispatch

Do dispatch when a task needs tools, files, or context from another project:

  • Check container logs via infra agent's Portainer MCP
  • Query a database via db agent's postgres MCP
  • Read code or run tests in another repository

Don't dispatch when you can do it yourself — dispatching spawns a full Claude session.

MCP Tools Reference

list_agents

Lists all configured agents. Call this first to see what's available.

// Response (capability + permission fields shown only when populated)
[
  {
    "name": "infra",
    "directory": "/home/user/projects/infra",
    "description": "Infrastructure agent. MCP: portainer. Stack: Python, Docker",
    "healthy": true,
    "has_claude_md": true,
    "has_mcp_config": true,
    "mcp_servers": ["portainer", "postgres"],
    "stacks": ["Python", "Docker"],
    "dbs": ["Alembic"],
    "permission_mode": "bypassPermissions",
    "allowed_tools": ["Bash", "Read", "Grep"]
  }
]

mcp_servers, stacks, and dbs are detected from the agent's project files (.mcp.json, Dockerfile, pyproject.toml, Cargo.toml, prisma/, alembic.ini, etc.) so callers can pick the right agent without dispatching a probe.

inspect_agent

Cheap detailed lookup — reads the agent's files without spawning a claude session. Returns the full config (timeout, model, budget, permission mode, allowed/disallowed tools), detected MCP/stacks/DBs, plus short previews of CLAUDE.md and README.md when present.

Parameter Type Required Description
name string yes Agent name from list_agents
preview_lines int no Max lines of CLAUDE.md/README.md (default 40, max 200, 0 disables)

Use this before dispatch_async/dispatch to confirm an agent has the tools and context for your task — much cheaper than a probe dispatch.

dispatch

One-shot task delegation. Results are cached — identical requests within TTL return instantly.

Parameter Type Required Description
agent string yes Agent name from list_agents
task string yes What to do — be specific, the agent has no context from your conversation
context string no Extra context: error messages, code snippets, stack traces
caller string no Your project/role — helps the agent understand who's asking
goal string no Broader objective — helps the agent make better trade-offs
response_format string no "json" to request a single JSON value; the parsed result lands in parsed_result. Empty = free-form text.
return_ref bool no When true, returns just a ref + summary preview instead of the full result text. Use fetch_result(ref) to load the full text on demand.
summary_chars int no Max chars of result text to include in the ref response (default 500).
timeout_seconds int no One-off timeout override for this call (0 = agent's configured timeout; clamped to 10–7200). No config edit needed for known-long tasks.
# Call — recommended form (always include caller and goal)
dispatch(
    agent="infra",                # must exist in list_agents()
    task="Check container logs for errors related to the scheduler service",
    context="Error: TypeError at scheduler.py:42",
    caller="backend",             # your project/role
    goal="debug production crash" # the broader objective
)
// Response (success)
{
  "agent": "infra",
  "success": true,
  "result": "Found 3 errors in container logs: TypeError in scheduler.py:42...",
  "session_id": "sess-abc-123",
  "cost_usd": 0.02,
  "duration_ms": 5000,
  "num_turns": 2
}

// Response (failure — error_type helps you handle programmatically)
{
  "agent": "infra",
  "success": false,
  "result": "",
  "error": "Tool_use is not allowed in this permission mode\n\nHint: ...",
  "error_type": "permission"
}

error_type values: permission (tool/action denied), timeout, recursion (dispatch depth exceeded), not_found (missing directory or CLI), cli_error (other failures). Permission errors include an actionable hint.

Resumable timeouts: every fresh dispatch pre-assigns a session UUID (--session-id), so a timed-out dispatch still returns a session_id — the partial transcript survives the kill. The timeout error spells out the recovery: resume with dispatch_session(agent, "Continue where you left off", session_id=...), retry with a bigger timeout_seconds, or use dispatch_async.

Denied-tools visibility: in non-interactive mode the claude CLI auto-denies tools the agent isn't allowed to use — the agent then often "succeeds" with an answer like "I need your permission for one read-only query". When that happens the response carries the deterministic signal: denied_tools (parsed from the CLI's permission_denials) plus a hint explaining the result may be incomplete and how to grant access. success stays true — it's a soft signal, not a failure.

// Response (success, but a tool was blocked)
{
  "agent": "analysis",
  "success": true,
  "result": "Here is the offline mapping. To finish I'd need to run one read-only query...",
  "denied_tools": ["Bash"],
  "hint": "1 tool call(s) were denied by permissions: Bash. The result may be incomplete..."
}

Structured JSON output: pass response_format="json" to ask the agent for a single JSON value. The runner appends an instruction footer ("respond with a single valid JSON value, no fences, no prose") and on success parses the response — the parsed value lands in parsed_result. The raw text is always in result. Parse failures leave parsed_result=None but don't fail the dispatch (soft mode).

// Response with response_format="json"
{
  "agent": "infra",
  "success": true,
  "result": "{\"errors\": 3, \"first_at\": \"14:02\"}",
  "parsed_result": {"errors": 3, "first_at": "14:02"}
}

Always pass caller and goal — the dispatched agent sees a structured prompt:

## Goal
debug production crash

## Dispatched by
backend

## Context
Error: TypeError at scheduler.py:42

## Task
Check container logs for recent errors related to the scheduler service

dispatch_session

Multi-turn: continue a conversation with an agent. First call starts a session, pass session_id back to continue. Never cached.

Parameter Type Required Description
agent string yes Agent name
task string yes Task or follow-up message
session_id string no From previous response — empty for new session
context string no Extra context
caller string no Who is dispatching
goal string no Broader objective
timeout_seconds int no One-off timeout override (0 = agent default; clamped to 10–7200)

dispatch_session is also the timeout recovery path: a timed-out dispatch returns a session_id — pass it here with task="Continue where you left off" to salvage the partial work instead of restarting.

Turn 1: dispatch_session("infra", "List running containers")
         → session_id: "sess-abc"

Turn 2: dispatch_session("infra", "Restart the nginx one", session_id="sess-abc")
         → agent remembers previous context

dispatch_parallel

Run multiple tasks concurrently. Much faster than sequential dispatch calls.

Parameter Type Required Description
dispatches string (JSON) yes JSON array of {"agent", "task", "context?", "caller?", "goal?", "response_format?", "return_ref?", "summary_chars?", "timeout_seconds?"}
aggregate string no Agent name to synthesize all results into one answer

Important: dispatches is a JSON string, not a list.

// Input
[
  {"agent": "infra", "task": "check pod logs for errors", "caller": "backend", "goal": "debug crash"},
  {"agent": "db", "task": "are all migrations applied?", "caller": "backend", "goal": "debug crash"}
]
// Response (without aggregate)
[
  {"agent": "infra", "success": true, "result": "No errors in pod logs", ...},
  {"agent": "db", "success": true, "result": "All migrations applied", ...}
]
// Response (with aggregate="backend")
{
  "individual_results": [
    {"agent": "infra", "success": true, "result": "No errors in pod logs", ...},
    {"agent": "db", "success": true, "result": "All migrations applied", ...}
  ],
  "aggregated": {
    "agent": "backend",
    "success": true,
    "result": "Summary: all systems nominal. No pod errors, all migrations applied."
  }
}

dispatch_stream

Same as dispatch but shows live progress while the agent works. Use for long-running tasks. Not cached.

Parameters are the same as dispatch except return_ref/summary_chars (streaming is incompatible with ref-mode).

dispatch_dialogue

Two agents collaborate through multi-turn conversation. Never cached.

Parameter Type Required Description
requester string yes Agent with the problem/context
responder string yes Agent with the expertise/tools
topic string yes Problem or question to discuss
max_rounds int no Max back-and-forth rounds (default: 3, max: 10)

Each round costs up to 2 dispatches. Agents signal completion with [RESOLVED].

// Response
{
  "resolved": true,
  "rounds": 2,
  "total_cost_usd": 0.04,
  "total_duration_ms": 12000,
  "final_answer": "Staging had 1 pending migration. Applied successfully.",
  "conversation": [
    {"agent": "db", "role": "responder", "round": 1, "message": "Which environment?", "cost_usd": 0.01},
    {"agent": "backend", "role": "requester", "round": 1, "message": "Staging", "cost_usd": 0.01},
    {"agent": "db", "role": "responder", "round": 2, "message": "Applied. [RESOLVED]", "cost_usd": 0.01}
  ]
}

add_agent

Register a new project directory as an agent. Description is auto-generated from project files if omitted.

Parameter Type Required Description
name string yes Agent name (letters, digits, hyphens, underscores)
directory string yes Path to an existing project directory (~ is expanded, relative paths resolved)
description string no What this agent can do — auto-generated if empty
timeout int no Timeout in seconds (0 = use global default)
max_budget_usd float no Max cost in USD per dispatch (0 = no limit)
permission_mode string no Permission mode (e.g. default, plan, bypassPermissions)
allowed_tools string no Comma-separated allowed tools (e.g. "Bash,Read,Edit")
disallowed_tools string no Comma-separated disallowed tools

update_agent

Update an existing agent's configuration. Only non-empty fields are changed. Pass "none" to clear a field.

Parameter Type Required Description
name string yes Agent name to update
description string no New description
timeout int no New timeout (0 = don't change)
max_budget_usd float no New budget limit (0 = don't change, negative = clear the limit)
model string no Model override. "none" to clear
permission_mode string no Permission mode. "none" to clear
allowed_tools string no Comma-separated. "none" to clear
disallowed_tools string no Comma-separated. "none" to clear

remove_agent

Remove an agent from config.

Parameter Type Required Description
name string yes Agent name to remove

cache_stats / cache_clear

View cache hit rate and size, or clear all cached results.

Result references — return_ref + fetch_result

For dispatches whose result text is large (audits, log dumps, code searches), passing the full text back inflates the calling agent's context. Use return_ref=True to get just a small reference instead:

dispatch(agent="infra", task="audit every container", return_ref=True, summary_chars=200)
  -> {"ref": "8f3a...e1", "agent": "infra", "success": true,
      "size": 14823, "summary_chars": 200,
      "summary": "Inspected 32 containers. Found 3 OOM kills in the last hour:\n- worker-3...",
      "cost_usd": 0.08, "duration_ms": 9200}

// Later, when you actually need to read the result:
fetch_result(ref="8f3a...e1")              -> full DispatchResult JSON
fetch_result(ref="8f3a...e1", max_chars=2000)  -> truncated, plus {"truncated": true, "full_size": 14823}

Refs reuse the same storage as dispatch_async jobs (under ~/.config/agent-dispatch/jobs/), so any job_id returned by dispatch_async is also a valid ref for fetch_result. parsed_result (when response_format="json" is set) is small and is always inlined directly in the ref response — no second fetch needed.

Async dispatch — dispatch_async, dispatch_status, dispatch_wait, dispatch_cancel, dispatch_jobs, dispatch_gc

When a dispatched task is going to take a while, you don't want to block your own tool slot for minutes. Async dispatch returns a job_id immediately and lets you check back when you're ready.

// 1. fire and forget (timeout_seconds= works here too for known-long tasks)
dispatch_async(agent="infra", task="audit every container log for OOM kills today")
  -> {"job_id": "8f3a...e1", "status": "pending", "agent": "infra"}

// 2. do other work, then check progress (non-blocking)
//    `progress` is a rolling tail of what the agent is doing right now
dispatch_status(job_id="8f3a...e1")
  -> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4,
      "progress": ["Using tool: Bash", "Scanning container logs for OOM events..."], ...}

// 3. or block until done (timeout_seconds default: 60, capped at 3600)
dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
  -> {"id": "8f3a...e1", "status": "done", "result": {"agent": "infra", "success": true, ...}}

// If the timeout fires, the job keeps running:
  -> {"id": "...", "status": "running", "timed_out_waiting": true}

dispatch_cancel(job_id) cancels a job that is still pending (before its subprocess starts) — a running job is left to finish, since its claude subprocess can't be safely interrupted. The response carries an outcome of cancelled, running, already_terminal, or not_found.

Async workers run with streaming under the hood: the job file keeps a rolling tail (last 20 lines, ~1 write/sec) of assistant text and tool-use events. dispatch_status shows it as progress while the job runs and keeps it afterwards as a post-mortem trace; dispatch_jobs shows last_progress for running jobs.

dispatch_jobs(status?) lists recent jobs as summaries (filter by pending / running / done / failed / cancelled). dispatch_gc(max_age_days=7) purges terminal jobs older than the threshold — pending and running jobs are never deleted.

Job state persists to disk at ~/.config/agent-dispatch/jobs/ (override with AGENT_DISPATCH_JOBS_DIR). One JSON file per job, written owner-only (0o600) with atomic writes — safe to read or ls while jobs are in flight. Caller-supplied job_ids are validated as 32-char hex before any file access (no path traversal). On startup the server marks jobs left in running by a crashed instance as failed once they are stale (stuck for over an hour).

When to use async When to use dispatch
Long task (minutes) — you want to keep working Short task — you need the answer right now
Several long tasks you'll collect later Several short tasks → dispatch_parallel
Don't care about caching (each call is a fresh job) Cached by default — identical requests are free

Which Tool to Use

Scenario Tool
Quick one-off question to another project dispatch
Multi-step workflow with follow-ups dispatch_session
Need answers from several agents at once dispatch_parallel
Long task, want to see progress dispatch_stream
Two agents need to collaborate dispatch_dialogue
Need a combined summary from multiple agents dispatch_parallel with aggregate
Long task — don't block your tool slot dispatch_async + dispatch_wait
Check progress without blocking dispatch_status
Known-long task, one-off any dispatch tool with timeout_seconds=...
A dispatch timed out dispatch_session with the session_id from the error

Error Recovery

Failures are deterministic: check success, then branch on error_type.

error_type Meaning Recovery
permission A tool call was denied update_agent(name, allowed_tools="Bash,Read") (least privilege) or update_agent(name, permission_mode="bypassPermissions"), then re-dispatch. The error text includes a hint with the exact fix.
timeout Process killed at the timeout Resume the partial work: dispatch_session(agent, "Continue where you left off", session_id=<from the error text>). Or retry with a bigger timeout_seconds=, or use dispatch_async.
not_found Agent directory or claude CLI missing list_agents() → check healthy. Re-add the agent with an existing path, or run agent-dispatch doctor to find what's missing.
recursion Dispatch nesting exceeded max_dispatch_depth (default 3) Don't dispatch from dispatched agents; if the nesting is intentional, raise max_dispatch_depth in settings.
cli_error Anything else from the claude subprocess Read the error text; run agent-dispatch doctor for environment issues; retry once if transient.

Two soft signals that arrive with success: true:

  • denied_tools + hint — the agent finished but some tool calls were blocked; the result may be incomplete. Grant access (see the permission row) and re-dispatch.
  • parsed_result: null with response_format="json" — the reply wasn't valid JSON; the raw text is still in result. Caveat: an agent that can't comply returns {"error": "<reason>"} — which parses successfully — so also check parsed_result for an "error" key.

Tool-level errors (unknown agent, malformed input) return a plain envelope instead of a DispatchResult:

{"error": "Unknown agent: 'foo'. Available: infra, db, monitoring"}

Configuration

Config at ~/.config/agent-dispatch/agents.yaml (override: AGENT_DISPATCH_CONFIG env var):

agents:
  infra:
    directory: ~/projects/infra
    description: "Infrastructure agent. MCP: portainer."
    timeout: 300            # seconds, default: 300
    # model: sonnet         # optional model override
    # max_budget_usd: 1.0   # cost limit per dispatch
    # permission_mode: bypassPermissions  # one of: default | plan | bypassPermissions
    # allowed_tools:        # restrict which tools the agent can use
    #   - Read
    #   - Grep
    # disallowed_tools:     # block specific tools
    #   - Write

settings:
  default_timeout: 300
  # default_permission_mode: bypassPermissions  # inherited by all agents
  # default_allowed_tools:                      # inherited when agent has none
  #   - Bash
  #   - Read
  #   - Edit
  max_dispatch_depth: 3     # recursion protection
  max_concurrency: 5        # max parallel claude -p processes (per dispatch path)
  cache:
    enabled: true
    ttl: 300                # seconds
    max_size: 1000          # max cached entries; oldest evicted first (FIFO)

Config is reloaded on every tool call — add agents without restarting.

Auto-Description

agent-dispatch add without --description generates one from:

  • CLAUDE.md — first meaningful paragraph (priority)
  • README.md — first substantial line (fallback)
  • pyproject.toml / package.json — project description
  • .mcp.json — lists MCP server names
  • Stack indicators — Docker, Rust, Go, Python, Node.js
  • DB indicators — Prisma, Alembic, migrations

How It Works

Your Claude Code session
  │
  ├─ dispatch("infra", "find errors", caller="backend", goal="debug crash")
  │
  ▼
agent-dispatch MCP server
  ├─ cache check → hit? return cached result
  ├─ semaphore → limit concurrent processes
  └─ subprocess.run("claude -p ...", cwd=~/projects/infra/)
       │
       ▼
     New Claude Code session in ~/projects/infra/
       ├─ Inherits: CLAUDE.md, .mcp.json, project tools
       ├─ Receives structured prompt with goal/caller/context/task
       └─ Returns result → cached for future identical requests

Safety

  • Recursion protectionAGENT_DISPATCH_DEPTH env var tracks nesting. Default limit: 3. Best-effort across the subprocess boundary (see SECURITY.md).
  • Argument-injection guard — structured CLI fields (session_id, model, permission_mode, tool names) that start with - are rejected so they can't smuggle extra claude flags.
  • Path-traversal guard — caller-supplied job_id/ref values are validated as 32-char hex before any filesystem access.
  • Owner-only state — job files (0o600) and agents.yaml (0o600) are written for the owner only; their directories are 0o700.
  • Cost controlmax_budget_usd per agent or globally.
  • Concurrencymax_concurrency (default: 5) caps parallel claude -p processes. Note: the sync and async dispatch paths use separate semaphores, so the worst-case total is 2 × max_concurrency.
  • Timeout — per-agent or global (default: 300s). Orphaned processes are cleaned up.
  • Caching — identical (agent, task, context, caller, goal, response_format) requests return cached results, bounded by cache.max_size (oldest entry evicted first). Only successes are cached. Sessions and dialogues are never cached.

See SECURITY.md for the full threat model (including the bypassPermissions escalation risk and on-disk job files).

CLI

Command Description
agent-dispatch init Create config + register MCP server with Claude Code
agent-dispatch add <name> <dir> Add an agent (auto-generates description)
agent-dispatch update <name> Update agent config (permissions, timeout, model, etc.)
agent-dispatch remove <name> Remove an agent
agent-dispatch list List agents with health status and permissions
agent-dispatch describe <name> Show full configuration for one agent (tri-state tools, project files)
agent-dispatch test <name> [task] [--stream] Test an agent with a dispatch (--stream for live progress)
agent-dispatch doctor Diagnose installation: claude CLI, MCP registration, agent health
agent-dispatch serve Start MCP server (stdio, used by Claude Code)

Requirements

  • Python >= 3.10
  • Claude Code CLI installed, authenticated, and on PATH (verify: claude --version)

License

MIT

Reviews (0)

No results found