Command line
The zeptap command drives your iPhone from any shell - screenshots saved as files, OCR text with refs, leases between agents, batched steps, and exit codes agents can act on.
zeptap is a command line tool that ships inside the Mac app. It drives the same iPhones as the MCP server, through the running app, so anything with a shell can use it: Claude Code, Codex, Gemini CLI, Aider, scripts, cron jobs, CI.
zeptap phones # which iPhones are paired, and whether they're ready
zeptap look # the screen's text, one line per label, each with a @t ref
zeptap tap @t4 --look # tap it, wait for the screen to settle, print the new screen's text
zeptap screenshot # save the screen as a JPEG and print its pathWhen to use it instead of MCP
Both drive the phone the same way and share one set of tools. Pick by agent:
| Command line | MCP | |
|---|---|---|
| Works in | Any agent or script with a shell | Clients with MCP support, including Claude Desktop and ChatGPT, which have no shell |
| Screenshots | Saved as files: the agent opens one only when text isn't enough | An image with every action |
| Reading the screen | look prints text with @t refs, which is much cheaper than an image | find_text |
| Many steps | zeptap run does a whole sequence in one call | One call per action |
| Setup | On PATH automatically; agents learn it from a skill | One click in Settings › Connectors |
Agents that only need to know what's on screen use far fewer tokens with look than with an image per action. For a model that relies on seeing each screen, MCP is just as good.
Install
There's nothing to install separately. When Zeptap runs from your Applications folder, it links ~/.local/bin/zeptap to the tool inside the app and keeps the link current across updates. If ~/.local/bin isn't on your shell's PATH, it adds one guarded line to ~/.zshenv (or ~/.bash_profile for bash), once. Settings › Connectors › Command line shows the status and has an Install button if the link is missing.
With Homebrew, brew install --cask zeptap/tap/zeptap links the same tool.
The Beta build installs zeptap-beta, which talks only to Zeptap Beta. It also takes zeptap while no other copy of Zeptap has it.
Check everything with:
zeptap doctordoctor checks the command, PATH, the app, the connection, the license, the phones and the agent skills, and says how to fix whatever is wrong.
Teach your agents
Settings › Connectors › Command line lists the agents found on this Mac. Add does two things for each one:
| Agent | Skill | Also allows zeptap to run |
|---|---|---|
| Claude Code | ~/.claude/skills/zeptap/SKILL.md | Bash(zeptap:*) in permissions.allow, and Zeptap's socket folder in sandbox.network.allowUnixSockets, in ~/.claude/settings.json |
| Codex | ~/.agents/skills/zeptap/SKILL.md | ~/.codex/rules/zeptap.rules: prefix_rule(pattern = ["zeptap"], decision = "allow") |
| Cursor | ~/.cursor/skills/zeptap/SKILL.md | |
| Other agents | ~/.agents/skills/zeptap/SKILL.md (Gemini CLI, OpenCode, ...) |
The skill is short. It tells the agent to run zeptap --help first, so the manual always matches the version installed. zeptap skill install writes the same skill files from a terminal, without the permission rules.
Use either the command line or MCP for an agent, not both: two sets of instructions for one phone tend to confuse it.
Agent sandboxes
Codex's sandbox, and Claude Code's when it's turned on, block every way a shell command could reach a local app: TCP, Unix sockets and launching apps. zeptap talks to Zeptap over a Unix socket in ~/Library/Application Support/Zeptap/run/, so one exception covers it:
- Claude Code: the socket folder in
sandbox.network.allowUnixSockets. Add in Settings writes it. - Codex: the
allowrule above. Codex then runszeptapwithout asking, outside its sandbox.
When a sandbox blocks it, zeptap exits with code 8 and prints the setting for the agent it's running in. Agents should call it by its bare name, zeptap, not a full path: permission and sandbox rules match the command as typed.
The loop
zeptap phones
zeptap acquire --reason "warm up @sara_runs on Instagram" # prints a lease id, e.g. 7
zeptap open Instagram -l 7
zeptap look -l 7
zeptap tap -l 7 @t3 --look
zeptap release -l 7Every action waits for the screen to stop moving, then prints what happened and saves a screenshot:
[Test iPhone] tap done; settled in 540 ms
target: "Reels" (412,1188)
screenshot: /Users/you/project/.zeptap/shots/0042.jpg (591x1280 px; tap coordinates are pixels in it)Coordinates are pixels in that screenshot, origin top-left: the same space as the MCP tools.
Seeing the screen
| Command | What it does |
|---|---|
look [<query>] [--exact] | Runs OCR and prints @tN x,y text per line, top to bottom. x,y is the center. With a query, only matching lines |
screenshot [-o <path>] | Saves the settled screen as a JPEG (or PNG for a .png path) and prints its path and size |
screenshot --if-changed | Prints the previous file instead of saving a new one when the screen hasn't changed |
screenshot --annotate | Draws each text line's box and its @t number on the image |
--look (on any action) | Prints the screen's text after the action, in the same call |
Screenshots go to ./.zeptap/shots/ in the working directory, numbered and limited to the last 50. The folder ignores itself in git. Set ZEPTAP_OUT_DIR to use another folder, or pass -o <path> for one file. If the working directory isn't writable, they go to $TMPDIR/zeptap/.
Refs
look numbers the lines it reads as @t1, @t2, and so on, and remembers them for that phone. tap @t4 checks that the text is still where it was before tapping. If the text moved a little it taps the new spot. If it went away, the tap is refused with exit code 3, and the agent runs look again instead of tapping a guess.
Touch, keys and apps
| Command | Notes |
|---|---|
tap <x> <y> | @tN | --text <t> | --text picks an exact line first, then a line containing the text. With several matches it lists them and taps nothing: pass --index <n> (1-based, top to bottom) or a ref |
tap <x> <y> --expect <t> | Refuses the tap unless that text is at the point. Use it where a mis-tap posts, sends or deletes |
double-tap, long-press [--ms 800] | Same targets as tap |
drag <x1> <y1> <x2> <y2> [--ms 800] | Precise scrolling, sliders, pickers (about 50 px per step). No momentum |
flick up|down|left|right [--from <x>,<y>] | A fast swipe with momentum. up scrolls far down a list |
type <text> | --stdin | Types into the focused field. US-keyboard ASCII only |
key <name> [--mod cmd,shift] [--repeat <n>] | enter, escape, backspace, tab, space, arrows, a c v x z. Also key cmd+a |
open <app> | Opens an app by the name under its icon |
home, back, dismiss | Home Screen, one screen back, and close Spotlight, menus, sheets or the keyboard |
lock, unlock | Unlock uses the passcode saved in Zeptap. The agent never sees it |
wait [<ms>] | Waits, then takes a screenshot. Default 1000 ms, at most 120000 |
wait --text <t> | --gone <t> [--timeout 10s] | Polls OCR until the text shows up or goes away. Exit code 6 on timeout |
The driving rules apply as they do over MCP.
Choosing a phone, and leases
Without options, commands act on the only paired iPhone, or on the one active in Zeptap. -p <id or name> (or ZEPTAP_PHONE) picks another phone.
When several agents share a Mac, have each one hold its phone with a lease:
zeptap acquire --reason "reply to Anna in Messages" --label codex-4411 -p A1B2- stdout is only the lease id, and the message goes to stderr. Pass the id with
-l <id>(orZEPTAP_LEASE) on every command. - While a phone is leased, calls from anyone else are refused with exit code 9: other
zeptapcommands, MCP agents and Zeptap's own chat. The refusal says who holds the phone, for what, and when they last used it. --wait <seconds>waits for a busy phone instead of failing.--forcetakes over a lease. Use it only when that lease is stale.- A lease ends with
zeptap release -l <id>, or after--idle-timeoutseconds without a command (default 600), so a crashed agent can't block a phone for long. Leases also end when Zeptap quits. zeptap phonesshows each phone's lease.
Many steps in one call
zeptap run -l 7 "open Instagram" "tap --text Reels" "wait 3000" "flick up" "wait 3000"
zeptap run -l 7 -f warmup.steps --continue-on-errorrun takes steps as arguments, one per line from a file (-f <file>), or from stdin (-f -). Blank lines and lines starting with # are skipped. The steps run in order and stop at the first failure unless --continue-on-error is set. Only the last step saves a screenshot unless --shots all is set. The output has one line per step, then the last step's result. Use run for sequences the agent has already worked out. It still decides what to do between runs.
Output for programs
--json prints one object instead of text, on stdout, whether the command succeeds or fails:
{"ok":true,"message":"[Test iPhone] tap done; settled in 540 ms","phone":{"id":"A1B2","name":"Test iPhone"},
"screenshot":{"path":"/…/.zeptap/shots/0042.jpg","width":591,"height":1280},"details":["target: \"Reels\" (412,1188)"]}Failures add "code". look and --look add "lines": each has a ref, its text, its center and its box. phones, acquire and doctor add "data".
Exit codes
| Code | Meaning |
|---|---|
| 0 | Done |
| 1 | Failed: read the message |
| 2 | Bad arguments, or the target was ambiguous (several text matches, several phones) |
| 3 | Not found: text, a ref (stale or missing), or an app. Nothing was done |
| 4 | Zeptap isn't running and couldn't be opened |
| 5 | The phone isn't ready: Bluetooth, USB, or locked |
| 6 | Timed out |
| 7 | Needs the user: the license, pairing, a permission |
| 8 | An agent sandbox blocked the connection to Zeptap |
| 9 | The phone is leased to another agent |
Everything else
| Command | What it does |
|---|---|
zeptap --help | The full manual, written for agents |
zeptap call <tool> --args '<json>' | Calls any MCP tool by name. zeptap tools lists them |
zeptap skill list | get <name> [--file <path>] | The skills turned on in Settings › Skills, built in and your own. Load the matching one before a job; --file reads one of its reference files |
zeptap skill print | install | The SKILL.md for agents, and putting it in their skills folders |
zeptap version | Versions of the command and of the running app |
zeptap mcp | The MCP server over stdio, for clients that only launch commands. zeptap --stdio does the same |
If Zeptap isn't running, zeptap opens it in the background and waits for it (up to 20 s). Inside a sandbox it can't, so the user has to open Zeptap first.
How it connects
The command finds the running app through ~/Library/Application Support/Zeptap/run/server.json. The app rewrites that file on every launch: process id, TCP port, socket path, version. The command then sends MCP requests over the Unix socket zeptap.sock in the same folder. That folder is readable only by your user (mode 0700), so only your own processes can connect. If the app is too old to have the socket, the command falls back to http://127.0.0.1:47801/mcp.