Start
How it works
The Pilothouse daemon, observe and wrap modes, the two approval checkpoints, presence-aware routing, session states and what each agent supports.
Pilothouse is a per-user daemon plus a few clients. Agents talk to the daemon through their hooks; you talk to it through the terminal inbox, the CLI, or the web app on your phone. Everything runs on your machine.
claude / codex ──hook──▶ pilothouse hook <agent> ──unix socket──▶ pilothouse daemon
│ policy engine
pilothouse (inbox), CLI ◀──────────── unix socket ─────────────────┤ presence
│ SQLite event log
phone / browser ◀── https (Tailscale Serve) ── 127.0.0.1:7517 ───┘ notifications
The daemon
pilothouse daemon (normally started by the user service) owns:
- a Unix socket (
0600) for the CLI, the inbox and the hook shim — the only place local decisions are accepted, - a web listener on
127.0.0.1:7517for the phone/browser app, which only accepts decisions signed by a paired device, - an append-only event log in SQLite. Every state change is written together with its event, so clients can load a snapshot and then follow the stream without missing anything.
If the daemon restarts, nothing is lost and wrapped agents keep running.
Two ways to run agents
Observe mode (default). Start claude or codex however you like. The hooks that pilothouse setup installed report to the daemon. Pilothouse never touches your terminal, so it works in any terminal, in tmux, inside herdr, over SSH, or in an IDE.
Wrap mode (optional). pilothouse run claude starts the agent inside a small holder process that owns its terminal. You get the same hooks plus: the agent survives closing the terminal window, you can re-attach from anywhere with pilothouse attach, and Pilothouse gets extra state from the terminal status sequence (OSC 7501). See Wrap mode.
Two checkpoints
Pilothouse looks at agent actions at two moments.
1. Before every tool call (PreToolUse). Your rules run here, on every tool call, whether or not the agent would have asked you. The result is one of:
| Outcome | What happens |
|---|---|
deny |
The tool call is blocked and the agent receives your rule’s message. |
ask |
The agent is told to ask a person, even if its own settings would have allowed the call. |
allow |
The call runs without a prompt. |
| no opinion | No rule matched; the agent’s own permission settings decide. |
This checkpoint never waits for a person, so it is fast: policy evaluation is designed to stay under 5 ms and the hook round trip under 25 ms.
2. When the agent wants a person (PermissionRequest). If the agent is going to show a permission dialog, Pilothouse decides where that question goes — see presence below.
Splitting it this way means “never force-push” holds even when the agent’s settings allow git push, and you are only interrupted when the agent would have interrupted you anyway (or your rule says ask).
Presence
Pilothouse tracks whether you’re at the machine using the OS idle time (macOS and Linux). After presence.idle_threshold (default 60 s) without keyboard or mouse input you’re away.
At your desk. Pilothouse steps aside immediately. The agent shows its own dialog, exactly as it would without Pilothouse; nothing is held and nothing freezes. If the dialog is still unanswered after presence.notify_after (default 45 s), you get a desktop notification.
Away. Pilothouse holds the request and notifies your paired devices (Web Push, ntfy, webhooks). The first valid answer wins — from the phone, the inbox, or the CLI. While it waits:
- if you come back to the keyboard, the request is handed back to the terminal and the agent’s own dialog appears,
- if nobody answers within
approvals.hold_limit(default 9 minutes, always kept below the agent’s hook timeout), the request expires and the agent’s own dialog takes over. You get one more notification saying so.
Override detection any time:
pilothouse here # treat me as present
pilothouse away # treat me as away (e.g. before a meeting)
pilothouse auto # back to idle detection
Set approvals.always_hold: true to send every permission request to your devices, even at the desk.
Answers
From the phone, the inbox or the CLI you can:
- Allow — once.
- Allow for session — also creates a grant for the rest of the session. For shell commands the grant covers the command prefix (
git push, not justgit push origin main); for other tools it covers the tool. Grants are applied after deny and ask rules, so a deny rule still wins. - Deny — optionally with a message the agent will see.
Every request ends in exactly one state: allowed, denied, answered_locally (you answered in the agent’s own dialog), or expired. A second answer gets “already decided”.
Session states
Each session is starting, working, waiting (finished a turn, your move), blocked (needs permission, has a question, or needs auth), errored, unknown or exited. States come from hook events first, then from the terminal status sequence in wrap mode, then from whether the process is still alive.
Not sure why a session shows what it shows?
pilothouse explain s_4k2m
explain prints the evidence and the rule that produced the state.
Agent support
| Claude Code | Codex | |
|---|---|---|
| Session state from hooks | Yes | Yes |
| Rules before every tool call | deny, ask and allow | Following Codex’s PreToolUse hook, which supports deny and allow but has no “ask” response |
| Permission requests to your phone | Yes (PermissionRequest hook) |
Yes, through Codex’s PermissionRequest hook (allow / deny) |
| One-time setup in the agent | None | Trust the Pilothouse hooks once with /hooks |
| Wrap mode | Yes | Yes |
Hook APIs change; the exact behaviour per agent version is documented in the repository (docs/AGENTS.md). OpenCode, GitHub Copilot CLI and Gemini CLI are on the roadmap.
If Pilothouse isn’t running
Pilothouse fails open by default. If the daemon is down or slow, the hook shim returns “no opinion” within two seconds and the agent behaves as if Pilothouse weren’t installed. If you would rather block tool calls in that case, set:
approvals:
fail_closed: true
With fail_closed, PreToolUse calls are denied while the daemon is unreachable.
Found a mistake? Open an issue.