@zeph-to/cli
v3.6.2
Published
Zeph CLI + push notification SDK for AI agents
Maintainers
Readme
@zeph-to/cli
Your agent works, hits a decision, and asks your phone. You tap a button (or type a reply), and the answer lands back in the live session — so the agent keeps going.
@zeph-to/cli is the terminal side of that round trip: a zero-dependency push SDK, a zeph CLI that wires up 8 AI agents in one command, and a resident listener that lets your phone drive any agent session it can start by typing straight into named tmux sessions.
ZephHookSDK — nativefetch, no runtime deps. Send / list / dismiss pushes.zephCLI — one-command setup for 8 agents, push sending, and the resident listener for phone-driven remote control.
Part of the Zeph toolchain: @zeph-to/mcp-server (the MCP tools your agent calls, e.g. zeph_ask) · zeph-to/plugin (Claude Code plugin bundling hooks + MCP + rules) · the Zeph app on your phone.
New here? docs.zeph.to walks the whole setup — one command on this machine, the app on your phone, and a restart. The reference below assumes that is already done.
Quick Start
Install the CLI globally, then run setup:
npm install -g @zeph-to/cli
zeph installzeph install opens a browser sign-in on a fresh machine — the web app
issues an API key + hook as a matched pair and the CLI writes them to
~/.zeph/config.json (no copy-paste). Then it installs rules + hooks +
MCP for every detected agent (Claude Code / Cursor / Windsurf / Gemini /
Codex / …). Safe to re-run: a saved login is reused untouched (no
prompts), so a re-run just refreshes the agent integrations. To switch
account, zeph install --relogin forces a fresh sign-in.
Why global, not npx? zeph cc (drive a session from your phone)
needs zeph on your PATH, and the agent hooks this installs are
$(command -v zeph || npx …) — a global binary skips an npx cold-start
on every notification. For notifications only, with no phone control,
npx @zeph-to/cli install is a lighter alternative that skips the global
binary.
Once installed, the hooks fire in every session of each configured
agent — zeph cc is the phone-control bridge, not the notification
switch. In Claude Code the routine per-turn push starts off (quiet is
the default) — while you're at the terminal; once you've stepped away, a
finished turn still pushes (Quiet while away).
/zeph-mode normal turns it on for a project, /zeph-mode loud
pushes on every turn, /zeph-mode mute silences a project entirely, and
/zeph-mode shows what's in effect. See
Mute & push mode.
~/.zeph/config.json is the single source of truth — the CLI, the MCP
server, the plugin hooks, and the listener all read it. You never need
ZEPH_API_KEY-style env vars for a normal setup; they exist as overrides
(second account, CI).
Variants:
# Headless box (no browser): paste credentials from another machine's
# ~/.zeph/config.json
zeph install --key ak_... --hook hook_...
# Skip the interactive agent picker
zeph install --only claude,cursor
# Switch account — force a fresh browser sign-in over a saved login
zeph install --relogin
# Refresh credentials only (no agent re-install) — assumes you already
# ran `zeph install` once; on its own it does NOT wire MCP into agents
zeph loginTo send notifications:
zeph notify --title "Deploy done" --body "v2.1.0 shipped"To drive an agent session from your phone, see Remote Control below.
Remote Control
Send messages from your phone into a live agent session — even after a
zeph_askpolling window has expired.
The MCP tool zeph_ask waits on a fixed
timeout (120–600 s). Once that window closes the
session becomes unaddressable from the phone, even though it's still
running. The zeph listener daemon fixes this by keeping a persistent
WebSocket open to Zeph and injecting matching messages into a named
tmux session via tmux send-keys.
Architecture
[phone — "Active Agents" picker on Zeph app]
│ selects session, types message
▼ POST /pushes/send { type: 'agent.command',
│ agentSessionName: 'zeph-myapp',
│ body: '리팩토링 마무리해줘' }
[Zeph backend]
│ WebSocket fan-out (push.new)
▼
[zeph listener — resident daemon, started by `zeph cc` automatically]
│ tmux send-keys -l -t zeph-myapp "리팩토링 마무리해줘" + Enter
▼
[tmux session "zeph-myapp" running claude / codex / cursor-agent / gemini / hermes]The listener polls its tmux session inventory every 5 seconds and reports it to the server whenever something changed (new session, agent state transition, activity) — an unchanged inventory is re-sent only as a 30-second idle heartbeat. The phone picker stays in sync with no manual configuration, and an idle listener costs the backend a fraction of what a fixed 5-second report cycle would.
Live timeline
While a session is working, the phone can draw what the agent is doing right now — the prompt that started the turn, each tool call and its outcome, the agent's own replies, and per-message token counts. It is read by tailing the agent's transcript on disk; nothing a tool read or wrote leaves the machine, only names, short labels and numbers.
Each agent stores its transcript differently, so a row in REMOTE_AGENT_TABLE
(src/remote-agents.ts) names two things: where the file is, and which
projector reads it. An agent with neither answers no_transcript and the phone
says it has no live timeline.
| Agent | Transcript | Projector |
|-------|-----------|-----------|
| Claude Code | ~/.claude/projects/<encoded cwd>/<uuid>.jsonl | src/transcript-tail.ts |
| pi | ~/.pi/agent/sessions/<encoded cwd>/<ts>_<id>.jsonl (PI_CODING_AGENT_DIR) | src/pi-transcript.ts |
| Codex CLI | ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl (CODEX_HOME) | src/codex-transcript.ts |
| Cursor, Gemini, Hermes, OpenCode | — none yet | — |
Adding an agent means a projector file of its own, not another branch in an existing one: the formats share no vocabulary. Each projector is pinned to the vendor layout it was read against, and an entry type it has never seen draws no row rather than breaking the watch.
Remote-origin detection (sticky REMOTE mode)
A message injected via send-keys is indistinguishable from typing — so
the listener also records each injection as a one-shot marker (epoch +
sha256 of the text, keyed by the pane's project dir). A prompt-submit
hook on the agent side matches the submitted prompt against that marker
and, on an exact match, tells the model the user is driving the session
from their phone — entering sticky REMOTE mode (every response ends with
an answerable zeph_ask).
| Agent | Hook | Installed by |
|-------|------|--------------|
| Claude Code | UserPromptSubmit → plugin's zeph-remote.sh | Zeph plugin |
| Gemini CLI | BeforeAgent → zeph remote-hook gemini | zeph setup |
| Codex CLI | UserPromptSubmit → zeph remote-hook codex | zeph setup |
| Pi | before_agent_start → zeph remote-hook pi (via extension) | zeph setup |
| Cursor CLI | — none yet | — |
Subagents (view-only). A subagent shows up on your phone as a child of its
parent card, named <session>.<n> — it streams live, but input, keys, exit,
resume and forget are all refused: you watch from the phone and answer at the
terminal, where the parent owns the conversation. A prompt push from a subagent
names it and opens its stream directly.
They reach the roster two ways. A pi subagent spawned into an extra pane of the
same tmux window is found as a pane, and labelled from @zeph_pane_label (the
pi extension sets it from PI_SUBAGENT_NAME). A Claude Code subagent — the
Agent tool — has no pane at all; it is found in the parent's transcript
directory instead (src/subagent-transcripts.ts), labelled from the
description the parent gave it, and carries the number of tools it has run so
far. Both kinds are numbered in one sequence, so the names never collide.
A Claude Code subagent is marked inProcess, because the phone cannot mirror a
pane that does not exist — its terminal view draws the transcript instead. It
leaves the roster the moment the parent's transcript records its end (a
background subagent's task notification, or a foreground one's Agent call
returning), rather than after five quiet minutes; one that is resumed comes
back when it writes again.
Detection is exact-match: a terminal keystroke racing a phone message can never false-flag. Muted projects are never flagged.
Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider, OpenCode) have one
other way in: a zeph_ask answer reporting zephState: "REMOTE". Their rule
files therefore keep the after-real-work zeph_ask — the "Entering REMOTE
without a prompt hook" preamble in src/templates.ts — so the phone always
has a button to tap that starts the loop. The one exception is an answer sent
with the phone's "send and exit": that turn carries out the instruction and
ends without an ask. Agents with the hook drop that
obligation while at the terminal — and their rule file carries only the
NORMAL branch of the core (about a quarter of it), since it is read on every
turn; the hook injects the REMOTE sections in full on the turn a phone
message enters REMOTE, the way the Claude Code plugin's hooks do.
zeph cursor has no remote-origin hook, so it never enters sticky REMOTE
mode by itself. Just ask for it — one line, once per session:
I'm driving this session from my phone. End every response with
zeph_askso I can answer with a button.
Nothing else is missing: injection works, and the MCP tools are all
there, zeph_ask included. Check them with cursor-agent mcp list-tools
zeph — mcp list prints only the approved list, not what's
configured, so it reads as empty even when the server is wired up.
Why it isn't automatic: hooks.json is honored by the Cursor IDE but not
by cursor-agent, and beforeSubmitPrompt's output schema is
{continue, user_message} — no context channel to deliver a marker match
through. Same reason the stop-hook auto-push zeph setup installs
covers the Cursor IDE but not zeph cursor panes; ask for a
zeph_notify when you want one.
zeph hermes is in the same position, and asking for it works the same
way. zeph install does not wire Hermes either — add the MCP server to
~/.hermes/config.yaml by hand, as one entry under mcp_servers:
mcp_servers:
zeph:
command: "zeph"
args: ["mcp"]zeph mcp runs the MCP server inside the CLI process. The older
command: "npx" / args: ["-y", "@zeph-to/mcp-server"] form still works,
but leaves an npm exec launcher resident beside the server for as long as
the session lives — zeph verify flags a config still on it.
Add the entry; do not replace the file. mcp_servers is shared with
every other server you have configured, and nothing under ~/.hermes is
in version control. Then /reload-mcp inside Hermes.
zeph_ask needs a hook id, which the MCP server reads from
ZEPH_HOOK_ID or from hookId in ~/.zeph/config.json — zeph install
writes the latter, so a machine that has run it once needs no env
block here.
Setup
Install tmux. The listener uses
send-keys; the wrapper spawns named sessions.zeph installchecks for it and, on macOS with Homebrew, offers to runbrew install tmuxfor you. Elsewhere it prints the line to run —sudo apt install tmuxon Debian/Ubuntu, and so on — because installing withsudois not something an installer should do on your behalf. tmux has no Windows build, sozeph ccneeds WSL there.Add
wsUrlto~/.zeph/config.json(the WebSocket endpoint of your Zeph backend — CDK outputWsApiUrl). Keep it on the same stage asbaseUrl: the REST stage is the path prefix (/v1prod,/d1dev) and the socket stage is the hostname, so they can disagree without either looking wrong.zeph verifysays which stage each one is on, and the listener only reads this at startup —zeph listener --restartafter editing:{ "apiKey": "ak_...", "hookId": "hook_...", "wsUrl": "wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>" }This is where it belongs.
--ws-urloverrides it for a single run.ZEPH_WS_URLis deprecated: it used to outrank this file, which meant anexportin a shell profile silently chose the stage and left no trace of having done so. It now loses to the file and is read only when the file has nowsUrl, so nothing that worked stops working — but it will go, andzeph verifysays so while it is still set.Run agents through the wrapper. That's it.
zeph cc # claude → tmux session "zeph-<project>" zeph codex # codex → tmux session "zeph-<project>" zeph cursor # cursor-agent → tmux session "zeph-<project>" zeph gemini # gemini → tmux session "zeph-<project>" zeph hermes # hermes → tmux session "zeph-<project>" zeph pi # pi → tmux session "zeph-<project>" zeph opencode # opencode → tmux session "zeph-<project>"Two wrapper flags, read off the front of the args:
--detachcreates the session without attaching (no TTY needed — a script or another agent's pane can launch it) and prints its name;--label <x>names itzeph-<project>-<x>instead of the-2/-3family. Example — a Claude Code planner starting a pi implementer for one plan:zeph pi --detach --label 20260914-PLAN-11-32-44-pi -n 20260914-PLAN-11-32-44 '/skill:02-implement .claude/20260914/PLAN-11-32-44.md'A labelled session, and any session started in a linked git worktree, still shows on the phone under its project — the main checkout's name — with the label (or the worktree's session name) beside it. The wrapper records both as tmux session options (
@zeph_project,@zeph_session_label) because the session name alone cannot say where the project ends. A session started without them (before this, or by an older CLI) is grouped by the listener from its pane's checkout instead, so upgrading the listener is enough — no session restart.Inside herdr. herdr finds agents by a pane's foreground process, which for a wrapped session is the tmux client, so the agent would not show in its sidebar. Every
zephattach records the herdr pane it runs in as session options (@zeph_herdr_pane,@zeph_herdr_socket,@zeph_herdr_bin), and clears them when attached from outside herdr; a baretmux attachchanges neither, so the agent keeps reporting to the last herdr panezephsaw. Reporters read them on every report and send the agent'sworking/idle/blockedstate to that pane: Claude Code through the Zeph plugin'szeph-herdr.shhook (plugin 0.20.0+) aszeph cc, pi through the Zeph pi extension aszeph pi. herdr's own pi extension reads the pane from its env, which tmux does not pass, so it stays silent in a wrapped session. Headless runs (claude -p,pi -p, RPC) started from a shell in a wrapped pane inherit itsTMUX_PANEand never report.A shell the phone may type into —
zeph sh. Every other shell pane is refused (see Defense);zeph shopens the one exception,zeph-<project>-shrunning your login shell, for the jobs an agent's permission prompt blocks — a restart, akill, asudo. It is a real terminal:cdandexportstick,sudo/sshprompts work, and a long command does not tie up an agent.--label <x>names itzeph-<project>-<x>;--detachcreates it without attaching. Away from the desk, open one from the phone through any running Claude Code session by sending!zeph sh --detach(Claude Code's!bash mode — see Defense item 7). Inside tmux it never attaches; it prints atmux switch-client -t <session>line instead. Running it again for a session it opened reattaches, and a session of that name it did not open is refused.zeph sh # → tmux session "zeph-<project>-sh" zeph sh --detach # create it, print its name, exitThe mirror fits a detached session to the phone. A pane is as wide as the terminal that last drew it, so a desktop's prompt rules and right-aligned clocks fold in two on a phone. While the phone watches a session no terminal is attached to (a
zeph sh --detachshell, or an agent whose terminal you closed), the listener resizes its window to the phone's width and gives the old width back when the phone leaves. An attached session keeps its size: shrinking it would shrink your desktop terminal, so the phone wraps those rows instead. A window split into panes is never resized, and one a terminal attaches to while the phone watches is handed back within a renew (5 s). A listener that died while a window was fitted restores it the next time it starts.zeph cursorrunscursor-agent, Cursor's terminal agent — a separate install from the Cursor IDE (the barecursoron your PATH is the editor launcher, which exits immediately and can't be driven).zeph hermesruns Hermes in its classic REPL, which is the default. Launching it in TUI mode instead (zeph hermes --tui, orHERMES_TUI=1in the environment) puts the pane on the terminal's alternate screen, and the alternate screen has no scrollback — the live mirror on your phone still draws, but scrolling up in it comes back empty.If a Hermes session disappears from the phone's picker, the cause is tmux having dropped the pane's
start_command: all the listener sees then is the interpreter Hermes execs (python3.11at time of writing), which names no agent.zeph listenerrun in the foreground prints the reason (no agent in pane (start=…, current=python3.11)). Kill the session and start it again withzeph hermesto recover.claudesits on the same edge for the same reason — it execs node.The first
zeph ccon a machine auto-spawns a background listener (singleton, PID file at~/.zeph/listener.pid, stdout/stderr at~/.zeph/listener.log). You never runzeph listenerby hand — everyzeph ccchecks the PID file and skips the spawn when one is already alive, so opening a dozen terminals doesn't create a dozen daemons. The daemon survives betweenzeph ccinvocations — but not a reboot, which is what the login-time service below is for.Project name resolves from
CLAUDE_PROJECT_DIR/CURSOR_PROJECT_DIR/WINDSURF_PROJECT_DIRif set, else the git repo root, else the cwd basename. Any extra args after the command pass through to the agent verbatim:zeph cc --resume "abc123" zeph cc --dangerously-skip-permissions zeph codex --model gpt-5-high "fix the failing test"Multiple sessions in one project. Open another terminal in the same folder, run
zeph ccagain, and the wrapper auto-suffixes: first session iszeph-encl, the next attached one becomeszeph-encl-2, thenzeph-encl-3, etc. The phone picker shows them asencl · Claude,encl · Claude #2,encl · Claude #3.Reattaching wins over spawning. If any session of that project is detached (no one attached), the wrapper reattaches instead of spawning a new one — close the terminal, come back later, pick up where you left off. When several are detached it takes the highest-numbered one first, then works down on later runs, so no session can end up out of reach. That matters because a session you cannot reach still holds a live agent: an agent CLI plus its MCP servers is a few hundred MB you can neither see nor reclaim. A detached session is never killed for you — nothing here can tell "done with this" from "closed the laptop".
Only a slot running the same agent is reattached.
zeph piwill not reattach a detachedzeph ccsession: tmux attaches to a session that already exists and drops the command it was given, so you would land in claude with pi never started. A slot running another agent is skipped and the next suffix is used instead.If you're already inside a tmux session (
$TMUXset) the wrapper skips the outer tmux and runs the agent in the current pane — the listener can't target an unnamed session that way, but you keep your existing multiplexer setup.
Starting the listener at login (macOS)
A reboot ends the daemon, and nothing starts it again until you open a
terminal and run zeph cc. Until you do, the phone shows no agents —
the machine is on, the past sessions are on disk, and the app has no one
to hear from.
zeph listener --install-serviceThat registers a launchd LaunchAgent (to.zeph.listener) that starts the
listener at every login. --uninstall-service removes it and
--service-status shows what the installed one points at; zeph install
offers it during setup and zeph uninstall takes it away again.
What you get back after a reboot is your past sessions, ready to resume from the phone — not live agents. tmux does not survive a reboot either, and this doesn't try to bring those sessions back to life.
Two details worth knowing:
- It fires at user login, not at boot. Without automatic login, the machine stays silent while it sits on the login screen.
- The plist bakes in absolute paths for node and the CLI, a
PATHthat can reach tmux, and a UTF-8LANG. launchd gives a job/usr/bin:/bin:/usr/sbin:/sbinand no locale at all: without the PATH the daemon exits 127 at every login, and without the locale tmux escapes the separator the session list is split on, so every session is silently dropped while everything else looks healthy. If any of that drifts — a version-manager node upgrade, say —zeph verifynames it, and re-running--install-servicerepoints the plist.
With the service installed, launchd owns the process: zeph cc and
zeph listener --stop|--restart ask launchd rather than signalling the
PID, so there is never a second daemon racing the first.
Keeping the Mac awake
The listener holds an outbound socket, and macOS "Wake for network
access" does not protect it — that setting only wakes the machine for
inbound requests to services it advertises. When the Mac idle-sleeps, the
socket dies and the phone shows "Waiting for the live stream…" until
someone opens the lid. A Mac running Claude Code hides this (the agent
runs caffeinate during every turn), so one machine looks fine while
another, idling at a prompt, keeps dropping.
So on macOS the listener runs caffeinate -s -w <its pid> for as long as
it lives. -s is honoured on AC power only — on battery macOS ignores
it, so a laptop still sleeps and nothing drains. The display still turns
off; a closed lid still sleeps. Activity Monitor shows the daemon under
"Preventing Sleep". Opt out with --no-keep-awake or "keepAwake": false
in ~/.zeph/config.json; the log's keep-awake: line says which way it
went.
Diagnostics
The auto-spawned listener writes to three files under ~/.zeph/:
listener.pid— the running daemon's PID.cat ~/.zeph/listener.pidps -p <pid>to confirm it's alive.
listener.version— the CLI version the daemon booted from. This is whatzeph cccompares against the installed package to spot a stale daemon (see below).listener.log— stdout + stderr from the daemon.tail -fto watch.
The daemon logs its version on the first line, which is the only reliable way to tell which build a long-running process is on:
[xx:xx:xx] zeph listener starting — v1.26.0 — wss://ws.zeph.toA healthy listener log shows one line per cycle:
[xx:xx:xx] reported 2 session(s): zeph-myapp, zeph-otherapp
[xx:xx:xx] ✓ server persisted 2 session(s)If you see ! server rejected listener.sessions: ... instead, the
message points at the failure (auth, missing device record, etc.) so
you can fix the actual problem instead of guessing.
To force a restart:
zeph listener --restartAfter upgrading @zeph-to/cli you normally don't have to. npm i -g
replaces the package on disk but never the daemon already running from
the old build — that daemon keeps answering pushes (so agent chat looks
fine) while silently ignoring every message subtype added since it
booted. zeph cc compares listener.version against the installed
version and restarts the daemon for you when the installed one is newer
(a daemon newer than the zeph cc you ran is left alone, so several
installs on one machine don't fight over it):
zeph: listener 1.25.0 is stale — restarting on 1.26.0If the PID file is missing (a different account started the daemon, or it was removed by hand) the singleton guard can't see it. Find the real process instead:
ps aux | grep '[c]li.js listener'To run it in the foreground (for development of the SDK itself):
zeph listenerYou'll get the same logs you'd otherwise tail from listener.log.
Custom tmux sockets
The listener auto-discovers the tmux socket — it probes the default
socket first and stops there when it answers. Otherwise it probes every
socket file in the per-user tmux-<uid>/ dirs ($TMUX_TMPDIR/$TMPDIR,
macOS /var/folders/.../T/, /tmp/), which is where tmux -L <name>
puts its socket. Only when none answers does it ask lsof which socket
each running tmux process holds — the slow path, which also covers a
-S path outside those dirs. If your tmux
uses tmux -L <name> or a non-standard -S <path>, set the override
explicitly:
export ZEPH_TMUX_SOCKET=/path/to/socket(The wrapper passes the env to the auto-spawned listener, so setting it in your shell rc is enough.)
Wire format
The listener only acts on pushes with type='agent.command' carrying
the tmux session name in agentSessionName and the message in body.
Other pushes (Stop-hook auto-pushes, zeph_ask responses, channel
broadcasts, plain notes) are ignored. End-to-end:
tmux send-keys -l -t <agentSessionName> "<body>"
tmux send-keys -t <agentSessionName> EnterIf you need to send one from the command line (debugging, scripting), build the structured push directly:
curl -X POST "$ZEPH_BASE_URL/pushes/send" \
-H "X-API-Key: $ZEPH_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"type": "agent.command",
"targetDeviceId": "dev_listener_<sha8(hostname)>",
"agentSessionName": "zeph-myapp",
"body": "테스트 통과시키고 PR 올려줘"
}'Defense
The listener is a remote-code-execution surface by design (it types into a shell-adjacent pane). The defense is layered:
- Pane guard — before injecting, the listener checks the pinned
pane's
#{pane_current_command}. If the pane is at an interactive shell (bash/zsh/fish/sh/dash/ksh/tcsh/csh/pwsh), the inject is refused. CC/Codex/Gemini exited ≠ phone gets free shell access. The one exception is azeph shsession: its@zeph_shellsession option names the session itself. Only a process on this machine can set a tmux option, and a value inherited fromset -gnames no session. The marker says you opened that shell; it is not a boundary — see what it does not stop, below. - Addressed shells only — a marked shell takes a REST
agent.commandonly when itstargetDeviceIdis this machine. An agent session still takes an unaddressed one, whichpush.newfans out to every listener of the account; for a shell, two PCs withzeph shopen in the same project would both run it. Stream input already arrives on one listener's own subscription. - Nothing a shell would misread — into a marked shell the listener
refuses attachments (their paths would arrive as lines, each a command),
control bytes such as ESC (which can leave a bracketed paste), and a
newline in an insert (a shell without bracketed paste, such as macOS
/bin/bash3.2, would run it). A multi-line body is allowed: each line is a command you meant. - Audit — every input admitted into a shell session is one line in
$XDG_STATE_HOME/zeph/shell-audit.log(default~/.local/state/zeph/shell-audit.log, mode 0600; past 1 MiB it moves toshell-audit.log.1, replacing the one before): time, session, pane, kind, foreground program, and the text only when the shell itself was in front. Input to anything else — asudoorsshpassword prompt — is recorded as[N chars → "sudo"].listener.log, which is not private, shows only the length. The foreground is judged when the input lands, so a secret read by the shell itself (read -s) is logged in full, and a command typed insidesudo -sorsshis logged by length only. - Literal injection — text is delivered as a paste of data, not typed key names; tmux escape sequences inside a message can't drive other tmux commands.
- Session-name allowlist — only
[A-Za-z0-9._-]+is accepted as a session target, so shell metacharacters never reach the tmux argv. Agents skip a shell:zeph sendandzeph_agent_send(from@zeph-to/mcp-server3.3.0) dropagentKind: "shell"sessions from their candidates. The check runs on the sending machine only, so an olderzephon any machine of your account can still type into your shell — update every machine that runs agents before you open one. - Per-session rate limit — a token bucket caps a runaway or compromised sender, shell sessions included.
- Agent permission gate — with one hole. Your CC/Codex/Gemini
permission prompt still stands in front of every tool call the agent
makes. But Claude Code's
!prefix runs a shell command directly, as typed input, with no permission prompt, and text the phone injects is typed input:!<command>sent to a Claude Code session runs on this machine. This is accepted, and it is how!zeph sh --detachopens a shell from the phone.
What the marker does not stop: anything running as your user on this
machine can set @zeph_shell on a session, or run zeph sh itself — and
so can the phone, while a Claude Code session (!tmux set-option …) or a
marked shell is open to it. It keeps the phone from choosing panes only
when neither is.
And no layer authenticates the sender: whoever holds your API key, or
controls the backend, can type into every marked shell and, through !,
every Claude Code session.
The transport (WS) is authenticated by API key + push:read scope.
Whether your backend also reads what crosses it depends on encryption
being on:
- Encryption off — the default. The phone has no device keypair to hand the listener, so pane frames and the messages you type both cross the relay in plaintext.
- Encryption on. The phone sends its device public key when it
subscribes, every pane frame comes back inside an ECDH P-256 +
AES-256-GCM envelope, and your keystrokes are sealed for this
listener with their
seq/epochstamps embedded in the ciphertext, so the relay can't replay one. A frame that fails to encrypt is dropped, never downgraded.
Two gaps either way. A message sent while no live stream is open falls
back to REST, which is plaintext to the server — that includes every
@<session> command from the agent chat, and every command typed into a
zeph sh shell. With encryption off, a shell's screen crosses the relay in
plaintext too, so keep secrets off both. And the sealed channel buys
confidentiality against a passive relay only: each side learns the
other's key from the wire, so a backend that mints its own keypair can
pose as the listener.
CLI Usage
# Send a notification
zeph notify --title "Deploy done" --body "v2.1.0 shipped"
# Send with priority
zeph notify --title "Build failed" --priority high --url https://ci.example.com/123
# List recent pushes
zeph list
zeph list --limit 10 --type note
# Dismiss a push
zeph dismiss push_01JXY...
zeph dismiss --all
# Rename the current agent session (shows in the app's Agents list)
zeph rename "Prod deploy"
zeph rename --clear # reset to the default name
# Type a message into another agent session, here or on another PC
zeph send dev_listener_ab12cd34:zeph-api "Build is green — rerun the e2e suite"
zeph send zeph-api "…" # a name/alias works when it names one session
zeph send zeph-api - <<'EOF' # a long message on stdin; the quoted heredoc expands nothing
Build is green. `npm test` passed.
EOF
# Drop an ended session from this machine's record (it leaves the app's past list)
zeph forget zeph-myapp-20260914-PLAN-11-32-44-pi
zeph forget --ended # every session tmux no longer has
# Test connection
zeph test
# Run an agent in a named tmux session (so the listener can reach it)
zeph cc # claude
zeph codex # codex
zeph cursor # cursor-agent (Cursor CLI, not the IDE)
zeph gemini # gemini
zeph hermes # hermes
# Run the resident listener (foreground; background it as you like)
zeph listener
zeph listener --ws-url wss://... # override config
# JSON output
zeph notify --title "Hello" --jsonCommands
| Command | Description |
|---------|-------------|
| login | Browser sign-in: auto-fetch API key + hook into ~/.zeph/config.json over a localhost loopback (--web-url, --timeout). No copy-paste |
| install (alias: setup) | One-command setup: detect agents, save config, install rules + hooks + MCP. No saved config → opens browser login automatically. --only claude,cursor,… skips the picker |
| uninstall | Remove Zeph from all detected agents (--dry-run, --purge) |
| verify | Check installation health across detected agents (--ping for a live API call). Also reports the resolved WebSocket URL and where it came from — --ws-url, config, the deprecated ZEPH_WS_URL, or the built-in default — warns when the socket and the API base are on different stages, and warns when the running listener is on a different URL than the one that resolves now (it reads the config once, at startup) |
| check-update | Check whether a newer Zeph version is on npm |
| notify | Send a push notification |
| list | List recent push notifications |
| dismiss <id> | Dismiss a push (or --all) |
| rename <name> | Set the current agent session's display name in the app — run inside a zeph cc session (--clear resets). Auto-detects the tmux session + this machine's listener device id, so the alias lands on the right device |
| send <target> <message…> | Type a message into another agent session — on this machine or another — as if the user sent it from the phone; it shows in that session's chat and puts it in remote mode. target is the <deviceId>:<session> key or a name/alias that names exactly one session (a name two machines share is refused, with both keys listed, and so is a session on a machine that is offline — its listener would never type it). The receiver reads [from <your alias or tmux name>@<machine> · reply: <your key>] first; outside tmux it is [from cli@<host>], and from a tmux session the listener does not report, or from a subagent, it is [from <session name>@<host>] — neither carries a reply key. The CLI twin of the MCP zeph_agent_send, for agents without MCP (pi). Global flags (--key, --base-url) go before send; everything after the target is the message, verbatim, and a --key or --base-url placed there is refused (exit 2) rather than typed into the other session. A - as the message reads it from stdin: write it on a quoted heredoc (<<'EOF'), since inside double quotes the shell would run backticks or $(…) in it. The body is plaintext even with E2E on — keep secrets out. Exit 0 sent, 2 usage, 1 failed, 3 no API key |
| forget <session> | Drop an ended session from this machine's record, so it leaves the app's past list (and the resume whitelist — the same thing deleting the row on the phone does). Refuses a session that is still running: kill it first. --ended forgets every remembered session tmux no longer has, which is what a machine that launches agents under a fresh name per task eventually needs |
| test | Verify connection and API key |
| cc · codex · gemini | Run the agent in a zeph-<project> tmux session — reattaches a detached session of that project (newest suffix first) when there is one, else auto-suffixes -2, -3, …. Auto-spawns the background listener on first invocation so the phone picker just works. Trailing args pass through to the agent (zeph cc --resume "..."). On node 22.15+ the wrapper hands its process to tmux rather than waiting on it, so a running session shows no zeph cc of its own in ps — older runtimes and Windows keep the waiting wrapper --detach / --label <x> (front of the args) create without attaching / pin the name — see "Run agents through the wrapper" above. |
| sh | Open (or reattach) zeph-<project>-sh, a login shell the phone may type into — the one shell pane the listener does not refuse. --label <x> / --detach as for the agents; inside tmux it never attaches and prints a switch-client hint. Refuses a session of that name it did not open. Input must be addressed to this machine, is audited, and agents (send, zeph_agent_send) skip it — see Defense |
| mcp | Run the MCP server on stdio, in this process. What agent MCP configs launch — zeph install registers it and you never type it. Replaces the old npx -y @zeph-to/mcp-server registration, which left an npm exec launcher resident alongside the server for the life of the session. @zeph-to/mcp-server ships as a dependency now, so it updates with the CLI rather than being re-fetched by npx on every launch — zeph check-update reports the version you actually have |
| listener | (Usually unnecessary — zeph cc autospawns it.) Resident daemon: subscribes via WebSocket, reports tmux session inventory every 5 s, injects agent.command pushes into the matching session. Run in the foreground for SDK development; otherwise let zeph cc manage it |
Notify Options
| Flag | Description |
|------|-------------|
| --title <text> | Push title (default: "Task done") |
| --body <text> | Push body (default: "<project> · <branch>" if cwd is a git repo, else "<project>") |
| --url <url> | URL to include |
| --type <type> | Push type: note, link, file, hook |
| --priority <p> | Priority: low, normal, high, urgent |
| --device <id> | Target device ID |
| --session <id> | AI session ID so the push threads into that session's chat (or ZEPH_SESSION_ID env) |
| --auto | Apply the push gate before sending — honors the /zeph-mode quiet / /zeph-mode loud push-mode dial, per project or machine-wide (--global); gated-out exits silently with code 0 |
| --pushmode-default <m> | Mode --auto assumes when the project has no dial: quiet (built-in), normal, loud. A dial the user set always wins |
| --marker <m> | Push Signal marker for --auto: skip, push, high |
| --tools <n>, --nonreadonly <n> | Turn tool counts feeding --auto's heuristic (defaults assume real work) |
The defaults are tuned for hook-driven invocations (e.g. Stop hooks
calling zeph notify --title "Task done" without a body) — you'll see
which project + branch finished without writing per-IDE wrappers. Pass
--body "" explicitly to suppress.
Listener Options
| Flag | Description |
|------|-------------|
| --ws-url <url> | WebSocket endpoint for this run only; the lasting setting is wsUrl in ~/.zeph/config.json |
| --key <api-key> | API key (or set ZEPH_API_KEY env) |
| --base-url <url> | REST API base URL (or set ZEPH_BASE_URL env, or baseUrl in ~/.zeph/config.json) |
| --stop | Stop the running daemon and clear its PID/version stamps |
| --restart | Stop it and relaunch detached (logs to ~/.zeph/listener.log) |
| --no-keep-awake | Don't hold the Mac awake on AC power (macOS). Lasting form: "keepAwake": false in ~/.zeph/config.json |
The listener reconnects with exponential backoff + jitter (1 s → 30 s cap). It pings every 25 s and drops the socket after 30 s of total silence — any inbound frame counts, ack or pong. That 30 s is waived while the daemon's own event loop is blocked, because a thread that is not listening cannot tell a dead socket from its own deafness; 90 s of wall-clock silence terminates the socket either way. On an authentication failure close (4001/4002/4003) the listener exits with code 3 instead of looping forever — fix the key and restart.
Local transfer. Once its device keys are loaded the listener also opens a
small HTTP server on an OS-assigned port and publishes host:port (private
IPv4 only) on its device record, so a sender on the same Wi-Fi can hand a
file straight to this machine instead of going through the cloud. The log
says local transfer: listening on 0.0.0.0:<port>. If the macOS firewall is
on, the first bind may ask "Do you want the application node to accept
incoming network connections?" — declining is safe; files simply keep
taking the relay.
Receiving files. A file push addressed to this machine (zeph_file,
zeph notify --file, the phone's share sheet) is saved to ~/Downloads/Zeph/
under its own name — name (2).ext when that name is taken, never
overwritten, never cleaned up — and a desktop banner (Zeph · <file> over
the push's title and the folder, e.g. [myproject] shot.png · ~/Downloads/Zeph)
says where it landed. The name in the banner is the one it was saved under, so
a (2) shows up there. With terminal-notifier
installed (brew install terminal-notifier), clicking the banner reveals the
file in Finder; the plain macOS banner has no click action. Encrypted
files are opened with this machine's own device key on the way down; nothing
is buffered, so a 1 GB recording streams straight to disk. agent.command
attachments are unaffected: they still go to ~/.zeph/attachments/<pushId>/
and are swept after 24 h.
A sender on the same network can skip the cloud entirely: it pings this
machine's endpoint, then uploads the encrypted file straight to it. Every
request is authenticated with a key only a device registered on your account
can derive (ECDH with this machine's device key), so an unknown sender — or a
replayed or tampered upload — gets an empty 401 and nothing is written. File
names and the recipient list travel sealed too; someone sniffing the Wi-Fi
sees a device id and a transfer id, nothing else. The sender counts a file
delivered only on a receipt this machine alone can compute, so a host that
merely answers on this machine's address cannot swallow it — the file goes via
the cloud instead. A device removed from the
account is refused within a minute. The file waits in ~/.zeph/attachments/lan/
until its push record arrives, then moves to ~/Downloads/Zeph/ like any
other; a transfer whose record never comes is swept after 24 h. Two uploads at
a time, 1 GB each at most.
List Options
| Flag | Description |
|------|-------------|
| --limit <n> | Number of pushes (1-20, default 5) |
| --type <type> | Filter by push type |
Global Options
| Flag | Description |
|------|-------------|
| --key <api-key> | API key (or set ZEPH_API_KEY env) |
| --base-url <url> | API base URL (or set ZEPH_BASE_URL env) |
| --json | Output JSON format |
| --version | Print version |
Mute & push mode
Both live as state files under ${XDG_STATE_HOME:-~/.local/state}/zeph,
keyed by a cksum hash of the project directory. Claude Code's
/zeph-mode mute / /zeph-mode quiet / /zeph-mode loud / /zeph-mode normal write them;
the CLI reads them (mute on every notify, push mode on --auto).
Notifications are silently skipped when a mute file exists for the current project:
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/zeph"
HASH=$(printf '%s' "$PROJECT_DIR" | cksum | cut -d' ' -f1)
# Mute (created by /zeph-mode mute in the Claude Code plugin)
mkdir -p "$STATE_DIR" && touch "$STATE_DIR/muted-$HASH"
# Unmute
rm -f "$STATE_DIR/muted-$HASH"Push mode is a one-word file (quiet / loud / normal) resolved in
this order — first hit wins:
| Order | File | Set by |
|-------|------|--------|
| 1 | $STATE_DIR/pushmode-<hash> | /zeph-mode quiet · /zeph-mode loud · /zeph-mode normal |
| 2 | /tmp/zeph-pushmode-<hash> | older versions (honored only when you own the file) |
| 3 | $STATE_DIR/pushmode-default | the --global form of any dial — the machine-wide default |
| 4 | --pushmode-default <mode> | the calling hook (the installed ones pass normal) |
| 5 | (nothing above) | quiet |
Row 5 changed: an install with no dial anywhere used to be normal.
It is now quiet, so upgrading turns the routine per-turn push off until
you run /zeph-mode normal. Row 4 is why the hooks this CLI installs are
unaffected: they name normal themselves, since a hook that supplies no
high marker would be silent under quiet whenever you're at the terminal,
rather than merely quieter. Row 4 sits below the state files on purpose — the flag
names a default, it does not override a dial the user set.
Under normal the JSON hook configs push on every turn, because they see
no per-tool events and so pass no counts — the gate then assumes real
work. The Pi extension and the OpenCode plugin do see those events, so
they pass real --tools / --nonreadonly counts and go quiet where the
others cannot: a turn with no tool calls, a turn with exactly one (a
lone edit included — the gate wants two), and a turn whose calls were
all reads. /zeph-mode loud still pushes on all of them.
The Pi extension also pushes when pi is waiting on you: any blocking
extension dialog — a bash guard's Run/Abort, an ask-user tool, a
ctx.ui.confirm — that is still open 10 seconds after it appeared sends a
high push ("pi asks: ", body = the dialog title or the guarded
bash command). high gets through quiet; only /zeph-mode mute stops it.
Answering inside the 10 seconds sends nothing. It is the pi twin of the
Claude Code plugin's AskUserQuestion push, driven by pi's
ui_prompt_start / ui_prompt_end events. Only dialogs that open while a
turn is running count: a settings picker you open yourself between turns
(/caveman config) never pushes.
Two more end-of-turn cases differ from Claude Code:
- Push Signal markers — pi's rules teach the same
<!-- zeph: skip|push|high -->markers, and the extension reads them from the final assistant message (then strips them, since pi's terminal would otherwise print the comment).highis how pi gets a "the turn ended with a question for you" push throughquiet. - Errors and aborts — a turn that ended on a provider error sends a
high"pi stopped: " push with the error text instead of "Task done"; a turn you aborted with Esc sends nothing.
Quiet while away
quiet silences routine pushes because you are watching the pane. When
you've walked away, nobody is, so notify --auto under quiet still sends
the push (normal priority) once it decides you're away. It checks three
signals, and the first that answers wins:
- Inside tmux and no client attached to the tmux server → away.
- Not over SSH and macOS
ioregreportsHIDIdleTime(system-wide input idle) → that alone decides. Inside tmux, "over SSH" follows the session environment tmux refreshes on each attach, not the value frozen at server start. - Inside tmux → the newest tmux
client_activity(SSH and non-macOS).
Anything unreadable counts as present. The threshold is ZEPH_AWAY_SEC
seconds in the hook's environment (default 300, 0 turns detection off,
and a non-number falls back to the default). The probe runs only on a
quiet turn with no high marker. The Claude Code plugin's Stop hook
applies the same rule (zeph_is_away in its hooks/gate.sh).
A dial file that exists but reads empty resolves to normal, not to row
5: an empty file is a failed write, and resolving breakage to silence
leaves no symptom to debug.
Mute has no -default form on purpose: it is keyed on presence, not
content, so a global mute could never be lifted for a single project.
Legacy /tmp/zeph-muted-<hash> files are still honored when owned by the
current user (the state dir moved out of world-writable /tmp).
The CLI checks CLAUDE_PROJECT_DIR, CURSOR_PROJECT_DIR,
WINDSURF_PROJECT_DIR, and falls back to cwd.
Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | General error |
| 2 | Quota exceeded |
| 3 | Authentication failed (also: listener auth close 4001/4002/4003) |
| 127 | A required external binary (e.g. tmux, claude) was not found on PATH |
Environment Variables
| Variable | Description |
|----------|-------------|
| ZEPH_API_KEY | API key (fallback when --key not provided) |
| ZEPH_BASE_URL | API base URL (default: https://api.zeph.to/v1) |
| ZEPH_WS_URL | Deprecated. WebSocket endpoint for zeph listener. wsUrl in ~/.zeph/config.json wins over it; read only when the file has none. It will stop being read |
| ZEPH_TMUX_SOCKET | Explicit tmux socket path for the listener (skips auto-discovery — use when your tmux runs with -L <name> or a custom -S <path>) |
| ZEPH_SESSION_ID | AI session ID (fallback when --session not provided) |
SDK Usage
import { ZephHook } from '@zeph-to/cli';
const hook = new ZephHook({ apiKey: 'ak_...' });
// Notify
const result = await hook.notify({
title: 'Build Complete',
body: 'Deploy succeeded',
url: 'https://example.com/deploy/123',
priority: 'high',
});
console.log(result.pushId); // 'push_01JXY...'
// List
const list = await hook.list({ limit: 5 });
console.log(list.pushes);
// Dismiss
await hook.dismiss('push_01JXY...');
await hook.dismissAll();Constructor Options
| Field | Type | Description |
|-------|------|-------------|
| apiKey | string | Required — API key from Zeph settings |
| baseUrl | string? | API base URL (default: https://api.zeph.to/v1) |
| timeout | number? | Request timeout in ms (default: 30000) |
Notify Payload
| Field | Type | Description |
|-------|------|-------------|
| title | string? | Push title |
| body | string? | Push body |
| url | string? | URL to include |
| type | 'note' \| 'link' \| 'file' \| 'hook'? | Push type (default: hook) |
| priority | 'low' \| 'normal' \| 'high' \| 'urgent'? | Priority (default: normal) |
| targetDeviceId | string? | Send to specific device |
Error Handling
import { ZephHook, AuthenticationError, QuotaExceededError, ZephError } from '@zeph-to/cli';
try {
await hook.notify({ title: 'Hello' });
} catch (err) {
if (err instanceof AuthenticationError) { /* Invalid API key */ }
if (err instanceof QuotaExceededError) { /* Monthly limit reached */ }
if (err instanceof ZephError) { /* Other API error */ }
}Supported Agents
zeph install detects and configures these agents automatically:
| Agent | What gets installed |
|-------|-------------------|
| Claude Code | Plugin (hooks + MCP server) |
| Cursor | MCP server + stop hook + rules |
| Windsurf | MCP server + response hook + rules |
| Gemini CLI | MCP server + AfterAgent hook |
| Codex CLI | MCP server + prompt/stop hooks + rules |
| Copilot CLI | Session end hook + rules |
| Cline | Rules file (~/.cline/rules/zeph.md) |
| Aider | Conventions file + read: directive in ~/.aider.conf.yml |
| Pi | Extension (settle hook + waiting-on-you push + remote detection) + rules — no MCP (pi has none; rules map zeph tools to the CLI) |
| OpenCode | MCP server + idle-notify plugin + rules |
For remote-control via zeph listener the per-agent setup is the same
across CC/Codex/Gemini — the wrapper just spawns them in a named tmux
session.
Encryption
End-to-end encryption is off by default and turning it on needs
Zeph Pro. The switch is in the app under Settings → E2E Encryption;
until you flip it, every push leaves this host in plaintext. If the
account later loses Pro the server answers PRO_REQUIRED and the CLI
resends the same push in the clear. No configuration either way.
With it on, push bodies and long-body attachments are encrypted with
AES-256-GCM. This host holds its own ECDH P-256 keypair in
~/.zeph/device-keys.json — generated on first use, and the private
half never leaves the machine; the backend stores public keys only and
rejects a private-key upload. Each push is encrypted once, and its AES
key is wrapped separately for every device on your account using ECDH
against that device's public key. The title and URL move inside the
ciphertext with the body.
Threat model: against a passive backend — a leaked snapshot, an operator reading the table — the stored ciphertext and wrapped keys are useless, so push contents stay private. Three limits worth knowing:
- No protection from an active malicious operator. Recipient public
keys come from
GET /deviceson that same server, unsigned and unpinned. A backend that injects a device record carrying its own key gets the message key wrapped for it, and reads everything. The Zeph app ships the counter-measure — compare device fingerprints, mark a device verified, and strict mode then wraps only for verified devices — but it defaults off, its verified list is per browser profile, and this CLI does not consult it:selectRecipientsasks only whether a device has a public key, and whether that key is the legacy account-wide one (ADR-0007 Phase 4). - No forward secrecy. The ECDH secret for a given sender/device pair is static, so compromising either private key opens every past push wrapped for that pair.
senderPublicKeyis unsigned, so a swapped one makes a push undecryptable — that direction fails closed rather than leaking.
A device that has not registered a per-device public key cannot be sent to; it is skipped, and if no device qualifies the push goes out in the clear rather than arriving as something nothing can open.
This host becomes a device others can encrypt for once zeph listener
runs: it registers the public key on this machine's device record each
time it connects to Zeph.
The zeph listener opens encrypted file pushes addressed to it, and
ignores every other isEncrypted push — it does not try to decrypt them. Stop-hook auto-pushes and zeph_ask responses are
not part of the @<session> injection path, so this doesn't affect
normal use.
Requirements
- Node.js >= 18 (uses native
fetch). - tmux — required for
zeph cc/codex/geminiandzeph listener. - The
ZephHookSDK has no runtime dependencies. The CLI depends on@inquirer/promptsfor the interactivezeph installpicker and onwsfor the listener's WebSocket subscription.
License
Apache-2.0
