Skip to main content

It calls you back

Voxa's signature move is simple to describe and has a lot of machinery behind it: when Claude finishes a turn or needs your input, your laptop calls your phone. Not a push notification, an actual incoming call, with a caller name and a summary of what happened. This page follows that call from the moment Claude stops working to the moment you pick up.

The signal: four hooks, always on

Voxa installs four Claude Code lifecycle hooks globally into ~/.claude/settings.json: Stop, Notification, UserPromptSubmit, and PreToolUse. Each one runs a curl that POSTs the hook's stdin JSON to the local Voxa server's /hook endpoint, over loopback, with an auth token in the URL. Every one of these commands ends in ; true, so if the local server is down or slow, Claude never notices and never blocks. For three of the four hooks, stdout and stderr are swallowed entirely. PreToolUse is the exception: its stdout stays connected, because that's the one hook that can hand back a deny decision Claude actually has to obey.

Each hook means something different to the server:

  • UserPromptSubmit records when a turn started. It doesn't announce anything on its own.
  • PreToolUse records the pending tool name and a capped summary of its input, and doubles as the danger gate for dictated or autonomous dangerous commands (see terminals).
  • Stop means a turn finished, either cleanly (landed) or with a failure signature in the output (broke).
  • Notification means Claude is blocked on you, a permission prompt or a question. This is the blocked reason.

The first real hook that arrives proves hooks are live on this machine, and the server responds by standing down its screen-watching fallbacks. Those fallbacks exist for the moment before that first hook fires, or for terminals hooks can't see into: TerminalWatcher polls every discoverable Claude session across tmux, iTerm2, and Terminal.app, and TranscriptMonitor follows GPU-rendered terminals through Claude's own transcript JSONL instead of reading the screen. Both stand down once hooks prove they're the more reliable, terminal-agnostic signal.

The decision: does this deserve an interrupt

Every /hook request starts with a token check, then folds the hook body into FleetState, the single in-memory model of what every Claude Code session is doing: its phase (working, idle, blocked, errored), whether a turn is open and how long it's been open, tool calls made, files touched, and whatever it's blocked on. Every downstream decision reads that model instead of parsing summary strings, which is what lets Voxa tell a 4-second turn from a 40-minute one, and tell two sessions in the same fleet apart.

From there, interrupt_policy.classify reduces the event to one of four reasons:

  • blocked: a Notification hook fired, or a Stop arrived while the session was still recorded blocked. The agent is frozen waiting on you, which is treated as a fact, never a guess.
  • broke: the Stop hook's summary, message, or transcript text matches a failure signature (a Python traceback, a SomeError: line, npm ERR!, a non-zero exit, and so on).
  • landed: the Stop hook is a clean finish, neither blocked nor broken.
  • trivial: anything else (UserPromptSubmit, PreToolUse). Not reportable on its own.

