tl;dr: Approve permission prompts from your phone via ntfy.sh — works with Claude Code, Codex, OpenCode, and any hook-compatible AI coding agent on macOS or Linux (systemd). A 20-second grace period keeps your phone quiet when you're at the terminal.
When an agent needs permission to run a tool, the terminal prompt appears instantly. If you answer at the terminal, your phone is never notified. If you walk away, a push notification arrives on your phone after the grace period — tap to respond, and the prompt auto-dismisses.
Agent fires hook (PreToolUse / PermissionRequest) or plugin event
→ Terminal prompt appears immediately
→ 20-second grace period starts
├── Answered at terminal? → phone stays quiet
└── No answer? → ntfy.sh notification sent to phone
→ Tap Allow / Deny / Retry
→ Adapter resolves the prompt (keystroke injection or reply API)
Each provider plugs into a provider-neutral core through an adapter: Claude Code via its hooks system (PreToolUse event), Codex via its hooks system (PermissionRequest event), Grok via its claude-compatible PreToolUse hook surface (~/.grok/hooks/*.json), OpenCode via an auto-loaded plugin, others the same way. A background daemon maintains a persistent SSE connection to ntfy so phone responses arrive instantly.
- macOS, or Linux with systemd (the daemon and watchdog run as systemd user units; a 60s user timer stands in for launchd's
StartIntervalwatchdog) - A terminal hookline can inject into: tmux (macOS and Linux) or iTerm2 / Terminal.app / WezTerm (macOS only — see terminal support)
bash,jq,curl,python3just(optional —bash install.shworks without it)- ntfy app on your phone (iOS / Android)
One-liner (downloads the latest release, no clone needed):
curl -fsSL https://raw.githubusercontent.com/tsyche/hookline/main/get.sh | bashThe one-liner never installs silently-unreviewed code: it fetches a tagged
release tarball, extracts it to a temp dir, and runs that release's own
install.sh — same file you would get from a clone. Piping still prompts for
the ntfy topic on your terminal (stdin is re-attached to your tty).
Or from a clone:
git clone https://github.com/tsyche/hookline.git
cd hookline
just install # or: bash install.shRun just in the repo to see every development command (lint, golden,
check-docs, test, …).
The installer will:
- Check dependencies (
jq,curl,python3) - Prompt for an ntfy topic name (or generate a random one)
- Write config to
~/.config/hookline/config - Install the hook to
~/.local/share/hookline/hooks/hookline.sh - Install the daemon to
~/.local/share/hookline/daemon/hookline-daemon - Register the daemon and the heartbeat watchdog with launchd (macOS) or
systemd user units (
~/.config/systemd/user/on Linux — the watchdog restarts a hung daemon;KeepAlive/Restartonly catches processes that exit). On Linux the installer probes the user bus first (if it's unreachable:loginctl enable-linger $USER, log out and back in) and reminds you to enable linger so services survive logout - Register the hook in
~/.claude/settings.json(plus a second Claude-profile settings file when one is present on the machine) - Install the OpenCode plugin to
~/.config/opencode/plugins/hookline.js, when~/.config/opencodeexists - Merge the Codex
PermissionRequesthook into~/.codex/hooks.json, when~/.codexexists (existing hooks in the file are preserved; review the new hook once inside codex via/hooks— codex skips untrusted hooks) - Merge the Grok
PreToolUsehook into~/.grok/hooks/hookline.json, when~/.grokexists (existing hooks preserved, config untouched; grok runs global hooks without a trust step)
Then open the ntfy app and subscribe to your topic (and your-topic-response).
Use your agent normally. When a permission prompt fires:
- At your terminal — answer as usual. No phone notification is sent.
- Away from your terminal — after 20 seconds, a push notification arrives on your phone.
| Button | Action |
|---|---|
| Allow | Approves this one request; terminal prompt auto-dismisses |
| Deny | Rejects the request; terminal prompt auto-dismisses with denial |
| Retry | Resends a fresh notification (useful when you catch it late) |
You can also type a reply in the ntfy channel instead of tapping: allow,
deny, or retry work anywhere a button would, and context (see below)
summarizes the session when you've lost the thread. With several prompts
pending at once a bare reply first pushes a picker listing each one — reply
1/2 to choose the prompt (your original reply applies to it), then send
the option/word as usual.
Every notification identifies itself at a glance — several sessions prompting at
once stay tellable apart. The title carries the provider and session
([claude · mysession / myrepo] Bash) and the body opens with
project · branch · directory before the command or question. Expiry notices,
invalid-reply corrections, and context summaries carry the same metadata.
Set HOOKLINE_ALERT_HEADER=0 to drop the body line (the title tag stays).
After HOOKLINE_PHONE_TIMEOUT the phone gets a "Prompt expired" notice, but the
background watcher stays alive for another HOOKLINE_EXTENDED_WAIT seconds
(default 3600 = 1h; 0 disables). Anything sent inside that window still lands —
including a typed retry or a button tap on the earlier notification — and the
expiry notice says how long you have. When the window closes, the watcher gives
up and the prompt is terminal-only again.
Away from the terminal and don't remember what the prompt is asking? Type
context in the ntfy channel while a prompt is pending: a separate
notification arrives with an assessed summary of where the conversation
stands — current task, what just happened, and what the pending prompt wants
(≤10 plain-text lines). The daemon asks the provider's own headless CLI
(claude -p, codex exec, grok -p, opencode run; opencode sessions come
from a read-only opencode export, since opencode has no transcript file) to
summarize the transcript tail the hook registered for the session; if that
fails or times out you get the raw tail instead. The pending prompt is
untouched — answer it afterwards by button or typed reply as usual. An
explicit request, so it pushes even while snoozed; needs the daemon, like all
typed replies.
At your desk and don't want the phone buzzing? Mute the phone channel without touching the terminal prompt:
hookline snooze 60 # no phone pushes for 60m
hookline snooze # status: active window + minutes left (or off/expired)
hookline snooze off # clear (also: 0 / clear)While snoozed, prompts stay at the terminal — answering there works as usual
(the watcher still detects a local answer and expiry notices don't push
either). From the ntfy app, type snooze (60m) or snooze 15 while a prompt
is pending; the confirmation carries an Unsnooze button (or type
unsnooze). hookline status shows the current window.
When OpenCode asks a question (question.asked) or Claude Code shows an
AskUserQuestion dialog, hookline applies the same grace period and notifies your
phone if you're away. For a single single-select question with 1–3 options, the
notification's buttons are the options themselves — tap one and the dialog
answers (OpenCode in-process via the reply bridge; Claude via keystroke
injection into the picker). You can also reply in the ntfy channel with an
option's number (4) or letter (D) — any option count works, since typing
goes past ntfy's 3-button cap; the body lists every option and ends with the
valid replies. Reply retry to re-notify or deny to dismiss. A reply that
doesn't match any option (like a typed word or an out-of-range number) gets a
correction notification listing the valid replies plus Retry/Deny buttons — the
question stays pending. Multi-select and stacked questions stay informational
only (the body lists every question, option, and description) and you answer at
the terminal. Notifications only fire when the ntfy config is set.
Known limits: ntfy allows 3 buttons and no structured input — multi-select questions, stacked questions, and typed/custom answers (anything outside the listed options) require the terminal.
The following Bash command prefixes are automatically approved without any prompt or notification:
echo, stat, ls, pwd, cat, grep, find, date, whoami, hostname, uname, which, type, file, head, tail, wc, sort, uniq, cut, tr
To permanently allow a tool/command, answer Yes at the terminal prompt and select the "Always allow" option. Patterns persist to settings.local.json and are auto-approved on future invocations without prompting (Claude-style settings files).
HOOKLINE_PROVIDERS in config is the whitelist of providers whose hook entries fire:
HOOKLINE_PROVIDERS="claude codex opencode grok" # unset = all installed providers enabledA provider not listed exits its hook silently — that agent behaves as if hookline were absent. Each provider registers its own entry point at install time (settings.json hook for Claude-style agents, hooks.json merge for Codex, plugin file for OpenCode, hooks/hookline.json merge for Grok); the entry calls hooks/hookline.sh <provider>, which gates on the registry before running the shared flow.
On Claude Code: native remote/mobile approvals already cover claude end-to-end — hookline still registers and works for claude (and Claude-style profiles), but is optional there. Its main job is bringing the same phone-approval UX to the other providers.
Codex support rides codex's own PermissionRequest hook — merged into ~/.codex/hooks.json at install (existing hooks in the file are preserved, codex config untouched). Four things to know:
- Trust it once. Open codex, run
/hooks, and review the hookline entry. Codex silently skips untrusted hooks until you do;hookline doctorandhookline statusboth report registered/trusted state. - An approval must actually fire. hookline only sees requests codex chooses to ask about — that depends on your codex
approval_policyand sandbox settings. If codex auto-approves or auto-denies by itself, no prompt reaches the hook and no phone notification is sent. - Scope is
Bash,apply_patch, and MCP tools (mcp__*). MCP approvals go through the same decline → menu → phone-answer flow. - The hook declines first, then injects keys. An empty decision hands the request back to codex's own approval menu (so the terminal looks completely normal); when the phone answers, the watcher sends Enter (approve) or Esc (cancel) into the tmux pane that owns the prompt — or the frontmost window outside tmux.
Grok support rides grok's claude-compatible PreToolUse hooks — a hookline.json entry merged into ~/.grok/hooks/ at install (existing hooks preserved, config.toml untouched; grok runs global hooks without a trust step). Three things to know:
- Scope is grok's native tool names. The matcher is an anchored alternation over
run_terminal_command,write,search_replace,ask_user_questionand their claude aliases, so an MCPserver__toolname containingwritenever matches. askforces grok's own permission card. Safe prefixes andBash(...)allowlist entries from your Claude settings (grok loads the same rules) defer silently; questions defer straight into grok's option picker and reuse the shared question body and buttons.- The watcher injects the keys — response-only like codex: allow types the allow-once row's digit, parsed off the pane screenshot because row labels and order vary by prompt class and the focused row defaults to always-approve (Enter is never safe); deny sends Ctrl+C (Esc only parks focus); a question answer types the option digit (the card auto-advances and auto-submits). tmux is the reliable path — outside tmux the screenshot comes from the bare terminal itself (iTerm2 session contents, Terminal.app's selected tab, or the WezTerm pane), and an unreadable screen injects nothing (the prompt stays for you to answer by hand).
- Create
hooks/adapters/<provider>.shimplementing the adapter interface documented at the top ofhooks/core.sh: normalize stdin JSON, extract the Bash command, parse an allowlist source, emit the provider's decision JSON, build the notification body, expose a local-progress counter, and inject/resolve allow|deny. - Register the provider's entry point (settings hook, plugin, etc.) to invoke
hookline.sh <provider>with the raw payload. - Set
ADAPTER_RESPONSE_ONLY=1when the daemon must not inject tmux keys for this provider — either the provider resolves decisions itself (reply API) or its prompt needs a different key profile than the daemon's built-in claude keys (codex: Enter/Esc, grok: row digit / Ctrl+C, both from the hook-side watcher). - Add golden cases in
scripts/hook-golden.sh, then runjust lint && just golden.
Config lives at ~/.config/hookline/config:
HOOKLINE_TOPIC="your-ntfy-topic" # required — subscribe to this in the ntfy app
HOOKLINE_NTFY_SERVER="https://ntfy.sh" # change for self-hosted ntfy
HOOKLINE_GRACE_PERIOD=20 # seconds before phone notification fires
HOOKLINE_PHONE_TIMEOUT=900 # seconds to wait for phone response (15 min)
HOOKLINE_EXTENDED_WAIT=3600 # extra seconds the watcher listens after the phone timeout (0 disables)
HOOKLINE_EXTENDED_INTERVAL=180 # cadence of extended-window checks (seconds)
HOOKLINE_MAX_RETRIES=3 # number of Retry button taps allowed
HOOKLINE_NTFY_USERNAME="" # for self-hosted ntfy with auth
HOOKLINE_NTFY_PASSWORD="" # for self-hosted ntfy with auth
HOOKLINE_PROVIDERS="claude codex opencode grok" # provider registry; unset = all enabled
HOOKLINE_ALERT_HEADER=1 # body metadata line (project · branch · dir); 0 = title tag only
HOOKLINE_CONTEXT_TIMEOUT=45 # seconds a typed `context` summary may takeChanges take effect immediately — no reinstall needed. hookline setup walks through
all of it (transport, topic, subscription QR, tmux check, live test) without editing
files by hand.
hookline setup # guided setup: transport, topic + QR, tmux check, live test
hookline status # version check (latest GitHub release), config, daemon status (heartbeat/SSE ages), connectivity, recent log
hookline doctor # diagnose the whole chain; fixes a dead/hung daemon
hookline topic # show the current ntfy topic
hookline topic <name> # switch topic, update config, restart daemon
hookline daemon start # start the daemon
hookline daemon stop # stop the daemon
hookline daemon restart # restart the daemon
hookline daemon status # daemon pid, sessions, pending approvals, heartbeat/SSE ages
hookline patterns # list allowlist entries (project + global settings)
hookline remove-pattern <p> # drop one entry (`--global` for the global file)
hookline clear-patterns # wipe the project allowlist (`--global` for global)
hookline snooze [minutes|off] # mute phone notifications (no arg = status)If phone notifications stop arriving, run hookline doctor — it checks the
interpreter, config, hook registration, daemon liveness (real socket ping plus
heartbeat ages), the launchd jobs / systemd units, and ntfy reachability, and
restarts a dead or hung daemon automatically.
just hooks # enable in-repo git hooks (pre-commit doc sync, pre-push gates)
just check-gates # every gate CI runs, in one command (the single source of truth)
just lint # shellcheck the shell scripts + py_compile the Python files
just golden # hook stdout contract tests (sandboxed, no network)
just test-daemon # daemon unit tests (registry, routing, heartbeat, watchdog)
just test-plugin # opencode plugin unit tests (node --test)
just doctor-test # sandboxed `hookline doctor` report tests
just status-test # sandboxed `hookline status` report tests
just patterns-test # sandboxed `hookline patterns`/`remove-pattern`/`clear-patterns` tests
just snooze-test # sandboxed `hookline snooze` window tests (fake HOME, no network)
just install-test # sandboxed install/uninstall round-trip tests
just get-test # sandboxed get.sh one-line install tests (file:// tarball)
just focus-test # sandboxed bare-terminal focus-targeting tests (dry-run)
just release-smoke-test # sandboxed release smoke check tests (fake gh, no network)
bash scripts/test.shscripts/test.sh sends a test notification with Allow/Deny buttons and reports
the response; the others run offline and also gate CI (ci.yml runs exactly
just check-gates). just release-smoke
asserts the latest GitHub release tag matches VERSION (needs gh auth) and
runs in CI right after every release.
tail -f ~/.local/share/hookline/hookline.log # hook log
tail -f ~/.local/share/hookline/daemon.log # daemon log (capped at 512KB + daemon.log.1 backup)
tail -f ~/.local/share/hookline/watchdog.log # watchdog restartsbash uninstall.shRemoves the hook from Claude settings, the Codex PermissionRequest entry, the
OpenCode plugin, the daemon and watchdog jobs (launchd plists / systemd units),
the CLI, and installed files. Optionally removes config and logs.
| Terminal | Method | Notes |
|---|---|---|
| tmux | tmux send-keys |
Focus-independent; works with any terminal inside tmux |
| iTerm2 | AppleScript (session-targeted) | Selects the prompt's own window/tab by TERM_SESSION_ID before the keystroke, so multiple open sessions don't cross wires |
| Terminal.app | AppleScript | Window index only — targets the frontmost window (fragile with several sessions) |
| WezTerm | AppleScript (pane-targeted) | wezterm cli activate-pane --pane-id focuses the prompt's pane first, then the keystroke lands |
| Other | AppleScript (frontmost) | Targets whichever app is in focus |
| OpenCode TUI | Reply API (no injection) | The plugin answers the prompt in-process; no terminal focus or Accessibility needed |
| Codex TUI | tmux send-keys (own pane) / AppleScript |
PermissionRequest declines → codex's own approval menu shows; phone answer injects Enter (approve) / Esc (cancel) into the pane that owns the prompt |
| Grok TUI | tmux send-keys (own pane) / AppleScript |
ask forces grok's permission card; the phone answer types the allow-once row's digit (parsed off the screen — Enter would hit grok's always-approve preselect) or Ctrl+C to deny |
When the phone answers a prompt in a bare terminal, hookline first focuses the exact session that asked (iTerm2 by session id, WezTerm by pane id) so keystrokes never land in a neighboring window; Terminal.app and unknown terminals keep the previous frontmost behavior. tmux needs none of this — send-keys targets the pane directly.
On Linux, tmux send-keys is the injection path that works today; bare-terminal keystroke injection (xdotool/ydotool) is planned (ROADMAP Phase 8 Tier 2).
For the AppleScript terminals, keystroke injection is performed by the hook process (a child of your terminal), so macOS Accessibility permission is only needed for the terminal app itself — never for a background process. tmux injection uses tmux send-keys (run by the daemon) and needs no Accessibility permission at all.
The ntfy topic name is your shared secret — anyone who knows it can send you notifications or respond to your approval requests. Use a unique, hard-to-guess name. For stronger isolation, self-host ntfy with auth and set HOOKLINE_NTFY_SERVER, HOOKLINE_NTFY_USERNAME, and HOOKLINE_NTFY_PASSWORD in config.
See ntfy.sh access control for auth options.
claude-remote-approver does something similar. hookline's differentiating features:
- Grace period — no stale phone notifications when you're at the terminal
- Local-answer detection — counts conversation entries, not raw lines, so agent metadata writes can't fake an answer
- Keystroke injection — terminal prompt auto-dismisses when phone responds (reply-API providers resolve in-process instead)
- SSE-based daemon — persistent connection for instant response; no polling delay
- Retry button — instant resend without re-waiting the grace period
- Typed replies — option numbers/letters plus
retry/denywords from the ntfy channel, andcontextfor an on-demand summary of where the conversation stands; invalid replies get a correction notification - Alert metadata — provider in the title, project/branch/directory as the body's first line, so concurrent sessions are tellable apart at a glance (
HOOKLINE_ALERT_HEADER=0for privacy) - Extended window — late answers still land for up to an hour after the phone timeout
- Fallback mode — works without the daemon via inline polling
- Multi-provider registry — Claude, Codex, OpenCode, and adapter-shaped future agents behind one core
See CONTRIBUTING.md — local testing (just check-gates),
how to add a provider adapter or notification backend, and the PR process.
MIT