Skip to content

Remote Access

aimux remote serve is an HTTP API for your Claude Code sessions, meant for your other devices on the same tailnet: list sessions, read recent turns, continue an idle session, follow the reply as it streams, and answer the agent’s permission requests. The conversation continues in the session’s own file, so claude --resume <id> on the Mac carries on from there.

  1. Allow your Tailscale login in ~/.aimux/config.yaml:

    remote_access:
    allowed_logins:
    - you@example.com
  2. Run the API:

    Terminal window
    aimux remote serve
  3. Expose it on your tailnet with HTTPS:

    Terminal window
    tailscale serve --bg --https=8443 http://127.0.0.1:7382

    Turn it off again with tailscale serve --https=8443 off.

From another device on the tailnet: curl https://<your-mac>.<tailnet>.ts.net:8443/v1/health.

  1. Pair each phone or browser once (see Device login):

    Terminal window
    aimux remote pair --name iphone

    Type the code it prints into the iPhone app or the web client within 5 minutes.

  2. Optional: let Claude Code sessions open in a terminal tell aimux when they wait for permission (see Permission alerts for terminal sessions):

    Terminal window
    aimux remote hooks install

On the iPhone, install Tailscale and sign in with the same account as the Mac. Then open https://<your-mac>.<tailnet>.ts.net:8443/app/ in Safari (remote serve prints the address) and use Share, Add to Home Screen to open it like an app. The first time, it asks for a pairing code from aimux remote pair (see Device login); after that it unlocks with Face ID.

The web client’s source is clients/web in the aimux repo. The iPhone app, a native app for the same API, is clients/ios; it has Now, All and the chat, and is built from source with Xcode. Both clients rank sessions and build the chat timeline with the same shared test fixtures.

The page has the iPhone app’s layout: Now and All tabs in the bar at the bottom, and a search box at the top of each. It follows the phone’s light or dark setting. Colour appears only as small status dots: green working, amber needs you, red failed, grey idle.

  • Now: the Mac as you see it, refreshed every 15 seconds while the page is open. See Finding sessions for the order. Permission requests are cards with the request in large type, the command it wants to run and the agent’s own options as buttons; when more than five sessions need you, each is a one-line row that opens its card in place. A failed turn shows its error and Retry, which sends the same message again.
  • All: every session. Recent lists them newest first, by day; Folders groups sessions by repository and worktree; a subfolder shows on the session’s row, not as a folder level. The choice is remembered on the device.
  • Search: a box at the top of Now and All; see Finding sessions.
  • New session: the pencil button at the top of Now and All starts a session in one of your recent project folders (only those; never an arbitrary path) with a first message. This sheet keeps its older style for now.
  • In a session: the header shows the title and the session’s state (“Working”, “Your turn”, “Failed”, or “Open on your Mac · 12h ago”); tap it, or the info button, for the session’s cost, tokens, model, turns, duration, branch and folder. Your messages are dark bubbles on the right, with inline Markdown (code, bold, links), pasted code kept as code and Claude Code’s own wrappers removed (a pasted image shows as an “Image 1” chip); Claude’s replies are on the left, formatted: headings, lists and task lists, quotes, tables that scroll sideways, and code blocks with their language, syntax colours (Go, Swift, JS/TS, Python, shell, JSON, YAML and diffs) and a Copy button; inline code reads in its own colour on a tint. A reply line that is only a YouTube, Vimeo, video, image or audio link becomes a card; nothing loads from the other site until you tap it. Links open in a new tab, and issue numbers, commits, file paths and Jira keys become links (see Links in replies). A thought is one grey line (“Thought for 8s”), the tool calls between two replies fold into one line (“6 steps · 41s · 2 files changed”), and a plan is a checklist (“2 of 4”); tap a thought or the steps to open them, then tap a step to read it in full (with Copy on its command and error): a command with its whole text, coloured as shell, a failure with its error under a red “Failed”, an edit as its removed and added lines (the first 40, then “N more lines”). A step with nothing more to show does not open. A permission request is a card in the conversation with the exact command and the agent’s own options as buttons; once answered it folds to one line (“Allowed”). Above the message box, Next opens the next session that needs you (or the next working one) without going back; it hides while this session waits for an answer. While Claude works, Stop shows beside the send arrow, which then queues your message for when Claude finishes (see Chat queue). A message that could not be sent stays with Retry, which sends it again without running it twice. A session open in a terminal on the Mac shows its latest turns and “Read-only here while it’s open in a terminal on your Mac” instead of the message box, unless terminal input is on (see Typing into terminal sessions). When the connection drops, a grey “Reconnecting…” line shows under the header until it is back; in a terminal session a read that fails keeps the turns shown and says “Couldn’t refresh” with Retry (iPhone). Scrolled up, a Latest button jumps back to the newest message. Not shown yet: what a command printed (aimux does not store command output yet) and attachments.
  • Voice: the mic dictates into the message box (the browser’s speech recognition; on iPhone it runs through Apple’s speech service, like keyboard dictation). In the session info sheet (and the voice menu on the new-session screen): read replies aloud (code is skipped), send when you stop talking, and hands-free conversation, which listens again after each reply. Permission requests are read out but only answered with a tap. iOS may need a tap on the mic again after the phone sleeps.

The page is served by remote serve itself and loads nothing from elsewhere; its Content-Security-Policy allows only its own script, style and API.

Tailscale decides who may reach the API; device login decides which of that person’s devices may use it. With it on (the default), only a device you paired from the Mac gets in, it logs in with Face ID or Touch ID, and the login ends on its own.

Pair a device. On the Mac:

Terminal window
aimux remote pair --name iphone

It prints a code such as 7K2M-Q9XD. Type it on the device: the iPhone app and the web client show a pairing screen when they find no pairing. The code is 8 characters (dash optional, case does not matter), works once, for 5 minutes, and a new pair replaces the old code. Five wrong tries burn it, and the count survives a server restart; run pair again then. The name (1 to 32 characters) is how the device is listed and must not be taken, whatever its case (iPhone and iphone are the same name).

  • iPhone app: pairing makes a key in the phone’s Secure Enclave that signs only after Face ID and never leaves the phone. A change to the enrolled faces (a face added or removed in Settings) invalidates it, and the app asks to pair again. Pair this device again on the lock screen starts over on the phone only: the old entry stays on the Mac until you run aimux remote revoke for it.
  • Web client: pairing makes a passkey for this server. It syncs as your passkey provider syncs it (iCloud Keychain to your Apple devices by default, another provider if you chose one, or nowhere), and each use needs Face ID or Touch ID. One pairing is one entry, whichever devices hold the passkey. If the code was wrong, the browser may keep an unused passkey; the client asks the browser to remove it where the browser supports that, otherwise delete it in your passwords settings.

Unlock. The iPhone app asks for Face ID when a login is needed. The web client never starts Face ID on its own: it shows Unlock with Face ID, and the tap does it. Requests that hit the lock are sent again after the unlock. A device that was revoked shows “This device is no longer paired.” and the pairing screen.

When a login ends. After 1 hour without use, after 12 hours in any case, when you lock it (Lock in the iPhone app’s server settings, or in the header of Now in the web client; the web client locks at once, and ends the login on the Mac in the background, so a Mac that is unreachable never keeps the chat open), when the device is revoked, or when remote serve restarts. Only things you do count as use: any POST, and a request the client marks with Aimux-Interaction: 1 (opening a chat or a tab, pull to refresh, Retry, a search). Background polling and event streams do not, so a phone left open locks after an hour. An open event stream closes when its login ends.