Each reason starts at one of three rungs, silent (recorded to the decision log only), notify (a visible push that never rings), or ring (an actual call):

  • blocked and broke both start at ring. Neither one looks at ring_threshold: a frozen or broken agent calls regardless, because silencing those would strand you. The only thing that quiets them is the mute switch (mute_blocked for blocked, mute_finish for broke).
  • trivial is always silent.
  • landed is silent if the turn stayed under the trivial floor (fallback_min_work_seconds, default 600, and fallback_min_tool_calls, default 15). Past that floor it's ring, unless the project's ring_threshold has been set to 101 or higher (Voxa's reserved "never ring" sentinel, since a normal threshold tops out at 100), in which case it's notify instead. This is the only rung ring_threshold affects.

That starting rung then passes through InterruptBudget, the guard layer that coalesces every session in the fleet into at most one interrupt per coalesce_seconds window (default 30s), and can only ever lower what it was handed, with one exception:

  • If you were at the laptop within presence_window_seconds (default 90s, proven by a recent UserPromptSubmit from anywhere in the fleet), a ring is demoted exactly one rung, to notify.
  • If a call already rang within min_seconds_between_rings (default 600s), a ring is demoted to notify too.
  • If the project is muted for that outcome (mute_finish for landed and broke, mute_blocked for blocked), a ring is demoted to notify. Mute takes the call away, never the information: the summary is still queued and still spoken next time you answer.
  • The one exception: a blocked session left unanswered for block_escalate_seconds (default 300s) with nobody demonstrably present overrides both of those guards and rings anyway. Mute beats even that: a muted project is never rung by the escalation either. A background ticker (escalation_tick_seconds, default 15s) keeps re-checking every blocked session for exactly this, since a frozen agent stops emitting hooks on its own and would otherwise never get re-evaluated.

If you already have a voice line open with Claude, any of this is moot: the summary is just spoken to you instead of interrupting, including one from some other, unattached session in the fleet.

Policy lives in ~/.voxa/interrupt_policy.json, keyed per project (by cwd) plus a reserved __default__ section, resolved the same way the old notify rules were: per-project override, then the default section, then Voxa's built-ins. mute_finish and mute_blocked are the two keys the phone's own mute toggle writes, and the ones to reach for when a project is too loud. If you already had a ~/.voxa/notify_rules.json from before this ladder existed, it's migrated into interrupt_policy.json automatically the first time Voxa starts, and left in place afterward: the legacy file is only ever read, never rewritten.

Every hook event, every classified reason, and every guarded decision, including the silent ones, is appended to ~/.voxa/hooks.jsonl, with the reason and a human-readable "why" ("finished after 640s and 22 tool calls", "you were at the laptop", "rang too recently"). That log, not guesswork, is what you read when tuning the ladder or the coalescing window. For a live look instead of tailing a file, GET /fleet?token=<auth_token> returns a ranked snapshot of every session Voxa currently knows about, and GET /fleet/timeline?token=<auth_token> returns the same decisions from hooks.jsonl as JSON, paginated with limit (defaults to 50, capped at 500).

The defaults are deliberately quiet: most short tasks land under the trivial floor and never leave silent. That's intentional, not a bug. Lower fallback_min_work_seconds and fallback_min_tool_calls to turn things back up; set a project's ring_threshold to 101 or higher to stop its finishes ringing without silencing a frozen or broken agent, or set mute_finish / mute_blocked to silence it properly.

A live notify push (not a ring) only reaches the phone today when you've configured your own APNs key; in zero-config hosted mode there's no relay equivalent yet, so a notify decision is queued instead and you see it the next time you open the app or attach.

The push: two ways to reach the phone

Whether a ring actually reaches Apple's push service depends on whether the laptop has its own APNs key configured. config.push_enabled is only true when all four are set: the key (path or raw value), the key ID, the team ID, and the bundle ID.

Self-hosted, with your own APNs key: the CallManager uses an ApnsClient to send a VoIP push directly, per registered device token, over a persistent HTTP/2 connection. Each request carries an ES256 JWT that's rebuilt roughly every 50 minutes, and a payload shaped like {call_id, summary, aps: {content-available: 1}, approval?}, with headers apns-topic: {bundle}.voip, apns-push-type: voip, apns-priority: 10, and apns-expiration: 0 so a stale ring never gets delivered late.

Zero-config, hosted: with no local APNs key, the laptop POSTs {account, summary, machine_id, approval?} to {VOXA_RELAY_URL}/notify. The account ID itself is the authorization here; the relay is the one holding the real APNs key, and it does the actual push on your behalf.

Either path also has to pick the right APNs environment. Apple runs separate hosts for sandbox (api.sandbox.push.apple.com) and production (api.push.apple.com); APNS_SANDBOX picks which one is tried first, matching whether the app was signed for Xcode/development (sandbox) or TestFlight/App Store (production). A 400 BadDeviceToken response makes the client retry the other host, and it remembers which host accepted each token going forward. If both hosts reject a token, the server treats it as dead: a 410 comes back and the token gets pruned.

The ring: CallKit, with the details ready before you answer

On the phone, every VoIP push has to report an actual call, that's an iOS/PushKit requirement, so even a thin or garbled payload still becomes a ring, falling back to the summary "Claude has an update" if nothing better came through. CallController primes the audio session and reports the incoming call with caller "Voxa" and the task summary as the call's context; supportsHolding is off, since there's nothing to hold. If iOS itself refuses to show the call, because Do Not Disturb or call filtering is blocking it, Voxa treats that the same as a decline and tells the server.

The moment CallKit accepts the ring, before you've actually tapped answer, Voxa opens the WebSocket back to the laptop. That preconnect means the round trip and the operator's greeting warm-up happen while the phone is still ringing, so answering feels instant instead of waiting on a connection. On a locked-screen answer, that same socket opens inside the audio-activation callback instead.

If the event was a blocked reason with a structured approval card attached, that card can show up right on the call screen, so you can approve or decline without even opening the app.

Connected Macs

Every laptop running Voxa registers itself in a small roster keyed by account: a stable per-install machine ID (persisted at ~/.voxa/machine-id), a name (VOXA_MACHINE_NAME, or the hostname if you haven't set one), and a can_ring flag that defaults to true. You can rename any Mac, or flip it from "ring" to "silent banner," from the Connected Macs view on the phone. When can_ring is false for a Mac, its updates arrive as a plain alert banner instead of a CallKit ring; an unknown machine, or a lookup failure, defaults to ringing rather than silently dropping the update.

Each laptop refreshes its own entry roughly every VOXA_MACHINE_HEARTBEAT_SECONDS (default 60s), and a Mac is shown as online if it's checked in within the last 120 seconds. Entries older than MACHINE_ROSTER_TTL_DAYS (default 30 days) get pruned automatically, and a per-account cap of 20 machines evicts the stalest entry once you're over the limit.

Answering, declining, and what happens if you don't

Answering does more than open a phone call. The server drains any queued updates and approvals, looks up which terminal actually triggered this ring, and attaches your voice session to that exact one, becoming the active session if it's part of a tracked fleet (unless you'd manually pinned a different terminal yourself). Whatever result was waiting gets spoken to you as the line opens, so you're not stuck reading a transcript to find out what happened.

Declining is remembered, not just dismissed. A decline is posted back to the server, which cancels the matching ring on every other device on your account, so answering (or declining) on one phone doesn't leave a second device ringing for the same event.

Not answering turns into a Missed Call after about 30 seconds, and the server is told about it the same way a decline would be, so it doesn't sit there thinking the call is still live.

Picking the work back up yourself cancels the ring too: if you return to the laptop and start interacting with Claude again before you've answered, a resume window (about 30 seconds) tells the scheduler you don't need the call anymore, and it's cancelled.

Free tier accounts that have used up their included calls for the month (VOXA_FREE_CALLS_PER_MONTH, default 3) get a plain alert banner instead of a ring, along the lines of "Claude finished, Voxa Pro would have called you." Paying accounts, and free accounts still under quota, always ring; if the quota check itself fails, the server fails open to ringing rather than silently going quiet.

The whole path, end to end

Claude Code hook (Stop / Notification / UserPromptSubmit / PreToolUse)
│ POST /hook (loopback, token; "; true" so it never blocks Claude)

FleetState (per-session phase, turn timing, tool calls, blocked_on)


interrupt_policy.classify (blocked / broke / landed / trivial)


InterruptBudget (fleet-wide coalescing, presence + rate-limit demotion,
block escalation) -> silent / notify / ring


Push (ring: self-host ApnsClient + your APNs key, or
zero-config relay's /notify, account id as auth
notify: local push only today, else queued for next attach)
│ VoIP payload: call_id, summary, approval? (ring only)

CallKit (iOS reports an incoming call, caller "Voxa")
│ ring-time preconnect opens the socket early

Answer
│ server pops the pending session, attaches you to it

Attached session (spoken recap, approval card if one was pending)

Make sure calls work

  • Confirm the four hooks are actually in ~/.claude/settings.json and point at the local server. Voxa's launcher installs them idempotently, but a hand-edited settings file can drop them.
  • Self-hosting: check all four of APNS_KEY_PATH (or APNS_KEY), APNS_KEY_ID, APNS_TEAM_ID, and APNS_BUNDLE_ID are set. See configuration for the full variable list.
  • Zero-config: confirm VOXA_RELAY_URL is reachable and your phone is paired to an account.
  • Check the project isn't muted in ~/.voxa/interrupt_policy.json (mute_finish or mute_blocked set to true, either under that project's cwd or under __default__), that its ring_threshold isn't 101 or higher (never-ring, finishes only), and that this Mac's can_ring is still on in Connected Macs.
  • Check iOS isn't filtering the call under Do Not Disturb or a call-screening setting.
  • If you're on the free tier, check you haven't used up this month's included calls.
  • Read ~/.voxa/hooks.jsonl for the actual reason and why Voxa did, or didn't, interrupt for that session.

If a ring still isn't reaching your phone after checking all of that, see troubleshooting.