Skip to content

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, with allow_text, no value and non-empty text.
  • checkboxes: known, distinct values, min ≤ count ≤ max; with allow_text, text may accompany the values or replace them (an empty selection is allowed only with text, even if min > 0). Without allow_text, an empty selection is a valid answer when min is 0.
  • text: no values, non-empty text.
  • text is at most 4000 characters, and refused on a choice type without allow_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_ask call waits at most 30 minutes, under Leo's 32-minute MCP tool ceiling. If the question is still open then, the call returns pending: true with its message_id, and calling leo_chat_ask again with only that message_id keeps 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_ask call 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 text message, so it pushes. From a task run it posts as a task card carrying the scheduling run's task and run_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 answered with 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:

{"answers":{"q1":{"values":["L"],"text":"…"}}}

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 PermissionRequest hook output updatedInput is not in Claude's documented hook contract (verified against claude 2.1.303). The documented route for the same effect is a PreToolUse hook that returns permissionDecision: "allow" with the same updatedInput; 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.