DovePaw Lite is a local multi-agent orchestration runtime for the Claude Agent SDK.
It provides Dove, a Next.js chatbot UI that turns local agent scripts into conversational tools. Drop TypeScript, Python, Ruby, or shell agents into agent-local/, run npm run dev, and Dove automatically exposes them through MCP tools backed by A2A servers — no hardcoded ports and minimal configuration.
DovePaw Lite is built and supported with Claude Code Trace and Codex Trace, session log viewers for debugging Claude Code and Codex CLI workflows.
Use DovePaw Lite when you want to:
- Build a local AI agent runtime on top of the Claude Agent SDK
- Turn scripts into chatbot-accessible agents
- Orchestrate multiple agents from one Dove conversation
- Run TypeScript, Python, Ruby, or shell agents
- Schedule agents with macOS launchd or Linux cron
- Stream agent progress to a browser over SSE
- Deploy a lightweight agent runtime with Docker or ECS
- Claude Agent SDK runtime — Dove uses
query()as the stateful agent loop - Next.js chatbot UI — browser-based chat interface with SSE streaming
- Script-based agents — run
.ts,.py,.rb, or.shfiles as agents - A2A agent servers — each agent runs behind an Express A2A server
- MCP tool injection — each registered agent becomes
ask_*,start_*, andawait_*tools - No hardcoded ports — A2A servers use OS-assigned ports
- Scheduling — run agents through launchd on macOS or cron on Linux
- Security modes — read-only, supervised, and autonomous execution
- Docker/ECS deployment — local Docker support and S3-backed ECS config
DovePaw Lite is not a hosted agent platform or a general observability system. It is a lightweight local runtime for wiring Claude Agent SDK, MCP tools, A2A agent servers, and script-based agents into one chatbot-driven workflow.
The project is built and supported with Claude Code Trace and Codex Trace, which make it easier to inspect agent sessions, understand tool usage, and debug the AI workflows behind the platform.
Dove, the chatbot inside DovePaw Lite, is built on the @anthropic-ai/claude-agent-sdk — the same runtime that powers Claude Code itself.
The SDK's query() function runs Dove as a stateful agent loop. It handles the Claude API calls, tool dispatch, and — critically — conversation memory. There is no database for chat history in this project. Conversation continuity is entirely managed by the SDK: each turn passes resume: sessionId to query(), which replays the session from the SDK's own storage under ~/.claude/projects/. The in-memory store (db-lite.ts) only tracks lightweight UI metadata (session status, progress labels) — not message content.
// chatbot/app/api/chat/route.ts — simplified
query({
prompt: message,
options: {
// Resume picks up the full conversation history from ~/.claude/projects/
...(sessionId ? { resume: sessionId } : {}),
mcpServers: { agents: mcpServer }, // inject ask_*/start_*/await_* tools
systemPrompt: { type: "preset", preset: "claude_code", append: buildSystemPrompt() },
},
});The SDK also provides the tool() factory used to define each agent's MCP tools, and the hooks / canUseTool callbacks used to gate permissions and stream progress to the browser.
What this means in practice: conversation history survives process restarts (it lives in ~/.claude/), but is tied to the machine. For server deployments where the container is ephemeral, each restart begins a fresh conversation. If you need persistent cross-restart history, add a session export step before container shutdown.
flowchart TD
subgraph ui["UI Layer"]
Browser["Browser\n(Next.js UI)"]
end
subgraph orchestrator["Orchestrator Layer"]
ChatAPI["Next.js — /api/chat\nClaude Agent SDK query()"]
MCP["In-process MCP Server\nask_* · start_* · await_*\n(one trio per registered agent)"]
end
subgraph agents["Agent Layer"]
A2A["A2A Servers\n(Express · OS-assigned ports)"]
Scripts["Agent Scripts\nagent-local/<name>/main.ts\n.ts · .py · .rb · .sh"]
end
subgraph scheduling["Scheduling"]
Sched["Scheduler\n(launchd / cron)"]
end
Browser -->|"SSE /api/chat"| ChatAPI
ChatAPI --> MCP
MCP -->|"A2A SSE"| A2A
A2A -->|"spawn\ntsx · python3 · ruby · bash"| Scripts
Sched -->|"scheduled trigger"| A2A
-
Browser → Dove. The user sends a message to the Next.js chat UI. Dove is a Claude Agent SDK
query()session that receives the message and a set of MCP tools — one trio (ask_*,start_*,await_*) per registered agent. -
Dove → A2A server. When Dove decides to invoke an agent, it calls one of its MCP tools. The tool sends an A2A message to that agent's Express server over SSE. Ports are OS-assigned at startup and published to
~/.dovepaw-lite/.ports.<port>.json— no hardcoded ports. -
A2A server → agent script. The A2A server spawns the agent script using a runtime determined by its file extension (
.ts→tsx,.py→python3,.rb→ruby,.sh→bash). The script receives the instruction asargv[1](orprocess.argv[2]in Node), runs its logic, and returns output. The server streams the result back up through the A2A protocol to Dove, then to the browser as SSE events. -
Scheduling. Agents with a
schedulefield in theiragent.jsoncan be installed as cron jobs (Linux) or launchd daemons (macOS) vianpm run install. The scheduler fires the A2A trigger script on the configured interval. No schedule = on-demand only.
| Decision | Reason |
|---|---|
| In-memory session store | Conversation memory lives in the Claude Code SDK (~/.claude/), not a DB — the store only tracks UI metadata |
agent-local/ scanned at startup |
Agent discovery is a directory scan — add a folder, restart, it appears |
| OS-assigned A2A ports | No port conflicts, no config to maintain |
| Platform-neutral scheduler abstraction | lib/scheduler.ts adapts to launchd (macOS) or cron (Linux) |
agent-local/ ← your agent scripts live here
hello-world/
agent.json ← agent metadata: name, icon, schedule, MCP description
main.ts ← agent entry point (default; .py / .rb / .sh also supported)
chatbot/
app/ ← Next.js pages and API routes
a2a/ ← A2A Express servers (one per agent)
lib/ ← shared chatbot utilities, session store, hooks
lib/ ← shared library: agents, scheduler, settings, paths
packages/agent-sdk/ ← shared agent utilities (Claude/Codex runners, git, logger)
scripts/
chatbot-start.ts ← starts A2A servers + Next.js dev server
Full walkthrough: see docs/getting-started.md for a step-by-step guide with UI screenshots.
Prerequisites: Node.js 20+ and Claude Code CLI authenticated, or ANTHROPIC_API_KEY set.
npm install
npm run dev # starts A2A servers + Next.js on an available portOpen the URL printed by Next.js. Dove appears in the sidebar. The hello-world agent is already registered — send it a message to verify the stack is working.
Prerequisites: Docker Desktop, ejson (brew install ejson).
1. Create and encrypt a secrets file
# Generate a keypair — private key saved to ~/.ejson/keys/<pubkey>
ejson keygen
# Copy the example and fill in your public key + API key
cp secrets.ejson.example secrets.ejson
# Edit secrets.ejson: set "_public_key" and plaintext "ANTHROPIC_API_KEY"
ejson encrypt secrets.ejson # encrypts in place — safe to commit2. Build and run
docker compose build --pull
docker compose upOpen http://localhost:8473. Dove is running inside the container using the same agents baked into the image.
Volumes
| Volume | Path in container | Contents |
|---|---|---|
dovepaw-data |
/data |
Agent settings, port manifests, workspaces, SQLite DB |
claude-sessions |
/root/.claude |
Claude Agent SDK session history |
Agent scripts and settings are baked into the image at build time. To add or edit agents, rebuild with docker compose build.
Quickstart: In Claude Code, run
/sub-agent-builderto scaffold a new DovePaw Lite agent interactively — it handles file creation, registration, and skill setup end-to-end.
To add an agent manually:
- Create a directory under
agent-local/:
agent-local/my-agent/
agent.json
main.ts ← default entry; set "scriptFile" in agent.json to use .py / .rb / .sh
agent.json— required fields:
{
"version": 1,
"name": "my-agent",
"alias": "ma",
"displayName": "My Agent",
"description": "What this agent does — shown to Dove as the MCP tool description.",
"iconName": "Bot",
"scriptFile": "main.ts",
"schedulingEnabled": false,
"locked": false,
"doveCard": {
"title": "My Agent",
"description": "Short description for the Dove card grid",
"prompt": "What does My Agent do?"
},
"suggestions": [
{
"title": "Run it",
"description": "Trigger the agent",
"prompt": "Run my agent now"
}
],
"repos": [],
"envVars": []
}- Entry script (
main.tsby default, or whateverscriptFilepoints to) — receives the user's instruction as the first argument (process.argv[2]in TypeScript/Node,sys.argv[1]in Python,$1in shell):
import { createLogger } from "@dovepaw/agent-sdk";
const log = createLogger("my-agent");
const instruction = process.argv[2] ?? "no instruction";
log.info(`Running with: ${instruction}`);
// your logic here
console.log("Done.");- Restart
npm run dev— the agent appears in the Dove sidebar automatically.
Add a schedule field to agent.json and set schedulingEnabled: true:
"schedulingEnabled": true,
"schedule": { "type": "calendar", "hour": 9, "minute": 0 }Then run npm run install to generate and activate the scheduler config. The agent will fire daily at 09:00 via launchd (macOS) or cron (Linux).
Per-agent env vars are declared in agent.json under envVars:
"envVars": [
{ "key": "MY_API_KEY", "value": "" }
]Fill in values through the Settings UI (Settings → agent name → Env Vars tab). Values are stored in ~/.dovepaw-lite/settings.agents/<name>/agent.json outside the repo.
All runtime state lives outside the repo under ~/.dovepaw-lite/ (override with DOVEPAW_DATA_DIR env var for server deployments):
| Path | Contents |
|---|---|
~/.dovepaw-lite/settings.json |
global settings: repositories, Dove persona, env vars |
~/.dovepaw-lite/settings.agents/<name>/agent.json |
per-agent repos, env vars, schedule |
~/.dovepaw-lite/workspaces/ |
isolated execution workspace roots |
~/.dovepaw-lite/agents/state/ |
persistent per-agent state |
~/.dovepaw-lite/agents/logs/ |
per-agent log files |
~/.dovepaw-lite/cron/ |
compiled scheduler scripts (generated by npm run install) |
Set S3_CONFIG_BUCKET to enable S3 write-through for all JSON config writes. On container startup, pull config before starting the app:
aws s3 sync s3://$S3_CONFIG_BUCKET/ ${DOVEPAW_DATA_DIR:-~/.dovepaw-lite}/
npm run devECS env vars:
| Env var | Required | Description |
|---|---|---|
S3_CONFIG_BUCKET |
Optional | S3 bucket name — activates write-through; unset = local mode only |
DOVEPAW_DATA_DIR |
Optional | Override data dir (default: ~/.dovepaw-lite/) |
AWS_REGION |
When S3 used | AWS region for the S3 client |
ANTHROPIC_API_KEY |
Required | Claude API key passed to the Claude Code CLI subprocess |
CLAUDE_CLI_PATH |
Optional | Path to the Claude Code CLI binary (default: ~/.local/bin/claude) |
OPENAI_API_KEY |
When using Codex | Required if AGENT_SCRIPT_MODEL is a GPT/Codex model |
The chat API is a plain HTTP SSE endpoint. Any frontend — Slack bot, CLI, mobile app — can talk to Dove without going through the Next.js UI.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/chat |
Send a message, receive SSE stream |
PATCH |
/api/chat |
Stop the current turn (keep session) |
DELETE |
/api/chat |
Stop and optionally delete a session |
Request body (application/json):
{ "message": "Run the hello-world agent", "sessionId": null, "streamEffort": "high" }message— the user's textsessionId—nullon the first message; the value from thesessionevent on every subsequent message in the same conversationstreamEffort—"high"(default) streams all events in real time;"low"suppresses intermediate text/tool/thinking events and emits only the final result, reducing bandwidth for non-interactive callers
Response: text/event-stream. Each event is a line of the form:
data: <JSON>\n\n
Parse each line by stripping the data: prefix and JSON-parsing the remainder.
type |
Payload | Action |
|---|---|---|
session |
{ sessionId: string } |
Save this. Pass it as sessionId on every subsequent turn to resume the conversation. |
text |
{ content: string } |
Append to the response buffer — Dove's reply arrives as incremental deltas. |
thinking |
{ content: string } |
Extended thinking delta — show or ignore. |
tool_call |
{ name: string } |
Dove invoked a tool — informational. |
tool_input |
{ content: string } |
Tool arguments JSON — informational. |
progress |
{ result: { output, progress } } |
Agent task progress — workflow step labels from a downstream agent. |
done |
{ content?: string } |
Stream complete. content is set when no text deltas were sent — use as the response fallback. |
cancelled |
— | User stopped the turn. |
error |
{ content: string } |
Query failed. |
permission |
{ requestId, toolName, toolInput, title? } |
Dove needs user approval to use a tool. POST { requestId, allowed } to /api/chat/permission. |
question |
{ requestId, questions } |
Dove is asking clarifying questions. POST { requestId, answers } to /api/chat/question. |
# Stop the current turn (subprocess exits, session row kept for resume)
curl -X PATCH http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"sessionId":"<id>"}'
# Delete a session entirely
curl -X DELETE http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"sessionId":"<id>","method":"delete"}'async function chat(baseUrl: string, message: string, sessionId: string | null) {
const res = await fetch(`${baseUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, sessionId }),
});
let text = "";
let nextSessionId: string | null = null;
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (const line of buffer.split("\n\n")) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
if (event.type === "session") nextSessionId = event.sessionId;
else if (event.type === "text") text += event.content;
else if (event.type === "done") {
if (!text && event.content) text = event.content;
break;
} else if (event.type === "error") throw new Error(event.content);
}
buffer = buffer.endsWith("\n\n") ? "" : (buffer.split("\n\n").at(-1) ?? "");
}
return { text, sessionId: nextSessionId };
}
// First turn — no sessionId
const turn1 = await chat("http://localhost:3000", "Hello Dove!", null);
console.log(turn1.text);
// Second turn — resume the same conversation
const turn2 = await chat("http://localhost:3000", "What agents do you have?", turn1.sessionId);
console.log(turn2.text);import httpx, json
def chat(base_url: str, message: str, session_id: str | None):
text, next_session_id = "", None
with httpx.stream("POST", f"{base_url}/api/chat",
json={"message": message, "sessionId": session_id},
timeout=None) as r:
buffer = ""
for chunk in r.iter_text():
buffer += chunk
while "\n\n" in buffer:
block, buffer = buffer.split("\n\n", 1)
if not block.startswith("data: "):
continue
event = json.loads(block[6:])
if event["type"] == "session":
next_session_id = event["sessionId"]
elif event["type"] == "text":
text += event["content"]
elif event["type"] == "done":
if not text and event.get("content"):
text = event["content"]
break
elif event["type"] == "cancelled":
break
elif event["type"] == "error":
raise RuntimeError(event["content"])
return text, next_session_id
# First turn
reply, sid = chat("http://localhost:3000", "Hello Dove!", None)
print(reply)
# Second turn — resume
reply, sid = chat("http://localhost:3000", "What agents do you have?", sid)
print(reply)- The SSE stream can stay open for up to 24 hours (
maxDuration = 86400) — set your proxy or load balancer timeout accordingly. - A
PATCH /api/chatstop leaves the Claude subprocess running in the background; the conversation can still be resumed.DELETEwithmethod: "delete"cleans it up entirely. - When the client disconnects mid-stream, the subprocess keeps running. This is intentional — long-running agents finish their work even if the Slack bot connection drops. Resume with the saved
sessionIdwhen reconnecting. - Each
sessionIdis a UUID. Conversation history (full message content) is stored by the Claude Agent SDK in~/.claude/projects/on the server — not in the in-memory store. The in-memory store only holds session status and progress metadata, which is lost on server restart.
Dove operates in one of three modes, configured in Settings → Dove:
| Mode | SDK permission mode | Effect |
|---|---|---|
| read-only | default |
Blocks all write tools via SDK disallowedTools + PreToolUse hooks. Write-capable Bash patterns (redirects, rm, mv, interpreters) are caught by a secondary regex gate. |
| supervised (default) | acceptEdits |
File edits are auto-approved; Bash commands and other tool calls prompt the user in the browser before executing. |
| autonomous | bypassPermissions |
All tool use is auto-approved. Suitable for fully-trusted local use only. |
sequenceDiagram
actor User as User (Browser)
participant API as Next.js /api/chat
participant SDK as Claude Agent SDK
participant Gate1 as disallowedTools (gate 1)
participant Gate2 as PreToolUse hooks (gate 2)
participant canUse as canUseTool callback
participant Script as Agent Script (main.ts)
User->>API: POST /api/chat { message }
note over API: resolve security mode
API->>SDK: query({ permissionMode, disallowedTools, hooks, canUseTool })
note over API,canUse: 🔒 Hard security rules (.claude/rules/security.md)<br/>• Never print/echo/log secret values — use variable names only<br/>• Never dump process.env — writes secrets to JSONL history permanently<br/>• Never hardcode secrets — load from env or secure store<br/>• Never log headers, env dumps, or config objects<br/>• Never put secrets in URLs — use headers or request body
loop each tool call
SDK->>Gate1: check disallowedTools
alt blocked
Gate1-->>SDK: deny
else passes
Gate1-->>SDK: allow
SDK->>Gate2: PreToolUse hook
alt blocked tool / Bash write (read-only)
Gate2-->>SDK: deny
else path outside allowedDirectories
Gate2-->>SDK: deny
else passes
Gate2-->>SDK: allow
alt autonomous mode
note over SDK: bypassPermissions — execute directly
else supervised mode
SDK->>canUse: can_use_tool?
canUse-->>User: SSE { type: "permission", requestId, toolName }
User->>API: POST /api/chat/permission { requestId, allowed }
alt allowed
canUse-->>SDK: allow
else denied
canUse-->>SDK: deny
end
end
SDK->>SDK: execute tool
end
end
end
note over SDK,Script: when Dove calls ask_* / start_* (agent invocation)
SDK->>Script: spawn(tsx · python3 · ruby · bash, [scriptPath, instruction],<br/>{ env: { DOVEPAW_SECURITY_MODE, DOVEPAW_DISALLOWED_TOOLS, AGENT_WORKSPACE, REPO_LIST },<br/> cwd: isolated workspace, secrets resolved from OS Keychain })
note over Script: resolveClaudeSecurityOpts() reads DOVEPAW_SECURITY_MODE<br/>→ enforces permissionMode + disallowedTools on inner query()
Script-->>SDK: output via A2A SSE
SDK-->>User: SSE stream (text · done · error)
PreToolUse hooks run inside the SDK's tool-dispatch loop and act as a second gate independent of the SDK's own permission model.
Read-only enforcement. When Dove mode is read-only, the hooks block every tool on the disallowedTools list (e.g. Write, Edit, TodoWrite, CronCreate) and inspect every Bash call for write patterns (output redirects >, sed -i, destructive commands). A tool that reaches the hook and matches is denied with an explanatory reason — it cannot be bypassed by the agent.
Directory restriction. Both Dove and each agent sub-process are given an allowedDirectories list (Dove: the project cwd plus any additional directories it needs; sub-agents: the workspace path plus the agent source and persistent state directories). Any Edit, Write, NotebookEdit, or Bash write call targeting a path outside that list is denied by a PreToolUse hook before the file is touched:
"<resolved_path>" is outside the allowed directories: /tmp/workspaces/.my-agent/...
You should stop and reconsider if you really need to access this path.
The agent is instructed to ask the user for explicit permission before retrying.
ScheduleWakeup guard. A hook blocks ScheduleWakeup while any await_* tool call is pending, preventing agents from scheduling a wake-up to defer polling.
In supervised mode, Dove uses a canUseTool callback instead of auto-approving everything. When a tool call needs approval, the server sends a permission SSE event to the browser:
{
"type": "permission",
"requestId": "...",
"toolName": "Bash",
"toolInput": { "command": "..." },
"title": "..."
}The user approves or denies via POST /api/chat/permission:
{ "requestId": "...", "allowed": true }Until the user responds, the agent is paused. If the browser disconnects, pending permissions are aborted and the agent stops waiting.
Agents launched by Dove run as SDK sub-agents with a permission mode inherited from Dove's security mode: read-only propagates fully (blocking all writes); supervised and autonomous both map to acceptEdits (no interactive approval UI for sub-agents). With:
- An isolated workspace directory (under
~/.dovepaw-lite/workspaces/) - A scoped
allowedDirectorieslist enforced by PreToolUse hooks - A fixed
allowedToolslist — only thestart_<name>andawait_<name>MCP tools are available; no arbitrary tool expansion
- Fork the repo and create a branch from
main. - Add or update tests for any changed behaviour.
- Run
npm run lint && npm testand ensure both pass. - Open a pull request — describe what changed and why.