List and revoke.

Terminal window
aimux remote devices # name, kind, paired, last seen
aimux remote revoke iphone # its next request fails

kind is secure-enclave (iPhone app) or webauthn (browser passkey). A running server notices a revoke within a second or two. Revoke a lost phone; pair a replacement under a new name.

Audit log. ~/.aimux/logs/remote-audit.log (owner-only) gets one JSON line per pairing, login, logout and revoke (and each failed pairing or login) and per action that changes something: sending a message, starting or stopping a turn, answering an approval, typing into or pressing a key in a terminal (terminal_send_now for the composer’s Send now), and changing or sending from a session’s chat queue (queue_add, queue_edit, queue_move, queue_delete, queue_resume, queue_send, queue_send_now, queue_paused). Each line has the time, the Tailscale login, the device name, the action, the aimux session when there is one, and the result (ok, or a fixed reason code such as wrong_code or bad_signature). It never holds message text, pairing codes, challenges, tokens or keys. aimux remote revoke writes its own line. Identical failure lines (same login, device, action and result) are written at most once a minute; the next one carries count, how many it stands for. If the log cannot be written, the action still happens and the failure is printed by remote serve (or by revoke).

Files in ~/.aimux, all owner-only (0600): remote-paired-devices.json (each device’s id, name, kind, public key and the login that paired it), remote-pairing.json (the pending code’s SHA-256, never the code), remote-paired-seen.json (last seen), remote-paired-devices.lock (taken by the CLI and the server for every write). APNs push registrations stay in remote-devices.json.

What it guarantees, and what it does not.

  • Every request still passes the Tailscale checks in Security model first; device login is a second wall behind them.
  • A login is proven by a key that never left a device you paired: the device proves it holds the key at pairing and signs a fresh challenge at every login. The session that login starts is a token, kept in memory on the iPhone and in an HttpOnly cookie on the web, and valid until 1 hour without use or 12 hours, whichever comes first. The server keeps only its SHA-256.
  • Don’t serve other apps under the same tailnet host name as remote serve, even on another port: browsers send cookies by host name and ignore the port, so a page from another app there could make requests with the web client’s login.
  • The server cannot prove that the iPhone’s key sits in the Secure Enclave behind Face ID; that would take Apple’s App Attest. It trusts the app it paired with.
  • A device’s logins work only with the Tailscale login that paired it.
  • Failed logins are audited, never locked out: a signature cannot be guessed. A device on your tailnet that knows a device id can delay that device’s next login by asking for many challenges (each device keeps 5 open, the oldest dropped), but cannot log in. Passkey logins (the web client) ask for challenges without a device id and share one such bucket, so any device on your tailnet can delay them without knowing a device id; it still cannot log in.
  • remote_access.device_login: false turns it off: any device signed in to your tailnet as an allowed login can use the API again, and remote serve prints a warning at start.

A permission request waits for a tap, which is easy to miss with the phone in a pocket. remote serve can send each new one to the lock screen, through either or both of:

  • ntfy (works today): install the ntfy app on the phone and subscribe it to a topic, then set push.ntfy.topic. Tapping the alert opens the session in the web client. Once the request is answered, expires or its turn ends, aimux deletes the alert from the topic (ntfy 2.16 or later, including ntfy.sh), so a phone that was offline does not show it later; the ntfy app also removes it from the notification drawer where it supports that. Older servers keep it, and remote serve logs that the delete was refused. On the public ntfy.sh server anyone who knows the topic can read it, so pick a long random name, or run your own ntfy server (push.ntfy.server) with an access token (push.ntfy.token_env).
  • APNs (for the aimux iPhone app): Apple’s push service, straight from the Mac. Create a token signing key (.p8) in your Apple Developer account and set push.apns. The app registers its device token with POST /v1/devices; devices are kept in ~/.aimux/remote-devices.json (owner-only) with the login that registered them, only devices of logins still in allowed_logins get alerts, and Apple’s “unregistered” answers remove them. The alert is time-sensitive, uses category AIMUX_APPROVAL, groups by session, carries approvalId and sessionId for the app to answer with, and expires with the approval.
remote_access:
push:
ntfy:
topic: aimux-7f3k9q2m1x # long and random on ntfy.sh
# server: https://ntfy.example.com
# token_env: AIMUX_NTFY_TOKEN # environment variable holding the token
# click_url: https://<your-mac>.<tailnet>.ts.net:8443/app/
apns:
key_file: ~/.aimux/AuthKey_ABC123DEFG.p8
key_id: ABC123DEFG
team_id: TEAM123456
topic: me.example.aimux # the app's bundle id

A notification says “Approval needed” and names the session and the tool call (for a shell command, the command itself, shortened). It never carries the tool’s full input. It only tells you to look: answer in the app or web client, never from the notification. When nothing is configured, nothing is sent. A half-done apns block, a key that is not P-256, or an unset token_env variable stops remote serve at startup. Each sender and each phone is tried at the same time, so a slow one does not hold up the rest. A request answered before its alert is out sends none. A failed delivery is logged and the approval still waits as usual.

A Claude Code session you started in a terminal on the Mac waits silently at a permission prompt. Claude Code’s own session registry reports such a session as waiting, so its activity is needs_you (and the TUI and dashboard show it) even without the hooks; the phone clients’ Needs you list and the push come from the hooks’ markers. With aimux’s Claude Code hooks installed, such a session lists under Needs you in both phone clients with “Waiting for permission” on its row, and remote serve sends the same push as a phone approval (ntfy, APNs; see Approval notifications). Open the session to answer: the chat shows the prompt card from the screen (with terminal input on), or answer at the Mac.

Terminal window
aimux remote hooks install # add the hooks
aimux remote hooks status # are they installed, and do they run this binary?
aimux remote hooks uninstall # remove only aimux's hooks

What install writes:

  • It merges hook entries into ~/.claude/settings.json (from HOME; created if missing) and keeps every other setting and hook you have. Before writing, it copies the file to ~/.claude/settings.json.aimux-bak-<time>. Only the hooks entry is rewritten: your other settings keep their exact text and order, and if Claude Code writes the file at the same moment the change is redone on its version. A file that is not valid JSON is left alone and the command fails. Running it again changes nothing; after you move the aimux binary it points the hooks at the new path.
  • One entry per event, each running <absolute path to aimux> remote hooks notify || true with a 10 s timeout: Notification with the matcher permission_prompt marks the session as waiting; PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop and SessionEnd clear it. Sessions started after the install use the hooks; restart open ones to pick them up.
  • The path is the aimux on your PATH when that is the same file as the one you ran (so a Homebrew link survives upgrades), otherwise the binary you ran. A build in the temp folder or the Go build cache (go run, go test) is refused, since it disappears. If the binary is removed later, || true keeps Claude Code from showing a hook error, alerts stop, and aimux remote hooks status says the binary does not exist.
  • uninstall removes only aimux’s entries, also from a matcher group you share with your own hooks (an event list or the hooks object left empty goes too), after the same backup.

