Tooling, MCP, and tool-agents¶
milk gives any agent — primary or escalation, on any provider — three ways to act beyond generating text: built-in tools, other configured agents exposed as callable tools, and MCP servers.
Built-in tools¶
The primary agent (HTTP or Bedrock backends) has these tools available with no configuration:
| Tool | Parameters | Returns |
|---|---|---|
bash(command) |
command string |
stdout, stderr, exit_code |
find_files(path, pattern) |
root dir + glob (e.g. *_test.go) |
matching file paths — locate by filename; use grep for contents |
grep(pattern, path, recursive) |
pattern/path/bool | matches — searches file contents; use find_files for filenames |
read_file(path, offset, limit) |
path + optional range | content |
write_file(path, content) |
— | ok — creates parent directories; expands ~ |
edit_file(path, old_string, new_string, replace_all) |
— | ok — exact-string replacement; rejects ambiguous matches unless replace_all=true |
delete_file(path) |
— | ok — permission-gated |
move_file(source, destination) |
— | ok — permission-gated; creates destination parent directories |
list_dir(path) |
— | entries (names, types, sizes) |
http_get(url, max_bytes) |
— | body — bounded GET |
http_request(method, url, headers, body, max_bytes) |
— | body, status — permission-gated |
get_session_context() |
— | full shared session history (both agents), so the primary model can see prior escalation turns |
get_context_stats() |
— | current history turn counts and total character size, so the agent can self-regulate before hitting context limits |
open_file(path) |
— | ok — opens the file: the configured editor in the TUI, the platform opener over ACP; refused (TUI-only note) where no opener is wired, e.g. single-prompt mode or background jobs |
current_need(goal) |
one-sentence goal | ok — same effect as the user typing /need <goal> |
export_session(format, output_path) |
"text"|"json", optional file path |
transcript inline, or written to output_path |
milk_config_help(topic) |
e.g. "mcp add", "agent add" |
a section of milk's own embedded reference docs — lets the agent look up how to manage milk's own config instead of guessing the schema; omit topic to list what's available. Backed by internal/selfdocs, which indexes docs/reference/spec.md/providers.md/workflows.md/tooling.md/operations.md by heading — same content this site is built from |
spawn_background_agent(task, label, full_context?) |
self-contained task description + short display label + optional full-context flag | an immediate acknowledgement (job ID) — the eventual result arrives asynchronously, injected into the agent's next turn and shown live in the TUI. See ADR-0043 |
cancel_background_agent(job_id) |
a job ID returned by spawn_background_agent |
confirmation, or an error if the job is already finished/unknown — the model-facing equivalent of /bg stop |
Self-escalation (escalate(reason)) is covered in docs/guides/workflows.md. Memory tools (record_memory, get_memory, list_memory, forget_memory) and task tools (create_task, update_task, list_tasks, complete_task) are covered in docs/guides/operations.md — Memory tools. The memory tools are registered wherever milk's own tool loop runs — live turns, workflow roles, agent-as-tool peers, and background subagent forks (the last two scoped to their invoker's memory visibility) — and honor limits.included_tools/limits.excluded_tools like the rest of the built-in set.
spawn_background_agent — forking yourself for background research¶
Unlike Agent-as-Tool below (which calls a different, specifically configured peer agent synchronously, blocking the caller's turn until the peer finishes, and with no access to the caller's conversation), spawn_background_agent by default forks the calling agent's own config — same model, same built-in tools — to research a self-contained question asynchronously, with its own full tool loop, without spending the caller's own context. Only available when the calling agent is backed by an inference-server provider (not claude-cli, which already has an equivalent fork/Task tool natively, and not subprocess agents, which don't use milk's tool loop at all). A forked job cannot itself spawn further background agents (depth is capped at 1), and its internal tool activity never appears in the main transcript — its final result does: it lands in the transcript the moment the job finishes (capped to this same delivery budget; /bg show <id> prints it in full) and is injected into the agent's next turn once drained — but is watchable live on demand by double-clicking the job in the background panel (ADR-0047; see docs/guides/operations.md — Background sub-agents). When the caller has Agent-as-Tool peers (below), spawn_background_agent also takes an optional agent argument naming one of them: the job then runs on that peer — its model, tools and MCP servers — without blocking the caller. The allow-list is exactly the caller's enabled agent_tools entries (a name outside it is refused), the peer needs to be inference-server-backed, the job is still isolated and depth-1, and its tokens are tagged with the spawner's role (primary:subagent) against the peer's model. This is what makes a good description on an agent_tools entry matter twice: it guides both the synchronous agent_<name> call and the background hand-off. Concurrency is bounded per session by max_background_agents (default 3; see docs/guides/operations.md).
Restrict or extend the set per agent with limits.included_tools / limits.excluded_tools — see docs/guides/providers.md — Per-agent limit overrides.
Structured result (optional): a job's final answer may end with a machine-readable tag on its own line, <result status="ok|error|partial" files_touched="path1,path2"/>, instead of leaving the caller to parse free-form prose for that information. Purely opt-in — the background system prompt mentions the convention but the job is never required to use it; when absent, the result is delivered exactly as written.
full_context (optional, default false): a job is isolated by design — no session history and no injected percepts, just the task text (the memory tools are still available when the calling agent has them, so a job can look things up deliberately). Setting full_context: true additionally injects the spawning agent's own recent-activity summary (the same rolling, already-budget-capped summary handed to the escalation agent on hand-off) as extra orientation, for a task that genuinely depends on what the caller has been doing rather than a self-contained question. This makes the job more expensive and defeats most of the point of forking — prefer putting everything the task needs directly in task instead; reach for full_context only when that's not practical.
Claude CLI and subprocess (aider, smolagents) agents use their own native tool sets instead of this list.
Agent-as-Tool¶
Any agent in the agents list can be exposed as a callable tool to any other agent via the global agent_tools list (or per-agent tools overrides). When enabled, milk synthesises an OpenAI function-schema for each peer agent and injects it alongside the built-in tools; the calling agent invokes a peer by name as it would any other tool call. The peer agent receives the caller's prompt and returns a text result fed back as a tool result — no session state is shared between peer calls; each call is a stateless, fresh first-turn for the peer.
agent_tools field¶
"agent_tools": [
{ "agent": "haiku-aws", "description": "Fast summarization and classification agent." },
{ "agent": "claude", "description": "Full-capability Claude Code escalation agent.", "enabled": false }
]
| Field | Type | Description |
|---|---|---|
agent |
string | Name of the agent to expose as a tool (must match a name in agents) |
description |
string | Description shown to the calling agent as the tool's purpose |
enabled |
bool | Whether the tool is active (default true when omitted) |
An agent entry's own tools field shadows or extends this global list for that agent specifically (same agent name = replace; new name = append). A cycle guard prevents an agent from calling itself as a tool. Unknown agent names are silently dropped.
Manage at runtime with /agent tool list|enable|disable|add|remove — see docs/reference/spec.md — CLI Interface.
Worked example: real config using aider as a tool-agent¶
From a live setup where a Bearer-auth enterprise Copilot proxy is the primary agent, with aider available as a tool for direct file edits:
{
"agents": [
{
"name": "copilot-enterprise",
"provider": "bearer",
"url": "https://copilot-api.your-enterprise-ghe.example.com",
"model": "claude-sonnet-4.6",
"token_cmd": "gh auth token --hostname your-enterprise-ghe.example.com",
"tools": [
{
"agent": "aider",
"description": "aider is a coding agent that directly reads source code files and applies the requested changes"
}
]
},
{
"name": "aider",
"provider": "aider-cli",
"model": "openai/claude-sonnet-4.6",
"url": "https://copilot-api.your-enterprise-ghe.example.com",
"token_cmd": "gh auth token --hostname your-enterprise-ghe.example.com"
}
]
}
claude-cli as a tool-agent¶
A claude-cli agent can be a tool-agent too — called inline during another agent's tool loop, running the Claude CLI subprocess headlessly for each invocation.
Requirement: dangerously_skip_permissions: true is mandatory. Tool-agent calls have no interactive permission back-channel — Claude Code's --permission-prompt-tool stdio mode cannot be used headlessly. Without this flag, milk returns an error rather than risk a silent hang.
{
"agents": [
{
"name": "local",
"url": "http://localhost:8080",
"model": "qwen2.5-coder",
"tools": [
{ "agent": "claude-tool", "description": "Call Claude Code for complex reasoning or code generation tasks" }
]
},
{
"name": "claude-tool",
"provider": "claude-cli",
"bin": "claude",
"dangerously_skip_permissions": true
}
]
}
Limitations: no permission prompts (all tool uses auto-approved); each call is stateless (no session history); the tool-agent does not see the calling agent's session history. For testing without a live claude binary, point bin at a milk-mock claude wrapper — see docs/internal/mock-setup.md.
Known limitation, not planned to change: when a claude-cli agent (in any role) uses Claude Code's own Agent-tool (Task-tool) subagents or background workflows internally, milk's stream-json parser sees only their aggregated token counts (subagent_usage/workflow_usage on the final result event) — no content field exists there to surface, so there is no live-attach view for those, unlike spawn_background_agent jobs and milk's native /workflow engine (ADR-0047). This is a stream-json protocol limitation, not a milk parsing gap.
MCP servers¶
milk connects to Model Context Protocol servers and makes their tools available to any agent assigned to them.
mcp_servers field¶
Global list of servers; reference them from an agent entry via "mcp_servers": ["my-server"].
"mcp_servers": [
{ "name": "internal-docs", "url": "https://mcp.your-internal-host.example.com/api/v1/mcp", "auth": "none", "timeout": "30s", "connect_timeout": "5s" },
{ "name": "cloudflare", "url": "https://mcp.cloudflare.com/mcp", "auth": "oauth" },
{ "name": "atlassian", "url": "https://your-atlassian-mcp.example.com/mcp", "auth": "token_cmd", "token_cmd": "your-token-fetch-command" },
{ "name": "local-tool", "url": "http://localhost:3333/mcp", "enabled": true }
]
| Field | Type | Description |
|---|---|---|
name |
string | Unique identifier referenced from AgentConfig.mcp_servers |
url |
string | MCP endpoint. Required for http transport (Streamable HTTP with SSE fallback) |
transport |
string | "http" (default) or "stdio" (launches a subprocess, communicates over stdin/stdout) |
command |
string | Executable path. Required when transport is "stdio" |
args |
string[] | Command-line arguments for the stdio subprocess |
auth |
string | "none" (default), "oauth", or "token_cmd" |
token_cmd |
string | Shell command whose stdout is the Bearer token (auth: "token_cmd") |
enabled |
bool | Whether the server is active (default true when omitted) |
mcp_connect_timeout_secs (default 5) bounds the per-server startup connect timeout. If a server doesn't respond in time, milk logs a warning and continues — the server's tools are still registered, and the client reconnects lazily on the first tool call targeting it (recorded as an mcp.lazy_reconnect span).
Manage at runtime with /mcp, or milk config mcp add|remove|assign|unassign.
MCP servers with OAuth¶
milk has native OAuth 2.0 support — it discovers the server's OAuth metadata, registers itself as a client, and runs the Authorization Code + PKCE flow itself. No manual app registration for spec-compliant servers.
The resolved token is shared across every agent, not just the primary agent: if a server is assigned to the escalation (claude-cli) agent's mcp_servers too, the same token is forwarded into the --mcp-config file generated for that turn. One /mcp auth <server> covers every agent using it.
{ "name": "my-server", "url": "https://mcp.example.com", "auth": "oauth" }
For servers that require a pre-registered app instead of dynamic client registration (RFC 7591):
{
"name": "my-server",
"url": "https://mcp.example.com",
"auth": "oauth",
"oauth_client_id": "...",
"oauth_client_secret": "...",
"oauth_scopes": ["read", "write"]
}
oauth_auth_timeout (default "5m") bounds how long /mcp auth waits for you to complete the browser flow.
Authorizing: the first time an agent tries to use the server, milk detects the OAuth error and prints a notice pointing at /mcp auth <server-name>. Run it — the TUI stays interactive while milk discovers the server's OAuth endpoints, registers a client if needed, starts a local callback listener, and prints the authorization URL:
[milk] starting OAuth flow for MCP server "my-server"
[milk] authorization URL: https://auth.example.com/authorize?...
[milk] opening your browser — if it doesn't open, paste the URL above into one
milk tries to open your browser automatically; over SSH, open the printed URL yourself. On success, reconnect with /mcp reconnect my-server.
Notes:
- Tokens are stored by milk at ~/.milk/mcp_oauth/<server-name>.json — not by the Claude CLI or any external tool.
- Refresh is automatic (proactive before expiry, reactive on 401) using the stored refresh token. The escalation-agent path resolves the token fresh on every turn instead, since it has no persistent connection to retry.
- Locking is per-process — two milk instances against the same OAuth-protected server concurrently aren't coordinated beyond what the disk-backed token file naturally serializes.
- "auth": "token_cmd" servers follow the same cross-agent sharing: resolved once, forwarded to both the primary and escalation agents.
How each agent type connects¶
claude-cliagents: milk translates applicablemcp_serversentries into a JSON file passed via--mcp-config— HTTP servers become{"type":"http","url":"...","headers":{...}}entries, stdio servers become{"type":"stdio","command":"...","args":[...]}. Theclaudesubprocess connects to each server directly; milk does not proxy the connection.smolagentagents (native MCP): milk writes the agent's resolved server params to a temp JSON file and passes its path viaMILK_MCP_CONFIG_FILE; themilk-smolagentscript connects to each server itself through smolagents' nativeMCPClient(stdio and Streamable HTTP transports, auth resolved per turn viainternal/mcpauth.ResolveHeader) and adds the resulting tools to the agent's tool list — real, directly callable MCP tool execution. A server that fails to connect is skipped with a transcript warning; milk builds no MCPToolSetfor these agents (no competing connection). This path applies per turn to primary, escalation and tool-agent roles alike.aider-cliagents: no native MCP client exists upstream, so MCP tool schemas are serialised into a text block (internal/escalation.BuildMCPContextBlock) and injected into the agent's context alongside built-in tool descriptions — informational only, not a wired function-calling path.
Observability¶
The MCP client emits to ~/.milk/otel/:
| Signal | Type | Description |
|---|---|---|
mcp.connect |
span | One per server per connect attempt; status is ok or error |
mcp.tool_call |
span | One per tool invocation; server, tool, status attributes |
mcp.lazy_reconnect |
span | Emitted on a deferred reconnect triggered by first use |
mcp.connect_failures |
counter | Failed connects or lazy-reconnect failures |
mcp.tool_calls |
counter | Total tool calls dispatched through the MCP client |
Concurrent tool dispatch¶
When an agent emits a batch of tool calls in a single turn, all tools in the batch run concurrently, each in its own goroutine:
- Cancellation propagates immediately — Ctrl-C or
/stopcancels every in-flight tool goroutine; none wait for the turn timeout. - Per-tool timeout — each tool gets its own timeout; a hung tool is cancelled without affecting the rest of the batch.
- Result order preserved — results are appended to history in original call order, regardless of finish order.
- Permission checks are synchronous — run before dispatch so the TUI presents prompts in order without interleaving.
{
"agents": [
{
"name": "local",
"url": "http://localhost:8080",
"model": "qwen2.5-coder",
"limits": { "tool_timeout_secs": 30 }
}
]
}
| Field | Location | Default | Description |
|---|---|---|---|
tool_timeout_secs |
agents[*].limits |
120 | Per-tool timeout in seconds. -1 for no limit. |
turn_timeout_secs |
agents[*].limits |
600 | Per-turn timeout (unaffected by concurrent dispatch). |
File and image attachments¶
Attach files and images to an agent turn via /attach or by pasting a file path.
/attach <path>¶
/attach /path/to/notes.txt
/attach ~/screenshots/error.png
- Text files — contents injected as a fenced code block prepended to your prompt; both local and CLI agents receive the full contents.
- Images — base64-encoded and sent as a multipart vision payload to vision-capable local models (OpenAI
image_urlformat); for the Claude CLI path, a data URI is injected as a context block. - PDF and other binary files — noted as binary with a byte count; text extraction is not performed.
The status bar shows [N attached] while pending. Attachments clear after the turn completes.
File path detection in paste¶
Pasting text that looks like an existing absolute path (starting with / or ~/) prompts:
[milk] pasted path "/etc/hostname" — attach as file? [y/N]
y/Enter attaches it; n/Escape/anything else inserts it as plain text. Non-existent paths are inserted as plain text without prompting.
Session history¶
Attachment data is never stored verbatim. The session records a compact placeholder like [attached: filename.png] next to the prompt; file contents are sent on the turn they're attached, not persisted beyond that.
Supported types¶
| Extension | MIME type | Handling |
|---|---|---|
.png, .jpg, .jpeg, .gif, .webp |
image/* |
Multipart vision payload |
.txt, .md, .log |
text/plain |
Fenced block in prompt |
.go, .py, .js, .ts, .sh, … |
text/x-* |
Fenced block in prompt |
.json, .yaml, .toml, .html, .css |
Various text | Fenced block in prompt |
.pdf |
application/pdf |
Binary notice only |
| Other | text/plain (default) |
Fenced block in prompt |
Machine-readable output¶
milk can present itself to machines two ways: a long-lived ACP v2 agent
server for editor embedding (milk serve --acp), and a one-shot batch event
stream for CI/scripts (--output-format stream-json|json). Both are real,
shipped, and narrower than the full design — the wire contract's full
target shape is ratified in ADR-0049
and ADR-0050, and the complete
normative catalog lives in docs/internal/design/machine-readable-output-design.md
— but what's actually implemented today is a deliberately scoped subset of
that catalog, documented precisely (not aspirationally) in two places:
- docs/reference/acp-integration.md —
milk serve --acp: which of the 4 real methods work, which of the rest return a plain JSON-RPC "method not found," the permission-handling caveat (local-provider agents only), and a worked example exchange. Read this, not the design doc, if you're integrating an editor against milk right now. - Batch mode, below —
--output-format stream-json|json.
milk serve --acp # ACP v2 agent server on stdio (editor embedding)
milk [flags] "prompt" --output-format stream-json # batch event stream (CI/scripts/log pipelines)
milk [flags] "prompt" --output-format json # single terminal-result document
milk [flags] "prompt" # --output-format text (default, unchanged)
--output-format text— current behavior byte-for-byte, unchanged. Interactive TUI mode ignores--output-formatentirely.--output-format stream-json— exactly three lines on stdout per run, in order: onesystem/initevent, one completedassistantmessage (the whole response text — not token-level deltas; there is nostream_event/partial-message emission in batch mode today), and one terminalresultevent.--output-format jsonis that sameresultevent alone, as a singlejson.MarshalIndent-ed document.- Nothing else in the full §6 event catalog is emitted in batch mode: no
stream_eventpartial deltas, notool_use/tool_resultevents (tool calls happen, just not reported on this wire — see the eval harness'stool_calls: 0caveat in docs/reference/eval.md if you rely on this), nosystemsubtypes beyondinit(noagent_switch,route,notification,warning,state,task_*,memory,commands,config_option,permission_denied,error).system/inititself omitstoolsandmcp_servers— nothing at the CLI boundary enumerates them cheaply yet. --permission-mode/--allow-tool/--no-partial-messagesflags described in the design doc do not exist on the CLI — batch mode has no permission-prompt wiring at all today; a tool needing interactive confirmation is handled per the agent's own existing default (see docs/reference/acp-integration.md's permission caveat, which applies here too).- What is locked and accurate: snake_case fields throughout
(
input_tokens,cache_read,is_error,session_id), atypediscriminator per line (subtypefor thesystem/resultfamilies, all open-set), stdout = events only, stderr = prose ([milk]warnings stay on stderr, never mirrored into a stdout event yet), exactly one terminalresultper run, and no ANSI escapes ever on this wire. The machine-checkable schema for the three real line shapes isdocs/schema/stream-json.schema.json(golden fixtures underinternal/transport/streamjson/testdata/).