Routing, sessions & workflows¶
How a prompt gets routed between agents, what happens across a turn, and milk's native multi-agent pipeline engine.
The only rule milk enforces: the escalation agent should be smarter (and typically pricier) than the primary agent; the primary agent should be cheaper than the escalation agent. Beyond that, there is no preferred or default backend for either role — every backend, including claude-cli, can serve either role (claude-cli as primary works but isn't a smart everyday choice — see docs/guides/providers.md). See docs/guides/providers.md for the full backend catalog, including an example of the same provider used at two model tiers for exactly this pairing.
Routing¶
Decision order per turn:
!direct-execution mode — pressing!as the first character of an empty textarea enters bang mode (Claude Code-style): the!is consumed rather than inserted, the prompt symbol switches live to a red!, and the rest of the line is typed as plain text. Submitting runs it immediately as a shell command, no confirmation, always taking priority over everything below — including while an agent turn is already in progress: it runs directly via the same embedded-PTY path rather than being funneled into the busy-state "Ctrl+Enter to spawn a background agent" flow (that flow is for everything else typed while busy, not bang commands — the agent has no special handling for milk's own!convention, so handing it the raw string would be meaningless). Output is appended to the transcript immediately and, if the agent turn was already running, queued to be included in its next turn's context.- Direct-bash shortcut — when
direct_bash: trueand the input matches the shell-command heuristic, milk promptsy/Nbefore running it locally viash -cinside an embedded PTY pane. Skips confirmation if the first token is indirect_bash_allow. Fires after slash-command handling but before agent routing. On Windows, where no real PTY is available, milk instead pipes the child's stdout/stderr and streams them into the transcript live (preferringbash/shon PATH — Git Bash, MSYS2, WSL interop — falling back tocmd /C); the child gets no stdin, so interactive prompts (editors, y/n confirmations) won't work there. WSL itself is unaffected and keeps using the real embedded-PTY path. - Explicit flags —
--escalateforces the escalation agent;--primaryforces the primary agent. Always wins. - Session state — if
ESCALATION_WAITING, bypass the router entirely and send directly to the escalation agent via--resume. - Rules layer — a layered scorer:
- Hard rules: prompt length above
escalate_above_tokens→ escalation; keyword match → escalation. - Short-prompt shortcut: at or below
local_below_tokens→ conclusive local. - Weighted signal scorer: local verbs, escalate verbs, path references, code blocks, and open-question prefixes each contribute a signed score; conclusive once the score reaches
escalate_thresholdorlocal_threshold. - Primary model classification — when the scorer is inconclusive, the primary model itself is asked to classify (
route: local | escalate); behavior configurable viaclassifier_fallback. - Default — attempt the primary agent; escalate if it calls
escalate(reason).
Direct-bash configuration¶
{ "direct_bash": true, "direct_bash_allow": ["ls", "git", "docker"] }
The heuristic is conservative: the first token must be a known binary name; natural-language openers (what, how, explain, …) always fall through to routing; prompts ending in ? are always questions; long non-flag, non-path tokens trigger rejection. The classifier reuses the primary agent's own model instance — no second inference server.
Customizing the rules block¶
All fields have built-in defaults (English + Italian keyword/verb lists); override only what you need. Lists are fully replaced, not merged — copy the default set and extend it if you add languages.
Real example (from a live config) adding bilingual open-question prefixes:
"rules": {
"escalate_above_tokens": 2000,
"local_below_tokens": 30,
"escalate_threshold": 6,
"local_threshold": -4,
"local_verb_weight": -3,
"escalate_verb_weight": 4,
"path_ref_weight": -2,
"code_block_weight": -2,
"open_question_weight": 3,
"classifier_fallback": "local",
"escalate_keywords": [
"architect", "refactor entire", "design", "explain why",
"analyze", "describe", "summarize", "context brick"
],
"local_verbs": ["grep", "find", "list", "run", "read", "fix", "debug", "show", "cat", "ls", "check", "print", "count", "search"],
"escalate_verbs": ["architect", "design", "refactor entire", "explain why", "compare", "evaluate", "plan", "propose", "summarize", "review"],
"open_question_prefixes": [
"what", "why", "how", "when", "where", "who", "which",
"could you", "can you", "would you", "should", "is it", "are there", "do you", "does",
"cosa", "come", "perché", "quando", "dove", "chi", "quale", "quali",
"potresti", "puoi", "dovresti", "è possibile", "ci sono", "sai"
]
}
| Field | Type | Default | Description |
|---|---|---|---|
escalate_above_tokens |
int | 2000 | Prompt exceeding this approximate token count is unconditionally escalated |
local_below_tokens |
int | 30 | Prompt at or below this token count is unconditionally kept local |
escalate_keywords |
[]string | see spec | Substring matches that unconditionally (hard) escalate. Keep short and specific — use escalate_verbs for soft signals |
escalate_threshold |
int | 6 | Soft score ≥ this → conclusive escalation |
local_threshold |
int | -4 | Soft score ≤ this → conclusive local |
local_verb_weight |
int | -3 | Score delta per local_verbs match |
escalate_verb_weight |
int | 4 | Score delta per escalate_verbs match |
path_ref_weight |
int | -2 | Score delta when the prompt contains a path that resolves on disk |
code_block_weight |
int | -2 | Score delta when the prompt contains a fenced code block |
open_question_weight |
int | 3 | Score delta when the prompt starts with an open-question prefix |
classifier_fallback |
string | "local" |
"local" asks the primary model to classify; "escalation" escalates directly when the scorer is inconclusive |
local_verbs / escalate_verbs |
[]string | English + Italian | Substring-matched; one match per prompt (first hit wins) |
open_question_prefixes |
[]string | English + Italian | Prefix-matched (word boundary) against the trimmed prompt start |
Guidelines: escalate_keywords should only contain multi-word or highly specific phrases where local routing would always be wrong — single common words like "design" are too broad ("the design looks off" is a local task). Put ambiguous terms in escalate_verbs instead, where they contribute a score rather than a binding decision.
Session states¶
ROUTING → rules + primary model decision → PRIMARY or ESCALATION
PRIMARY → --escalate OR primary model escalate() → ESCALATION
ESCALATION → escalation agent ends turn with a question → ESCALATION_WAITING
ESCALATION_WAITING → next user input bypasses router → direct --resume to escalation agent
ESCALATION_WAITING → user passes --primary → back to ROUTING
Sticky and auto-sticky escalation¶
/escalatealone (no inline prompt) pins every subsequent turn to the escalation agent until/primaryor Ctrl-C — shown as<agent> (pinned)./primaryalone does the same for the primary agent./escalate <prompt>//primary <prompt>is a single-turn override; normal routing resumes after.- Auto-sticky: when the router escalates without an explicit
/escalate, milk automatically keeps subsequent turns on the escalation agent (<agent> (sticky)) to avoid the cold-start context penalty on every turn. A one-shot/escalate <prompt>turns exactly one turn into an escalation turn and never becomes auto-sticky. Cleared by/primaryor a single-turn override. Disable globally with"sticky_escalation": false; explicit pinning is unaffected by this setting.
Context handoff¶
When promoting from primary to escalation, milk formats the local conversation as a plain transcript and hands it to the escalation agent so it can orient itself with no separate setup step:
[Context from primary agent session]
User: <turn>
Assistant: <turn>
[Tool: bash] <command>
[Tool result] <output>
...
User: <final prompt that triggered escalation>
For Claude CLI, a new session gets this across two --append-system-prompt-file flags to preserve Claude's prompt cache — a static file (identity block, per-session nonce tags, remembered percepts; byte-identical across turns) and a dynamic file (escalation brief, current need, last local summary). On a resumed session (--resume) Claude Code replays the system prompt it recorded on the session's first request (--system-prompt-snapshot, on by default) and ignores new system-prompt files until a compaction, so milk instead prepends whatever is new for that turn — the dynamic content, plus the static instructions when they are being re-injected — to the user message in a <milk-context>…</milk-context> block (dropped when identical to the previous resumed turn's block). The full static block is still passed as a file on every resume so that a compaction re-records it. Inference-server escalation agents receive the same content as the first system message.
Returning to a stale escalation¶
If the topic has switched, or returning_fresh_start_local_turns local turns (default 8) have passed since the last escalation, milk treats the next escalation as a fresh start rather than a resume: Claude CLI drops --resume, and inference-server escalation agents scope history to post-escalation turns only.
Session storage¶
~/.milk/sessions/
├── index.json # cwd → [{id, name, last_used}] sorted by last_used desc
└── <uuid>.json # full session: history, state, escalation_session_id, escalation_nonce
milk <prompt> resumes the most recent session for the current directory; milk --new always creates a fresh one; milk --session <id|prefix|name> targets an existing session by exact id, unambiguous id prefix, or name (cwd-scoped — the same name can exist in different projects) — or, with --new, names the fresh session.
The native /workflow engine¶
Routing and escalation handle a single agent handing off to a smarter one mid-conversation. The /workflow engine is different: it runs a predefined multi-agent pipeline with structured roles, loops, and a typed completion contract, rather than an open-ended conversation.
The motivating problem: orchestrating a multi-stage pipeline (e.g. design → implement → review, looping until the reviewer signs off) through an interactive coding agent is fragile — there's no structured handoff between stages, no typed verdict, and no reliable way to know a background stage actually finished. /workflow gives milk direct control over stage transitions instead.
Workflows are data, not code: each one is a YAML (or JSON) definition — a list of stages an interpreter (internal/workflow/interp) walks through — not a hand-written Go pipeline. Three ship built in; you can add your own the same way.
Built-in workflows¶
| Name | Roles | Shape |
|---|---|---|
dev |
designer, generator, evaluator | Designer plans sprints → generator implements each sprint → evaluator verdicts good_to_go / needs_refinement (retry) / sprint_done (advance). |
pair |
designer, generator, evaluator | Same shape as dev, but the evaluator pauses after writing findings for a user checkpoint — you can add context or say "continue" — before it decides the verdict. A human-in-the-loop variant of dev. |
swarm |
designer, worker, evaluator, implementer | Designer splits the task into independent (or dependency-ordered) items; a worker+evaluator retry loop runs per item, concurrently (bounded fan-out); a final evaluator checks cross-item integration; an optional implementer pass fixes integration issues, skipped entirely if the final evaluator found none. |
All three separate ambiguity resolution from execution: the designer either asks clarifying questions up front (paused for a user reply, with the design proceeding on stated defaults if you don't answer) or proceeds straight to planning when the task is already clear.
Usage¶
/workflow # list available workflows (built in + yours)
/workflow dev <task description> # run; wizard collects missing agent assignments
/workflow dev <task> --designer <agent> \
--generator <agent> --evaluator <agent> # inline agent assignment
/workflow swarm <task> --designer <agent> --worker <agent> \
--evaluator <agent> --implementer <agent>
/workflow status # show the running (or paused) workflow
/workflow cancel # stop the running workflow; /workflow resume continues it
/workflow resume # resume from the last checkpoint
/workflow reconfigure # reassign agent roles without losing saved state
/workflow clear # delete saved state for this session (type "clear" to confirm)
The role flags are whatever the chosen definition declares (dev/pair: --designer/--generator/--evaluator; swarm: --designer/--worker/--evaluator/--implementer) — the wizard asks for any role you didn't pass inline. Each role accepts any configured agent name, or the aliases primary/escalation, resolved once at workflow start.
A workflow always runs in the background, whether you start it with /workflow or the model starts it with the start_workflow tool: the status bar shows [⚙ workflow <name> running · <role>], and you keep a free prompt — chat, run commands, spawn background agents. Only one workflow runs per session at a time, and while it runs /workflow <name>, resume, reconfigure, clear and the session-switching commands (/new, /clear, /resume, /drop) are refused with a pointer to /workflow cancel (the checkpoint and the completion notice belong to the session). /workflow status and /workflow cancel work even mid-turn. Ctrl+C cancels the agent turn in flight if there is one; with an idle prompt it arms the usual "press again to exit" confirmation (quitting cancels the workflow) rather than cancelling the workflow, so stopping only the workflow is always the explicit /workflow cancel. If the designer asks clarifying questions, your next Enter answers them even if a turn is running, and Ctrl+C at that point cancels the workflow. When it ends, the outcome is queued and delivered with your next prompt, so the model knows how it went.
State is checkpointed to ~/.milk/sessions/<session-id>.workflow.<id>.interp.json after every stage. /workflow resume re-launches from that checkpoint. /workflow reconfigure re-runs the agent-assignment wizard against the saved state without touching progress, so the next /workflow resume continues from the same point with new agents.
The workflow progress panel (/panel workflow or F4, auto-opens when a workflow starts unless you've manually closed it — see docs/guides/operations.md — Keyboard shortcuts) shows current stage/iteration and verdict history — a structural summary, not the stage's actual output. Double-click the panel (ADR-0047) to watch that output live instead (for a workflow with a parallel_group, like swarm, the view carries a note: the workers run without live output, so during the fan-out it shows a [item N] started / done / failed line per worker instead): the main transcript swaps for the running stage's streamed text, Esc to swap back. This also fixed a real bug: stage output previously landed in the main session transcript directly, mixed in with the live conversation with no way to view it separately — it now accumulates in the workflow's own buffer, viewable only on demand. Remote oversight (Telegram) labels workflow turns workflow:<role> — see docs/guides/operations.md.
Writing a custom workflow¶
Drop a .yaml or .json file in ~/.milk/workflows/ — same Definition schema as the built-ins (internal/workflow/definitions/*.yaml, embedded in the binary). A file whose name matches a built-in replaces it; a new name registers a new workflow, listed by plain /workflow.
name: my-workflow
roles: [designer, generator, evaluator] # RunConfig.Runners must provide one agent per role
vars: # seeds the template context every stage's prompt renders against
shared_instructions: "..."
stages:
- id: designer
kind: agent_turn
role: designer
prompt: "Task: {{.task}}\n\n{{.shared_instructions}}"
save_as: plan # subsequent stages see this as {{.plan}}
Stage kinds:
| Kind | Purpose | Key fields |
|---|---|---|
agent_turn |
Run one turn on a role's agent | role, prompt (Go template), save_as; optional verdict (map of outcome → {action: break\|retry, warn}, read from the turn's output for an enclosing loop), skip_user_checkpoint_marker/on_answer_prompt (treat output as clarifying questions unless a marker line is present), empty_output_fallback: git_diff, run_unless_marker_in/run_unless_contains (skip this stage based on a prior stage's output), use_memory: true (opt this stage into the same percept injection primary/escalation turns get — off by default, since workflow roles are otherwise fully isolated: a fresh scratch session per role, no session-orientation context) |
loop |
Repeat its body |
Either over+from (iterate a declared collection parsed out of another stage's saved output — see below) or max_iterations/max_iterations_from (a bounded retry loop; the last body stage must declare verdict) |
user_checkpoint |
Pause for a real user reply | prompt (rendered from current vars), save_as |
parallel_group |
Like loop over, but each item's body runs concurrently |
over, from, max_concurrency, save_as (aggregated per-item results) |
Declared collections: a designer-style stage's saved output can declare structure the interpreter parses automatically — numbered sections (## Sprint 1, or ## Item 2 (depends_on: 1) for dependency-ordered parallel_group items) and scalar limits (max_passes: 4 on its own line). dev.yaml's plan_instructions var is the exact text instructing the designer agent to produce this format — reuse or adapt it in a custom definition rather than inventing a new plan syntax from scratch.
All workflows — including the built-in dev, pair, and swarm — run through the YAML-driven interpreter. Legacy dev-format checkpoints (pre-migration) can be cleared with /workflow clear and restarted.