How it works:

  • The hook command reads Claude Code’s JSON on stdin and writes or removes one marker, ~/.aimux/waiting/<session id>.json (directory 0700, file 0600): the session id, kind (permission), Claude Code’s notification line (such as “Claude needs your permission to use Bash”, at most 200 characters, never the tool’s input), the time, the session’s transcript path and, inside a subagent, its agent id. A tool that a subagent ran does not clear a prompt of the main conversation. It makes no network call, prints nothing, always exits 0 so Claude Code never sees a failure, and logs a failed call to ~/.aimux/logs/hooks.log without its text.
  • Claude Code sends the permission_prompt notification once a prompt has waited about six seconds, so a prompt you answer at the Mac right away sends nothing.
  • remote serve reads the markers when it lists sessions and adds waiting only for a session open in a terminal (Claude’s registry shows it live). A marker of a session that is not open is ignored, and removed once it is older than 30 seconds and the session’s process is gone (or, for a session remote serve never saw open, once it has been missing for 5 seconds), so one unreadable registry file does not remove it.
  • A marker also goes once the session’s transcript is written more than 2 seconds after the prompt was reported: answering runs the tool, and denying with Esc writes “[Request interrupted by user]”, which fires no hook.
  • It reads the directory every 2 seconds and sends one push per new marker, worded like a phone approval (“Approval needed”, the session’s title and the notification line), and takes it back (ntfy) when the marker goes: a tool ran, you sent a prompt, the turn ended, the transcript moved on or the session closed. When remote serve starts, markers older than 2 minutes count as already pushed; newer ones are pushed.
  • At startup remote serve prints whether the hooks are installed.

Sessions open in a Claude Code terminal on the Mac are read-only from the phone unless you turn on terminal input. With it on, aimux types the phone’s message into the session’s own iTerm2 pane, so the running Claude Code gets it as if you typed it, and you can answer its permission prompts from the phone. Only iTerm2 is supported so far.

  1. Turn it on in ~/.aimux/config.yaml and restart aimux remote serve:

    remote_access:
    terminal_input:
    enabled: true
    # allow_while_in_use: true # for testing at the Mac only; see below
    # allow_slash_commands: true # let messages start with / (slash commands); see below
    # keep_drafts: true # keep a draft left in the input box instead of replacing it
  2. Run this once at the Mac:

    Terminal window
    aimux remote terminal-input check

    It sends one harmless Apple Event to iTerm2, so macOS asks for Automation access while you are there to allow it, then lists each Claude session open in a terminal, one row per session, with the pid aimux types into (the newest, highest pid when two processes hold the same session, as the server picks), its tty and whether aimux can reach its pane. It never types, reads a screen or changes a window. When remote serve runs from an iTerm2 tab it uses iTerm2’s own Automation grant; run as a background service it needs this step. Off macOS it only prints that terminal input needs macOS and iTerm2, and the server answers every terminal request with no_terminal_driver.

What the phone shows (the iPhone app and the web client alike). A terminal session’s header reads “On your Mac · Working” or “On your Mac · Your turn”. When aimux can type into it, the message box appears; a sent message shows as pending “Sent to your Mac’s terminal” until Claude saves the turn, and “Sent; no answer yet” after 2 minutes without one. The message is never sent again on its own; Retry on a failed one sends it under the same key, and waits while the terminal cannot be typed into. When it cannot, a read-only line says why (“You’re using this session on your Mac”, “Claude isn’t running in that terminal”, “This terminal isn’t supported yet”, “Allow aimux to control iTerm2 on your Mac”, “Claude isn’t at its input box”, “Typing into your Mac’s terminal is off”, or “Can’t reach the terminal”). When the input box on the Mac already holds text, sending still just works. That text is either Claude Code’s grey suggested next prompt (the one Tab accepts) or a draft; the screen aimux reads has no colours, so aimux types one space to tell them apart. A suggestion vanishes when typed over, so your message follows. A draft keeps its text: the space is taken back out, then the draft is replaced (cleared with Ctrl+E and Ctrl+U, checked empty, then your message is typed), or, with keep_drafts: true, kept and the message refused (“Your draft on the Mac is kept”). If the box will not empty, nothing is sent (“The box on your Mac couldn’t be cleared”). No key is pressed while a prompt shows or you are using that session on the Mac, nor while Claude works unless you chose Send now (below). A permission prompt showing in the terminal appears as the usual approval card instead of that line, with what it asks about (the command and its description, or the file and a preview, as the terminal shows them) in monospace; each option presses its number in the terminal, and a prompt answered on the Mac meanwhile folds away without an error. A prompt too tall for the last 300 lines aimux reads shows no card, since its command would be cut off: answer it at the Mac. The chat reads the session again every 2 seconds while Claude works or for 2 minutes after a message, otherwise every 15 seconds, and only while the chat is open on screen. While Claude works, the chat ends in a live line, “Working · 1m 20s · go test ./…”: how long the turn has run (from Claude Code’s working line on the Mac) and its latest step. Thoughts are not shown for terminal sessions; the saved turns, steps included, refresh with every read.

