Leo Chat¶
Leo Chat is a message thread per agent between you and your agents, served by the daemon for the Leo iOS and Mac apps. The daemon owns the state; apps are clients. Messages live in SQLite at ~/.leo/state/chat.db and are kept indefinitely; attachments live under ~/.leo/state/chat/attachments/<agent>/<id>/<name> and are pruned after 90 days (the row stays, marked unavailable). The database is versioned (PRAGMA user_version) and upgraded in place, each step in a transaction, when the daemon opens it; a database from a newer leo is refused.
Config¶
chat:
whisper_model: ~/.leo/models/ggml-large-v3-turbo.bin
permission_timeout: 120s
question_timeout: 1h
tasks:
daily-news:
owner: assistant
| Field | Default | Description |
|---|---|---|
chat.whisper_model | unset | whisper.cpp ggml model used to transcribe voice notes. Unset or missing: a voice note is delivered with a "transcription failed" note. |
chat.permission_timeout | 120s | How long a Claude permission prompt waits for an answer from chat (at most 1h). |
chat.question_timeout | 1h | How long an AskUserQuestion questions card waits for an answer from chat (at most 1h, the hook's ceiling). Longer than the permission wait because questions are often answered from a phone. |
tasks.<name>.owner | see below | Agent whose thread gets this task's cards. Validated as an agent name only; agents are runtime state, resolved when the card posts. |
push.chat (defaults and templates) | on | Whether an agent's chat messages push to paired phones. |
A task's owner, when owner is unset: a runtime: persistent task's own agent; otherwise the agent whose workspace equals the task's workspace; otherwise assistant. An owner that names no agent posts to assistant with a system note naming it.
Voice notes¶
Transcription runs locally: ffmpeg converts the note to 16 kHz WAV and whisper-cli (whisper.cpp) transcribes it. Both and the model are optional, and leo doctor reports them. With Homebrew:
brew install whisper-cpp ffmpeg
mkdir -p ~/.leo/models
curl -L -o ~/.leo/models/ggml-large-v3-turbo.bin \
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin
then set chat.whisper_model. No restart is needed.
Delivery¶
A message you send is stored pending, transcribed if it is a voice note, then delivered as a user turn, stopping at the first route that works: the claude mod bridge, the agent's peer inbox, or a tmux paste. A stopped agent is started first, with a "Starting…" note in the thread. The agent sees:
[Leo Chat] <text or transcript>
Attachments: /abs/path/a.png, /abs/path/b.pdf
In reply to task daily-news run <run id> (<time>): <card text, up to 1500 characters>
A reply to an ask reads In reply to your leo_chat_ask <id> (<time>): <question> instead, followed by an Answer: {…} line once the ask is answered (see Ask lifecycle).
On success the message is delivered; on failure it is failed, with a system note saying why. A message still pending when the daemon restarts is delivered if it waits in the agent's bridge outbox, which delivers it after the restart. Otherwise it is marked failed with a note to send it again, never replayed twice.
Replies. For Claude agents the daemon captures the reply: on the agent's Stop hook it finds the delivered [Leo Chat] prompt in the transcript and posts that turn's last assistant message. It posts nothing when the agent called leo_chat_post during that turn, when another prompt (a message, a task, a task-notification) took over the turn, or when the reply is empty. A prompt not yet in the transcript, for example one queued behind a running turn, waits up to 30 minutes. Codex and opencode agents reply with leo_chat_post.
Agent tools¶
Supervised agents and task runs get seven MCP tools; leo_dispatch subagents are refused.
| Tool | Arguments | Result |
|---|---|---|
leo_chat_post | text, files (paths, up to 10, 50 MB each; relative to the workspace) | {id, agent} |
leo_chat_ask | question, input (below), timeout_s (optional, no default, max 3600); or message_id alone to resume | {values, labels, text, timed_out, closed_reason?, message_id, pending?} |
leo_chat_ask_withdraw | message_id | {message_id, withdrawn: true} |
leo_chat_history | limit (max 200), before, task | a page (below) |
leo_chat_schedule | text (Markdown), at (RFC3339 with an offset) | {id, at} |
leo_chat_schedule_list | none | your pending reminders, soonest first: [{id, at, text}] |
leo_chat_schedule_cancel | id | {id, cancelled: true} |
A headless task run has LEO_TASK and LEO_RUN_ID set, so its posts become task cards in the owner's thread. A runtime: persistent task runs in an agent that serves every turn, so the daemon files that agent's posts and asks under the task run whose turn is in flight.
To show a file, post it: leo_chat_post with text as the caption and files. To ask about a file, post it first and then call leo_chat_ask: an ask carries no files, and the thread keeps the two adjacent.
Ask inputs¶
input is {type, options?, allow_text?, min?, max?, placeholder?}, one per ask:
type | Answered by | Options |
|---|---|---|
buttons | tapping a choice, which submits at once | 1–12, required |
radio | selecting one choice, then submitting | 1–12, required |
checkboxes | selecting min–max choices, then submitting | 1–12, required |
text | one free-text field | forbidden |
min and max apply to checkboxes only: min defaults to 0 and max defaults to the number of options. An ask is rejected when min < 0, max < 1, min > max or max exceeds the option count, or when a nonzero min or any max is set on another input type.
Bare-string options are accepted only as MCP tool input; on the wire (API and events) options are always {value, label} objects, plus an optional description (omitted when empty) on every input type. allow_text (choice types) also accepts a free-text answer, placeholder hints the text field. Omitting input and giving top-level options asks buttons; giving both is an error.
An answer is {values, text}:
buttons/radio: exactly one known value, or, withallow_text, no value and non-emptytext.checkboxes: known, distinct values,min ≤ count ≤ max; withallow_text,textmay accompany the values or replace them (an empty selection is allowed only withtext, even ifmin > 0). Withoutallow_text, an empty selection is a valid answer whenminis 0.text: no values, non-emptytext.textis at most 4000 characters, and refused on a choice type withoutallow_text.
The tool returns values with their labels (same order) and the text. An ask that closed without an answer returns empty values, timed_out: true and a closed_reason.
The questions input is not an input type an agent can ask for: the daemon builds it for an AskUserQuestion (see Questions cards).
Ask lifecycle¶
An ask is open until it is answered, withdrawn, times out, or its agent is gone. Asks from an agent have no deadline by default: omit timeout_s (or pass 0) and the question waits for as long as it takes. A positive timeout_s (at most 3600) sets a deadline the daemon keeps, not the tool call: the ask expires when it passes. An ask from a headless runtime: oneshot task run, or from a persistent task whose owner is a different agent, has no agent to hand a late answer to, so without timeout_s it keeps a 300-second deadline.
- The call window. One
leo_chat_askcall waits at most 30 minutes, under Leo's 32-minute MCP tool ceiling. If the question is still open then, the call returnspending: truewith itsmessage_id, and callingleo_chat_askagain with only thatmessage_idkeeps waiting. The agent does not have to: a call the harness abandons leaves the ask open. - Late answers. When an ask is answered and no
leo_chat_askcall is blocked on it, the daemon delivers the answer to the asking agent as an ordinary inbound chat message that replies to the ask, through the usual delivery (a suspended agent resumes for it). The agent sees:
[Leo Chat] Yes
In reply to your leo_chat_ask 01JABC… (2026-10-15T18:00:00Z): Ship it?
Answer: {"message_id":"01JABC…","values":["y"],"labels":["Yes"],"text":""}
The first line is the chosen labels, then any free text ((no selection) if there is neither). A resume with the message_id still returns the stored answer, so an agent that both resumes and receives the delivery may see an answer twice. Asks from a task run are not delivered; resuming returns them. - Withdraw. leo_chat_ask_withdraw closes your own open ask. It is refused for another agent's ask and conflicts when the ask is no longer open. A blocked call returns at once. - Closed reasons. A timed-out, withdrawn, agent-gone or dismissed ask is expired, with closed_reason timeout, withdrawn, agent_gone or dismissed (the last only for a questions card). Existing clients that read only state keep working. - Agent gone. leo agent stop and removing an agent close its open asks as agent_gone. Idle suspension, a crash restart, a graceful restart and a daemon restart do not. - Restarts. Open asks survive a daemon restart. One with a deadline gets it re-armed for the time left, or expires at once if the deadline passed while the daemon was down. A resume after a restart reads the store: the answer, or pending: true while the ask is open. A permission prompt is different: its hook dies with the daemon, so it expires.
Reminders¶
leo_chat_schedule posts text into your thread at at, which must be an RFC3339 time with an offset (2026-10-14T09:00:00-04:00), strictly in the future and at most a year out; anything else is rejected. Replies give at in the daemon's local offset.
- Thread. A reminder belongs to the thread it will post into: your own for an agent, the task owner's for a task run (resolved when you schedule). List and cancel see only that thread's reminders. Cancelling an unknown or another thread's id is an error, as is cancelling one that has already fired or been cancelled.
- Firing. It posts a normal outgoing
textmessage, so it pushes. From a task run it posts as ataskcard carrying the scheduling run'staskandrun_id. - Restarts. Reminders are stored in
chat.db, and the daemon fires any that came due while it was down, once each. One more than a minute overdue ends with a line such as_(late — scheduled for Tue 14 Oct 2026 09:00 EDT)_(daemon local time).
Recurring and editable reminders are not supported: cancel and schedule again.
Permission prompts¶
Supervised Claude agents get a PermissionRequest hook. When Claude asks for permission, the prompt posts to the agent's thread as a permission message with allow and deny buttons, and the hook waits up to chat.permission_timeout for your answer. Claude's own terminal dialog stays up meanwhile, so either side can answer:
- Answered in chat: the hook returns the decision and the dialog closes.
- Answered in the terminal: Claude ends the hook and runs or skips the tool; the message becomes
answeredwith answer{values: ["terminal"]}. - No answer in time: the hook returns no decision, the dialog stays, and the message becomes
expired. Unlike an ask, a permission prompt always has this deadline, because a tool call is blocked on it.
Agents with bypass_permissions never ask.
Questions cards¶
When a supervised Claude agent calls AskUserQuestion, the same PermissionRequest hook posts ONE ask message for all of its questions (up to 4) instead of the Allow/Deny prompt. It has no permission field, and its input is of type questions:
{"type":"questions","questions":[{"id":"q1","header":"Size","question":"Which size?","multi":false,"allow_text":true,"options":[{"value":"L","label":"L","description":"large"}]}]}
The daemon assigns ids q1..qN by position. value equals label, and allow_text is always true (Claude always offers "Other"). Top-level options, min, max and placeholder are absent. An AskUserQuestion that does not fit (more than 4 questions, duplicate question text or labels, no options) falls back to the ordinary Allow/Deny prompt.
Answer with POST /api/v1/chat/{agent}/messages/{id}/answer:
Top-level values and text must be empty or absent, and answers is a 400 on any other input type. The answer is validated per question (400 on failure):
- every question id is present, and no unknown ids appear;
- each question has at least one value or non-blank text;
- single-select allows at most one value, and not a value together with text;
- multi-select values are distinct;
- every value is a known option;
- text is trimmed and at most 4000 characters.
The hook then answers Claude with allow and an updatedInput of the original questions array, verbatim, plus answers keyed by exact question text: the chosen label or the free text for a single-select question, and an array of the chosen labels with any free text as its last element for a multi-select one. The terminal dialog is skipped and Claude receives the answers.
The answered message has state: "answered" and answer: {"values":[],"text":"","answers":{…}}. Answered in the terminal first: answer: {"values":["terminal"],"text":""}, with no answers.
POST /api/v1/chat/{agent}/messages/{id}/dismiss (empty body) closes an open card without answering it: 200 with the message, now expired with closed_reason: "dismissed". It is a 400 for a message that is not a questions card and a 409 for a card already settled. A timeout is expired with closed_reason: "timeout".
In every case but an answer from chat (dismissal, timeout, daemon shutdown or restart, the hook going away, an answer given in the terminal first) the hook prints nothing, Claude's own dialog stands, and nothing is ever denied. The timeout is chat.question_timeout (default and maximum 1h), not chat.permission_timeout. A settled card is a 409, and a late answer is never delivered to the agent as a reply: the tool call is gone.
The chat.updated event is change: "answer" for an answer and change: "state" for a dismissal or timeout. The observe hello lists the chat.questions feature.
The
PermissionRequesthook outputupdatedInputis not in Claude's documented hook contract (verified against claude 2.1.303). The documented route for the same effect is aPreToolUsehook that returnspermissionDecision: "allow"with the sameupdatedInput; switch to it if this path ever breaks.
Task failure cards¶
When a runtime: oneshot task ends in failure (every attempt used, not interrupted) and has notify_on_fail: true, the daemon posts a card into the task owner's thread, whether or not the task has channels. Cron firings and leo run both count. The card is a task message (task, run_id set) and pushes like any other agent message:
**Task failed: <name>**
Run `<run_id>` · <n> attempt(s)
<first 500 characters of the error, in a code block>
Log: `<log path>`
The thread is the task's owner, resolved as for any task output, including the fallback to assistant with a system note when the owner is not an agent. If the task also has channels, the channel notification still fires; drop channels at cutover to end the duplicate. Persistent tasks run inside their agent and post no card, and interrupted runs post nothing. With the daemon down or chat off, leo run prints a warning and the run's result is unchanged.
API¶
Every route takes the operator token or a paired-device token (agent and API-client tokens are refused) and answers with the usual {ok, data, error?} envelope. The daemon's unix socket serves the same paths without a token for the Mac app.
| Route | Body | data |
|---|---|---|
GET /api/v1/chat/threads | {threads: [Thread]} | |
GET /api/v1/chat/{agent}/messages?before=&limit=&task= | {messages: [Message], has_more}, newest first, limit ≤ 200 | |
POST /api/v1/chat/{agent}/messages | {text, reply_to?, attachment_ids?} | Message (202) |
POST /api/v1/chat/uploads | multipart: one file part, optionally after an agent field; 50 MB | Attachment |
GET /api/v1/chat/attachments/{id} | the file, with Range support; 410 once pruned | |
POST /api/v1/chat/{agent}/messages/{id}/answer | {values, text?}, or {answers} for a questions card | Message; 400 when the answer does not fit the message's input, 409 once settled |
POST /api/v1/chat/{agent}/messages/{id}/dismiss | Message, expired as dismissed; 400 unless a questions card, 409 once settled | |
POST /api/v1/chat/{agent}/read | {up_to} |
The agent tools use POST /api/agent/{name}/chat/{post,ask,ask/withdraw,history} (agent token); ask/withdraw takes {message_id} and answers {message_id, withdrawn: true}, with 400 for another agent's ask and 409 for one that is no longer open.
The socket also serves POST /chat/task-failure {task, run_id, attempts, error, log_path} → {id, agent}, which leo run uses to report a failed run for a failure card. It is never served on TCP.
An upload without an agent belongs to the first message that sends it. Audio uploads make that message a voice note.
Message: {id, agent, direction (in|out), kind (text|voice|file|task|ask|permission|system), text, sender?, transcript?, reply_to?, task?, run_id?, state, answer?: {values, text, answers?}, input?: {type, options?, allow_text?, min?, max?, placeholder?, questions?}, permission?: {tool, summary}, attachments: [{id, filename, mime, bytes, available}], ts, deadline?, closed_reason?}; state is always present (empty on outbound text) and attachments is [] when there are none. States: inbound pending|delivered|failed; ask and permission open|answered|expired. deadline (RFC3339) is present only on an ask that has one; closed_reason (timeout|withdrawn|agent_gone|dismissed) only on an expired ask, never on a permission prompt.
sender is client:<name> on a message an API client posted (can_post), and absent on every message the agent wrote.
Thread: {agent, last_message, unread, pinned}. Only assistant is pinned.
Events¶
GET /api/v1/events (and the socket's /events) carries:
| Event | Payload beyond seq/at |
|---|---|
chat.message | agent, message |
chat.updated | agent, message (with closed_reason once an ask closes), change (transcript, state or answer) |
Push¶
An agent's outbound messages (not permission prompts or system notes) push as kind chat: title the agent, body the text or file names, plus message_id, message_kind, input_type for an ask (always sent; omitted on posts and permission prompts) and, for a buttons ask, options when they fit. A permission prompt keeps the needs_input attention push as its only alert, which carries the prompt's message_id with message_kind: "permission". A questions card is the ordinary chat push (message_kind: "ask", input_type: "questions", no options); the agent's own needs_input alert for the same question is dropped in its favour. Both go through the per-state push policy.
Trust boundary¶
The agent routes name the caller in the path, and every agent shares one agent token, so an agent could post into another agent's thread by lying about its name. The tools keep honest agents in their own thread; they are not a security boundary between agents.