Safety rules, checked by the server on every message and key press:

  • Off by default. The phone sees the terminal_input capability only when it is on; otherwise both endpoints answer 403 disabled.
  • The pane’s foreground process must be the session’s own claude (its process group, running claude or node; not_claude otherwise), checked again right before typing, before Enter and before a key press, so text never lands in a shell.
  • The session must not be the one you are using (mac_in_use otherwise); see The in-use guard. allow_while_in_use: true lifts this for testing at the Mac; do not leave it on.
  • A session where Claude is working refuses a direct message (409 busy, “Claude is working in this session; try again when it finishes.”), and so does a session started from the phone while its turn runs. Working is Claude Code’s working line on screen or a busy status in Claude’s session registry. Nothing is typed. The clients put the message in the queue instead (see Chat queue), unless you chose Send now: a message sent with "now": true is typed while Claude works, with every other check below kept, and Claude Code hands it to Claude during the turn.
  • While the session’s queue is sending (a message in flight, or the queue running with messages waiting), a direct message is refused with 409 busy too, on both POST …/terminal/messages and POST …/messages, so it never joins the queue’s turn and the waiting messages keep their place. Send now ("now": true, or the queue’s send-now route) is the exception: it goes past waiting and answering items, and is refused with 409 busy only while another message is being typed.
  • A message is typed only when Claude Code’s input box shows (not_at_input otherwise), so it cannot pick a highlighted prompt option. Text already in the box never joins it: a grey suggestion is typed over; a draft is cleared first (409 box_not_cleared if the box does not empty), or with keep_drafts: true kept and the message refused (409 draft_in_box). A message with "replace": true replaces a draft even with keep_drafts on. Before every key that clears the box (each Ctrl+E and Ctrl+U included), aimux checks the pane’s process and the in-use guard again, and the screen after each wait; if you turn to that session meanwhile, it stops (a space it typed may stay in your draft).
  • The text is typed without Enter. aimux reads the screen again every 300 ms, for up to 1.5 s, and presses Enter only if no prompt is showing and the input box holds just that text; after the in-use and process checks it reads the screen once more, right before Enter, so a permission prompt that appears in that moment never takes the Enter as its answer (also for Retry and Send now). If a prompt came up meanwhile (a background task can ask while you type), the box does not show just that text, or you turned to this session on the Mac, Enter is not pressed: the answer is 409 not_submitted, the phone says “Your message is in the box on your Mac, not sent yet; Retry sends it” (and shows a prompt’s card if there is one), and the text waits in the input box. Retry then presses Enter without typing again, but only for text aimux itself typed there and only while the box shows the same words (line wrapping aside) and nothing else stops it; a draft of yours is never submitted (a long message that Claude Code shows as “[Pasted text #1]” is retried only while the box shows the exact marker aimux saw after pasting it, the same claude still runs and the Mac has had no keyboard or mouse input since, wall-clock time counted so a sleep cannot shorten it; otherwise Retry answers 409 unsent_unconfirmed and you send or clear it at the Mac); another message is refused with 409 box_has_unsent and never erases it, unless sent with "replace": true. The attempt log names which check stopped it (not_submitted in_use, not_in_box, prompt or cancelled), never the text.
  • A key press names the prompt it answers by its promptId, a hash of the prompt’s body (such as the command), question and options; if another prompt shows by then, even the same question about another command, nothing is pressed (prompt_changed).
  • Text with control characters other than newline and tab is refused (400 bad_text): such bytes would reach the terminal as keys.
  • Text starting with ! (after optional spaces) is always refused (400 bad_text): it switches Claude Code into bash mode, where Enter runs a shell command with no prompt. A ! later in the text is fine.
  • Text starting with / (after optional spaces) is refused too (400 bad_text), since Claude Code runs it as a slash command (/clear, /model, …), unless allow_slash_commands: true is set.
  • Every attempt is logged to ~/.aimux/logs/terminal-input.log (owner-only): time, session id, text length or key, outcome. Never the text.
  • A retry with the same key within 10 minutes is not typed twice, and the server never retries a failed attempt on its own.
  • aimux never creates, focuses, resizes or closes windows, tabs or panes.

A session is blocked only when all three hold: iTerm2 is the app in front, the session is the focused pane of iTerm2’s current window, and the Mac was used (keyboard or mouse) in the last 2 minutes. Any other iTerm2 session takes phone input while you work at the Mac. aimux checks before typing and again just before Enter; if it changed in between, nothing is submitted (not_submitted, the text waits in the input box).

The scope is the focused pane only, because keystrokes reach only that pane: typing into a visible split pane, another window or a background tab cannot mix with what you type. A hotkey window and Stage Manager are not yet verified live. If the check cannot be made (the probe fails or iTerm2 does not answer), the session counts as in use.

A session started or continued from the phone runs on the Mac as a background Claude process with no window. Open on Mac (the chat’s More menu, or a long press on a row in Now or All) hands any session that is not already open in a terminal to iTerm2: aimux closes the background process, opens the session in a new pane of its own iTerm2 window with the harness’s resume command (claude --resume <id> in the session’s folder), and waits until Claude holds the session there. The chat then turns into a terminal chat, with the queue, Send now and permission alerts. Background stays the default; nothing moves to iTerm2 unless you ask.

aimux’s window holds up to 10 panes, in two columns of up to five; the eleventh open starts a new aimux window, and later opens fill the newest aimux window that has room. Each pane aimux opens is named aimux · <session title> and carries the iTerm2 session variable user.aimux set to 1 (with user.aimux_col for its column and user.aimux_seq for its place in the open order), which is how aimux finds its windows; it stores no window id. Panes you split yourself in that window do not count toward the 10 and are never split by aimux, and closing panes frees their places. Opens run one at a time, so several sessions opened together land in the same window. aimux never brings iTerm2 to the front, focuses or closes anything.

It is offered only when terminal input is on and the iTerm2 driver is available (/v1/health reports openOnMac). The first time, macOS asks whether aimux may control iTerm2; allow it in System Settings, Privacy & Security, Automation.

RefusalShown on the phone
busyClaude is answering. Wait for it to finish, or Stop it first.
open_in_terminalAlready open in a terminal on your Mac.
no_terminalOpen on Mac needs iTerm2 on your Mac.
not_authorizedaimux may not control iTerm2. Allow it in System Settings, Privacy & Security, Automation.
in_useAnother aimux process is using this session.
not_startedThis session has no messages yet, so there is nothing to open.
timeoutThe pane opened, but Claude did not pick up the session. Check the aimux window on your Mac.

While a handoff runs, no new turn starts for that session, from the queue or the phone. A handoff that has started finishes or times out even if the phone goes away. After a timeout the background process stays closed; the next message from the phone starts a new one.

Each session has a queue held by the Mac, so what you line up keeps going while the phone is locked or offline. It works for sessions started from the phone and for Claude Code sessions in iTerm2 (with terminal input on). It needs no setting; the server offers the queue capability.

Using it. When a session is working, blocked by the guard, its queue is sending (a message in flight, or the queue running with messages waiting), or it just refused a message as busy, the same send arrow queues your message instead of sending it now, with Stop beside it while Claude works; it goes once Claude finishes. A message the session refuses as busy goes into the queue by itself (both clients). The queue shows under the message box as one line (“2 queued · next: …”); tap it to see one row per message with its state, Waiting, Sending or Answer in progress, and tap again to fold it. A paused queue’s reason and Resume show either way. In an iTerm2 session a message counts as answered once Claude stops working on it (text you type into the box at the Mac meanwhile is yours, not part of the answer), or when your next turn at the Mac shows. Tap a waiting message to edit it, move it with the drag handle (iPhone) or Move up and Move down (web), delete it with swipe or Delete. A message that is sending or answering cannot be edited or moved, and can be deleted only while the queue is paused. While the queue runs, one line says why it waits: “Waiting: you’re using this session on your Mac”, or “Waiting: Claude is working” while the next message waits for Claude to finish (if that line stays while the Mac shows Claude idle, check the session at the Mac). If you were editing a message that the Mac sent meanwhile, the clients keep your text so you can queue it again.

Send now. A correction should reach Claude during the turn, not after it. In a Claude Code session open in iTerm2, a waiting message’s row offers Send now (iPhone: swipe right, or touch and hold the row; web: the row’s button), and the composer’s text can skip the queue too (iPhone: touch and hold the send arrow; web: the Send now button beside it). The message is typed into the box while Claude works, Claude Code hands it to Claude during the turn, and “Sent now; Claude sees it during this turn” shows. The other queued messages keep their order. Every other check of typing into terminal sessions still applies: no permission prompt on screen, the in-use guard, Claude Code in front in the pane, and an input box that is empty or holds only aimux’s own unsent text (a draft of yours is never cleared mid-turn). A refused Send now leaves the message at its place in the queue and shows why (“Your Mac is in use right now”, “Your draft on the Mac is kept; nothing was sent” …). It is not offered for sessions started from the phone (their runs take one turn at a time), nor for a message that is sending or answering, nor for the composer while a queued message is being typed. While another message is being typed (a queued one, or another Send now) it is refused as busy. Its turn in the session counts as aimux’s own, so the rest of the queue keeps running instead of pausing as “You used this session on your Mac”. If remote serve stops after the message was typed but before it left the queue, the queue comes back paused with “This message may have been sent” and the message is not typed again. The server offers it with terminal input on (capability send_now).

Rules.

  • The queue lives on the Mac in ~/.aimux/remote-queue.json (owner-only) and survives a restart of remote serve. It holds up to 20 messages per session (queue_full beyond that).
  • The text rules are checked when you add a message and again just before it is sent: control characters, a leading !, and a leading / unless allow_slash_commands is on. A message that breaks them is refused at adding (409 bad_text); if it breaks them later (the setting changed), the queue pauses.
  • One message at a time. The next is sent only after the answer to the previous one has finished: for a session started from the phone, when its turn is done; for iTerm2, when the message shows in the session history and Claude has not been working for 2 seconds (text you type into the box at the Mac meanwhile does not hold it), or when your next turn at the Mac shows. A session with a permission prompt on screen waits silently. A session that is working waits with the line “Waiting: Claude is working”, and one you are using on your Mac with “Waiting: you’re using this session on your Mac”.
  • The Mac never queues by itself. The clients add a message the session refused as busy to the queue; a direct API call answered 409 busy stays refused.

When the queue pauses. Nothing is sent from a paused queue until you tap Resume; editing, moving or deleting never resumes it. The line shown is the reason:

ReasonWhat to do
You used this session on your MacA turn you started at the Mac appeared. Resume when your queued messages should follow it.
This session closed on your MacClaude exited in iTerm2. Resume it at the Mac, then Resume the queue. The queue never moves to a headless run by itself, which could clash with claude --resume.
Session not runningThe session ended. Delete the messages or resume it at the Mac.
This message may have been sent. Check the session, then resume or delete it.The server stopped while a message was being sent or answered. Look at the session; Resume sends it again, so delete it first if it already went.
Your message is in the input box on your MacThe text was typed but Enter was not pressed. Resume presses Enter only if the box holds exactly that message.
Clear the input box on your Mac, then resumeThe box holds something else.
Your message may still be in the box on your Mac. If you sent it there, delete it from the queue; otherwise clear the box, then resume.aimux pasted the message but can no longer confirm the box holds it (the Mac had input since, or claude restarted). Resume after sending it at the Mac would send it twice.
The input box on your Mac isn’t empty. Clear it, then resume.Clear the box at the Mac.
Your earlier message is still in the box on your Mac. Retry it in the chat, or clear the box, then resume.A message aimux typed but did not submit is still in the box, so the next one cannot go. Tap Retry on it in the chat (Enter is pressed only if the box shows exactly that message, or the paste marker aimux saw for it), or clear the box at the Mac, then Resume.
No answer seen yetThe message was sent but no turn showed within 2 minutes. Check the session.
Claude stopped with an error. Check the session, then resume.The turn failed.
This message is no longer allowed. Edit or delete it.It now breaks the text rules.
This message couldn’t be sent. Check the session, then resume.Any other send failure. aimux never retries a terminal send on its own.
Claude Code isn’t running in this session’s terminal; Only sessions in iTerm2 can take queued messages; Typing into terminal sessions is off on the Mac; macOS isn’t letting aimux type into iTerm2Fix it at the Mac (see Typing into terminal sessions), then Resume.
This session is open in a terminal on your Mac; Couldn’t read this session on your MacResume once the session is reachable again.
Couldn’t read the session on your Mac. Check iTerm2, then resume.aimux could not read the iTerm2 screen for 2 minutes. Check that iTerm2 is running and the session’s window is open, then Resume.

Queue changes and sends go to the audit log as queue_add, queue_edit, queue_move, queue_delete, queue_resume, queue_send, queue_send_now and queue_paused, never with the message text.

The phone and web clients turn references in Claude’s replies into links:

  • #123 and owner/repo#123 open the issue or pull request, a commit hash (7 to 40 hex characters) opens the commit, and a file path such as internal/api/server.go:42 opens that line on the session’s branch. The server reports the session’s repository in repo on GET /v1/sessions/{id}; sessions in other folders get no such links. git runs on the Mac with a 2 second limit and never prompts; results are cached for a minute.
  • Jira keys link only when you set both settings. Without a project list nothing is linked, so UTF-8 or ISO-9001 never become links:
remote_access:
links:
jira_base: https://issues.example.com
jira_projects: [WID, OPS]

Every session in the phone clients shows a name and, when there is one, a short description. Names and pins are stored with the session, so the TUI and the web dashboard see them too.

The title is the first of:

  1. The owner’s text: a title you typed (info sheet or PATCH /v1/sessions/{id}/meta). It is never overwritten.
  2. The name you gave the session with Claude Code’s /rename.
  3. Ours: a title a model wrote (below). Update summary writes this one.
  4. Claude Code’s own ai-title.
  5. The session’s first message (shortened to 80 bytes).

The TUI, aimux sessions, the web dashboard and session search choose the title the same way. Search picks up a title you or the model wrote on its next refresh, even though the transcript did not change.

The description is the owner’s when you wrote one; otherwise the newer of Claude Code’s away summary (the recap it writes after about 3 minutes idle in an interactive session) and ours.

A title that says nothing (“Session c513a7b6”, “continue c513a7b6”, an id alone, or only a slash command) counts as useless: the model writes a title to replace it.

Pins are the existing star: the same flag in the TUI, the dashboard and the phone clients.

Automatically only for the session last in front on the Mac, pinned sessions, sessions that are busy or open in a terminal, and sessions active in the last 15 minutes (at most 30 per list request), plus the chat you open, when it has no title or description of yours and nothing of ours, once. A session without an away summary gets a title (when its shown title is useless) and a description, made from its first message and the start of its latest reply. A session with an away summary gets only a title, and only when its shown title is useless; it is made from that summary alone, and no description is written (the summary is the description). aimux asks for up to 5 such sessions per request, with small calls: at most 500 characters of the session in, 60 tokens out per session. The rest is up to you:

  • sessions.auto_title: true turns automatic naming on (default false). With it off, nothing is sent unless you press Update summary.
  • sessions.naming_daily_limit (default 20) caps the sessions named automatically per local day. Update summary is not capped, but is counted.
  • Update summary (info sheet) asks again for one session, from its first message and latest turns, up to 1,200 characters. It keeps any title or description you wrote.
  • sessions.title_model is haiku (default, claude-haiku-5-5), sonnet or opus. An unknown name falls back to the default.
  • Backend: the Messages API when an API key is set (sessions.api_key, else ANTHROPIC_API_KEY); otherwise claude -p in safe mode, started from an empty temporary folder with no tools, MCP servers, hooks or memory, using your Claude Code login.
  • After three failed calls in a row, automatic naming pauses for a while; Update summary still works. Health shows naming.failing, a fixed reason and paused.
  • Token use is recorded in ~/.aimux/data/usage.jsonl under the label naming. A model without a price in aimux’s table shows tokens and an unknown cost, never 0.
  • The TUI and dashboard Generate Titles action keeps titles you wrote and records its usage the same way.

What leaves your Mac, to Anthropic, as when you use Claude Code:

  • Automatic naming: the session’s first user message and the start of its latest reply, or, for a session with a Claude Code away summary, that summary instead; up to 500 characters per session, only for the sessions named above.
  • Update summary: the session’s first user message and its latest turns, up to 1,200 characters, only when you tap it.

No other session is sent, and nothing is sent automatically for a session that already has a name and description or that you named yourself. Names and descriptions are stored in .meta.json next to each transcript.

From the top of the screen:

  1. The search box and a Working chip that narrows Now to working sessions.
  2. Needs you: pending permission requests and terminal sessions waiting for permission (with the hooks installed; their row says “Waiting for permission” and opens the chat), together newest first, then failed turns from the last 15 minutes; they appear here wherever the session is open or pinned. Cards answer in place.
  3. Pinned: pinned sessions that are not in an iTerm2 pane (closed, or open elsewhere). A pinned session in a pane stays in its pane with a pin mark.
  4. On your Mac: sessions open in iTerm2, in iTerm2’s order: window (its title, or “Window 1”, “Window 2” in creation order), then tab, then pane. A row has the title, a state line (“Working · go test ./…” with the latest step, or “Your turn · 4 min”), and the folder and pane number. Every tab is folded to one line (name, panes, working count) except the one holding the pane last in front; which tabs you open or close is remembered on the device. Windows, tabs and panes keep iTerm2’s order, so a session moves only when you move it on the Mac; a status change shows on its row.
  5. Elsewhere: open sessions not in an iTerm2 pane (started from the phone, other terminals, or a pane aimux could not match), each row showing its folder.

If no session has a place (iTerm2 is not running, or macOS has not granted Automation access), open sessions are listed under Elsewhere, each row showing its folder.

Recent | Folders at the top, remembered on the device (Recent the first time). Folders lists repositories A to Z; under a repository the main checkout first, then worktrees A to Z; sessions newest first. A folder starts closed unless it holds the newest session, and open folders are remembered. Non-git folders are grouped by their parent folder, never in one catch-all bucket. A session’s status is a mark on its row; it never moves to another section.

The same box on Now and All. As you type, it matches on the device: names, descriptions, folder, tab and window names and the last message. After a short pause it also searches conversation text on the Mac (GET /v1/search); those hits follow under “In conversations” with an excerpt. Open sessions come first, in the Mac’s order, then the rest newest first. On Now, typing hides non-matching panes in place and counts hidden tabs; closed matches follow under “Other sessions”. An empty box leaves the screen as usual.

The bar offers the next session that needs you (“your turn”, or “waiting for permission” for a terminal session at a prompt), otherwise the next working one, in Now’s order. It skips the session you are in; when picking a working session it also skips one whose approval you just answered (sessions that need you are offered regardless).

GET /v1/sessions gives each listed session these optional fields, so clients never derive names themselves:

FieldMeaning
folder{repo, repoPath, worktree, sub, label}. Git: repo is the main worktree’s folder name, repoPath its full path (the key clients group Folders by), worktree the linked worktree’s (absent for the main checkout), sub the path below the worktree top. Non-git: repo is the parent folder’s name and worktree the folder’s own; the home folder is “Home”. label is for display (“aimux / clients · ios”).
macPresent when the session’s running claude is in an iTerm2 pane: {window:{id,index,name}, tab:{index,name}, pane, panes, lastInFront}. Window and tab names drop a leading status mark such as Claude Code’s ”✳” or a spinner frame, so a name stays the same while Claude works.
workingtrue when the session is open in a terminal and Claude’s registry calls it busy.
waiting{kind, message, since} while a session open in a terminal shows a permission prompt (from the hooks); also on GET /v1/sessions/{id}.
activityWhat the session is doing now: needs_you (a permission prompt, or an approval of a turn aimux runs), else working (aimux runs a turn, Claude’s registry says busy, or on the detail the terminal screen shows Claude working), else idle (open, or an aimux run, and not working), else gone. The TUI and the web dashboard show the same answer. Also on GET /v1/sessions/{id}; absent in search hits. The clients still derive their own for now; api/testdata/clients/activity/ pins that both agree.

lastOnMac stays in the list for older clients, which hide it after 12 hours.

How it is built:

  • The layout is read from iTerm2 and joined to open sessions by tty. The read is read-only (it never focuses, types or starts iTerm2), takes about a tenth of a second, and is reused for 3 seconds. A failed read falls back to the last good one for 30 seconds, then mac is absent. With no layout yet, or one older than 30 seconds (the first list after the phone was away), a list call waits up to 1.5 seconds for a fresh read.
  • It needs macOS Automation access to iTerm2 even when terminal input is off (terminal_input.enabled is not needed), so macOS may ask once after upgrading; aimux remote terminal-input check asks for it while you are at the Mac.
  • Folders are resolved in the background and kept in ~/.aimux/folders.json (owner-only), so a deleted worktree keeps its name. A list call never waits on git.
  • The Mac layout and folder identity are phone-only for now; the TUI and CLI do not show them.
  • Pin: in the web client, the ”…” menu on a row; on the iPhone, long press. Both need the describe capability.
  • Info sheet: edit the title (80 characters) and description (300), Update summary, Pin, and a line such as “Named 4 of 20 today.” or “Naming paused.”. An empty edit clears text you wrote and returns to the automatic text; clearing a title or description you did not write changes nothing and says “Nothing to clear”.
  • Each row can show a one-line description under the title.
  • With device login on (the default), every request other than health, the contract, the web client’s files and the three login endpoints also needs a live login from a paired device.
  • The API listens on 127.0.0.1 only. It is not reachable on the tailnet except through tailscale serve.
  • Requests must carry this machine’s tailnet name as their Host (as tailscale serve forwards them). Requests for any other Host, including 127.0.0.1, are refused, so a web page that rebinds its own domain to your machine cannot use the API. The name is detected from tailscale status; set hostnames to override it.
  • Every request must carry the Tailscale-User-Login header that tailscale serve sets for the caller (and overwrites if a client sends its own). The login must be in allowed_logins; an empty list admits nobody.
  • Browser requests are accepted only from the API’s own origin (a client served by remote serve itself) (https://<this host>:<port>, as tailscale serve exposes it). A request from any other page is refused, including a plain cross-site GET such as an image tag (browsers mark those with Sec-Fetch-Site).
  • These checks keep out the tailnet and web pages, not programs on this machine: any local process (of any user account) can connect to 127.0.0.1 and send the same Host and Tailscale-User-Login headers. Run it only on a machine whose local users and programs you trust as much as yourself.
  • Sessions open in a terminal on the Mac are read-only through POST /v1/sessions/{id}/messages: sending to them is refused (open_in_terminal), because aimux does not drive a session another process owns. With terminal input on, POST /v1/sessions/{id}/terminal/messages types into the terminal itself under the checks in Typing into terminal sessions. GET /v1/sessions/{id} shows their latest turns; the event stream carries only turns started through this API, so live progress of a terminal session is not streamed yet.
  • A permission request waits for an answer from a client. Without one it is denied after approval_timeout.
  • Step detail shows a command’s full text and a failed step’s error text on the phone, as replies already show Claude’s text, so a command that carries or prints a secret shows that secret there.

The contract is api/remote-v1.openapi.yaml (OpenAPI 3.0): every path, request and response body, error code and event schema, and the compatibility rules. Each server also serves it at GET /v1/openapi.yaml, so a client or code generator gets the exact contract of the server it talks to. A contract test drives the server through every operation and event kind and checks each response against it, so the file and the server cannot drift apart.

Compatibility. /v1 only grows: new endpoints, optional fields, response fields and enum values (event kinds, states, error codes, capabilities). Clients ignore unknown fields, handle unknown enum values, and detect optional features from capabilities in GET /v1/health. Anything breaking gets a new path (/v2) next to /v1. Known gaps for app clients, listed in the spec: no paging of the session list, Claude sessions only. Events other than approval notifications (see Approval notifications) arrive only over an open stream and replay on reconnect.

Responses are JSON. Errors are {"error": "<message for people>", "code": "<stable code>"}.

Method and pathPurpose
GET /v1/health{"status","login","version","acpProtocolVersion","capabilities"}: who you are, the aimux version, the ACP version update events follow, and what this server offers (sessions, search, new_sessions, push, terminal_input, device_login, queue, describe, send_now). With the describe capability it also has "naming":{"today","limit","failing","reason","paused"} (today’s automatic naming; see Finding sessions). With device login on it also has "session": live when the request carries a live login, else none. With remote_access.links.jira_base set it also has "links":{"jiraBase","jiraProjects"} (see Links in replies)
POST /v1/auth/pairBody {"code","kind","publicKey",...}: pair this device with a code from aimux remote pair and log it in (capability device_login). kind is secure-enclave (with signature, over aimux-pair-v1\n<host>\n<code>) or webauthn (with credentialId, userHandle, clientDataJSON and authenticatorData from the passkey’s creation). The exact fields and signed strings are in the contract
POST /v1/auth/challengeBody {"deviceId"}, or {} for a passkey that finds its own device: {"challenge"}, valid 60 s, single use
POST /v1/auth/loginBody {"deviceId","challenge","signature",...}: {"deviceId","name"}, plus token for the iPhone app (send it as Authorization: Bearer <token>); the web client gets the __Host-aimux_session cookie instead
POST /v1/auth/logoutEnd this login: {"status":"logged_out"}
GET /v1/sessions?limit=50{"sessions":[...],"lastOnMac":{"id","at"}}: recent Claude sessions, newest first, with state, lastPrompt, pinned and an optional description; lastPrompt is the last message the person typed; lastOnMac is the session last in front on the Mac (absent when unknown; kept for older clients). Sessions also carry optional folder, mac and working (see Finding sessions). Sessions started by a program (claude -p) are left out unless include_automated=1. Sessions started through this API are listed too, except one whose first message failed before Claude saved it (then unavailable)
GET /v1/sessions/{id}?turns=20One session and its last turns: {..session, "turns":[{"number","time","user","reply","tools":[{"name","snippet","ok","detail"}]}], "stats":{...}, "repo":{"web","branch","root"}}. repo is the session folder’s GitHub or GitLab repository (its origin remote as a web address without credentials: https for ssh remotes, while an http or https remote keeps its scheme and a non-default port; the checked-out branch; and root, the repository folder on the Mac; the session is looked up by the working directory it recorded); it is left out for any other folder and for a session that recorded no working directory. stats is what the session has cost and used so far: costUsd, tokensIn, tokensOut, cacheReadTokens, cacheWriteTokens, turns, durationMin, and startTime, model and gitBranch when known. It is left out for a session not in the index yet (just created, file not written). A tool’s detail is optional and holds input (the full command or target; left out when snippet already shows it, as for a path or a search query), error, and for an edit old and new text (a Write’s content arrives as new), cut at 4096, 2048, 4096 and 4096 bytes with a trailing …; it is left out when the tool has none. Long sessions are read from the end, so number counts turns in the part read: use it for order, not as a stable id. A session in state open_in_terminal also has terminal: {"status","canType","working","reason","prompt"} (see below). A session with messages queued also has queue (the same object as GET …/queue)
POST /v1/sessions/{id}/messagesBody {"text","key"}. Starts a turn
POST /v1/sessions/{id}/terminal/messagesBody {"text","key","replace","now"} (replace optional: replace a draft in the box even with keep_drafts on; now optional, capability send_now: Send now, typed also while Claude works on a turn with every other check kept, 409 busy while another message is being typed, not together with replace, audited as terminal_send_now). Types the text into the session’s terminal pane, then Enter: 202 {"status":"typed"} (capability terminal_input). Bodies over 64 KiB or with unknown fields are 400 bad_request; a driver failure is 500 internal with a fixed message, its detail only in remote serve’s log
POST /v1/sessions/{id}/terminal/keysBody {"promptId","option","key"}. Presses option (an option’s key) on the prompt promptId if it still shows: 202 {"status":"typed"} (capability terminal_input)
POST /v1/sessionsBody {"cwd","text","key"}. Starts a session in one of /v1/projects and sends its first message (capability new_sessions)
GET /v1/sessions/{id}/eventsThe session’s events: a server-sent event stream, or one JSON page {"events","log"} with ?wait=0
POST /v1/sessions/{id}/cancelStop the running turn
GET /v1/sessions/{id}/queueThe session’s queue {"state","reason","note","items":[{"id","text","state","created"}]} (capability queue); state is running or paused, item states are waiting, sending and answering. A session without a queue has an empty one
POST /v1/sessions/{id}/queueBody {"text","key"}. Adds a message to the end: 200 with the item. A retry with the same key never queues it twice (once the message was sent or deleted, the answer has state done and no text). 409 queue_full at 20, 409 bad_text (the text rules), 400 bad_request for empty text or over 64 KiB
PATCH /v1/sessions/{id}/queue/{item}Body {"text"} or {"position"} (exactly one). Edits or moves a waiting message; answers the queue. 409 item_sending for a message being sent or answered, 404 unknown_item
DELETE /v1/sessions/{id}/queue/{item}Removes a message and answers the queue; 409 item_sending for one being sent or answered while the queue runs. Works for a session that no longer exists
POST /v1/sessions/{id}/queue/resumeResumes a paused queue; answers the queue
POST /v1/sessions/{id}/queue/{item}/send-nowBody {"key"} (optional, may be empty; capability send_now). Send now: types a waiting message during Claude’s turn and answers the queue without it; the others keep their order. A refusal keeps the message at its place and answers the terminal’s code (not_at_input, mac_in_use, draft_in_box, box_has_unsent …). 409 not_supported for a session no terminal holds (started from the phone), 409 item_sending for a message being sent or answered, 409 busy while another message is being typed (a queued one or another Send now), 403 disabled with terminal input off. A retry with the same key within 10 minutes answers the queue without typing again
POST /v1/sessions/{id}/open-on-macNo body. Open on Mac: closes the session’s idle background process and opens it in a pane of aimux’s iTerm2 window; answers {"state":"open_in_terminal"} once Claude holds it there (up to about 50 s). 409 busy, 409 open_in_terminal, 409 in_use, 409 not_started, 503 no_terminal, 403 not_authorized, 504 timeout, 404 unknown_session. Audit action open_on_mac
PATCH /v1/sessions/{id}/metaBody with title, description and/or pinned (at least one; title up to 80 characters, description up to 300, one line, an empty string clears your text). Answers the title, description and pin as clients show them (capability describe)
POST /v1/sessions/{id}/describeUpdate summary: asks the model now for a new title and description; {title, description, changed, kept}. 409 owner_set when you wrote both, 409 describe_running; 502 naming_auth, naming_model, naming_unreadable, naming_failed, 503 naming_unavailable, 504 naming_timeout (capability describe)
POST /v1/approvals/{id}Body {"option": "<optionId>"} from the approval event, or {"option": ""} to deny
POST /v1/devicesBody {"token","environment"}: an APNs device token (hex) and sandbox (a build run from Xcode) or production (TestFlight, App Store). The phone then gets an alert for each new approval (capability push). Register on every launch; tokens change
DELETE /v1/devices/{token}Stop sending approval alerts to that phone (capability push)
GET /v1/search?q=...&limit=20{"results":[...]}: sessions whose content matches q (keyword ranking), each with a snippet whose matched words are in [brackets] (capability search). remote serve keeps its own keyword index, ~/.aimux/remote-search.db (separate from the CLI and TUI index, no embeddings), built in the background on first start (a few minutes for a large history, with no results until then) and refreshed every minute, so new turns can take up to a minute to become searchable
GET /v1/projects{"projects":[{"path","name","lastActive"}]}: up to 12 folders of recent sessions, where a new session may start

Starting a turn. Both POST /v1/sessions/{id}/messages and POST /v1/sessions answer 202 {"id","turn","after","events"}: the session, the turn, and its events URL from just before the turn. A retry with the same non-empty key returns the same session and turn instead of running it twice; keys live until the server restarts. When every run slot (max_runs) is busy, a message is still accepted and the turn ends with turn_error; creating a session fails at once with 503 too_many_runs.

Session states. idle (can be continued), busy (aimux is running a turn), open_in_terminal (read only here while a terminal on the Mac holds it), unavailable (created here, but its first message failed before Claude saved it).

Terminal status. terminal.status is busy, idle or unknown, from Claude Code’s session registry. working is true while the screen shows Claude working; it does not change canType, but a message would be refused with busy (the clients queue it). workingSeconds, while working, is how long the turn has run, read from Claude Code’s working line. boxHasText is true when the input box holds text (a suggestion or a draft); it does not block typing. canType says whether a message would be typed now; when it is false, reason says why: disabled, not_in_terminal, not_claude, mac_in_use, no_terminal_driver, automation_denied, not_at_input or error (the terminal driver failed). prompt, when a permission prompt shows, is {"promptId","question","options":[{"key":"1","label":"Yes"}]}; promptId stays the same while the same prompt shows. With terminal input off, terminal is still there with reason disabled, so a client can say why it cannot type.

Events. Each event is {"seq","turn","kind","time","data"}:

kinddata
turn_started{"text"}
updateAn ACP SessionUpdate, passed through unchanged (agent text, thoughts, tool calls, plans; the agent’s slash-command list is left out). It follows the ACP version in /v1/health
approval{"approvalId","title","options","toolCall"}: answer with one option’s optionId
approval_resolved{"approvalId","outcome"}, outcome allowed, denied or expired
turn_done{"stopReason"} (end_turn, cancelled, …)
turn_error{"error"}

Cursors. Read events after a cursor: ?after=N, or the Last-Event-ID header, which wins (browsers send it on reconnect). Stream frames carry id: <log>.<seq> and event: <kind>. <log> names the server’s event log and changes when it restarts: a cursor from an earlier log replays the new log from its start, so pass a page’s log back as ?log=; a bare Last-Event-ID (just <seq>) belongs to the log ?log= names. The server keeps the latest 2,000 events per session, in memory.

Queue events. The events request with queue=1 also carries queue events, each the session’s whole queue after a change; without queue=1 they are left out, so older clients never see them. A detail read can be older than a queue event received just before it; the newest event wins on the next change.

Error codes. unauthorized (401), login_required (401, device login is on and the request has no live login: log in, or pair if that fails), forbidden (403), bad_request (400), invalid_option (400), unknown_session (404), queue_full (409), item_sending (409), unknown_item (404), no_such_approval (404), not_supported (404, this server does not offer the endpoint; see capabilities; 409 for Send now on a session not open in a terminal), open_in_terminal (409), busy (409), in_use (409, another aimux process such as aimux chat drives the session), not_running (409, cancel with no turn), ownership_unknown (503, Claude’s session registry is unreadable, so aimux will not risk a second writer), too_many_runs (503), internal (500). busy also answers a direct message while the session’s queue is sending. Terminal input adds disabled (403, terminal input is off), bad_text (400 for POST …/terminal/messages; 409 for POST …/queue; control characters, or a leading ! or /), and, all 409: not_in_terminal, not_claude, mac_in_use, no_terminal_driver, automation_denied (macOS refused aimux’s Automation access to iTerm2), not_at_input, not_submitted (typed but Enter not pressed; the text waits in the input box), draft_in_box (409, a draft kept by keep_drafts), box_has_unsent (409, the box still holds aimux’s own earlier message, typed but not sent: Retry it, or clear the box; another message never erases it unless sent with "replace": true), unsent_unconfirmed (409, the box shows a paste marker for the same message, but aimux can’t confirm it is its own because the Mac had input since or claude restarted: send or clear it at the Mac), box_not_cleared (409, the draft would not clear), no_prompt, prompt_changed. Finding sessions adds owner_set (409), describe_running (409), naming_timeout (504), naming_auth, naming_model, naming_unreadable, naming_failed (502) and naming_unavailable (503). Device login adds pairing_failed (401, wrong, used, expired or burned code, or a bad proof), pairing_conflict (409, the name is taken), unknown_device (404) and login_failed (401).

If ~/.aimux/config.yaml cannot be read or parsed, aimux remote serve stops with that error, even when --allow and --port are given.

Key under remote_accessFlagDefaultMeaning
port--port7382Port on 127.0.0.1
allowed_logins--allownoneTailscale logins admitted
adapternpx -y @agentclientprotocol/claude-agent-acpACP adapter command
max_runs2Sessions running at once; each is a full agent process
approval_timeout10mUnanswered permission requests are denied after this
device_logintrueOnly paired devices, logged in with Face ID or Touch ID, may use the API (see Device login); false admits any device of an allowed login, with a warning at start
hostnamesdetected from tailscale statusHost names to accept: this machine’s tailnet names
push.ntfy.topicoffntfy topic for approval notifications (see Approval notifications)
push.ntfy.serverhttps://ntfy.shntfy server
push.ntfy.token_envnoneEnvironment variable holding an ntfy access token
push.ntfy.click_urlhttps://<tailnet name>:8443/app/Web client a tapped notification opens
push.apns.key_file, key_id, team_id, topicoffAPNs token key, its id, your Apple team id and the app’s bundle id; all four or none
links.jira_basenoneJira address, http or https (for example https://issues.example.com); anything else is ignored; clients link keys like WID-123 to <jira_base>/browse/WID-123 (see Links in replies)
links.jira_projectsnoneProject keys whose issue keys become links, for example [WID, OPS]; with none listed, no key is linked
terminal_input.enabledfalseType phone messages into sessions open in iTerm2 (see Typing into terminal sessions)
terminal_input.allow_while_in_usefalseType even into the session you are using on the Mac; for testing at the Mac only
sessions.auto_title, sessions.naming_daily_limit, sessions.title_model, sessions.api_keyfalse, 20, haiku, noneAutomatic names and descriptions (see Finding sessions); these keys sit under sessions, not remote_access
terminal_input.allow_slash_commandsfalseLet a phone message start with /, so it runs as a Claude Code slash command; a leading ! is refused regardless
terminal_input.keep_draftsfalseKeep a draft you left in the input box on the Mac: a phone message is then refused (409 draft_in_box) instead of replacing it. Claude Code’s grey suggestion is always typed over

Sessions started through the API are recorded in ~/.aimux/remote-created.json (owner-only), so they stay listed although Claude marks agent-SDK sessions as automated.

A session aimux continued keeps its adapter running between turns, up to max_runs, and stops it after a few idle minutes.

Codex sessions are not listed yet.