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.
Set it up
Section titled “Set it up”-
Allow your Tailscale login in
~/.aimux/config.yaml:remote_access:allowed_logins:- you@example.com -
Run the API:
Terminal window aimux remote serve -
Expose it on your tailnet with HTTPS:
Terminal window tailscale serve --bg --https=8443 http://127.0.0.1:7382Turn 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.
-
Pair each phone or browser once (see Device login):
Terminal window aimux remote pair --name iphoneType the code it prints into the iPhone app or the web client within 5 minutes.
-
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
Web client
Section titled “Web client”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.
Device login
Section titled “Device login”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:
aimux remote pair --name iphoneIt 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 revokefor 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.
aimux remote devices # name, kind, paired, last seenaimux remote revoke iphone # its next request failskind 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
HttpOnlycookie 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: falseturns it off: any device signed in to your tailnet as an allowed login can use the API again, andremote serveprints a warning at start.
Approval notifications
Section titled “Approval notifications”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, andremote servelogs that the delete was refused. On the publicntfy.shserver 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 setpush.apns. The app registers its device token withPOST /v1/devices; devices are kept in~/.aimux/remote-devices.json(owner-only) with the login that registered them, only devices of logins still inallowed_loginsget alerts, and Apple’s “unregistered” answers remove them. The alert is time-sensitive, uses categoryAIMUX_APPROVAL, groups by session, carriesapprovalIdandsessionIdfor 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 idA 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.
Permission alerts for terminal sessions
Section titled “Permission alerts for terminal sessions”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.
aimux remote hooks install # add the hooksaimux remote hooks status # are they installed, and do they run this binary?aimux remote hooks uninstall # remove only aimux's hooksWhat install writes:
- It merges hook entries into
~/.claude/settings.json(fromHOME; 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 thehooksentry 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 || truewith a 10 s timeout:Notificationwith the matcherpermission_promptmarks the session as waiting;PostToolUse,PostToolUseFailure,UserPromptSubmit,StopandSessionEndclear it. Sessions started after the install use the hooks; restart open ones to pick them up. - The path is the
aimuxon yourPATHwhen 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,|| truekeeps Claude Code from showing a hook error, alerts stop, andaimux remote hooks statussays the binary does not exist. uninstallremoves only aimux’s entries, also from a matcher group you share with your own hooks (an event list or thehooksobject 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.logwithout its text. - Claude Code sends the
permission_promptnotification once a prompt has waited about six seconds, so a prompt you answer at the Mac right away sends nothing. remote servereads the markers when it lists sessions and addswaitingonly 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 sessionremote servenever 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 servestarts, markers older than 2 minutes count as already pushed; newer ones are pushed. - At startup
remote serveprints whether the hooks are installed.
Typing into terminal sessions
Section titled “Typing into terminal sessions”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.
-
Turn it on in
~/.aimux/config.yamland restartaimux 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 -
Run this once at the Mac:
Terminal window aimux remote terminal-input checkIt 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 serveruns 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 withno_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_inputcapability only when it is on; otherwise both endpoints answer403 disabled. - The pane’s foreground process must be the session’s own
claude(its process group, runningclaudeornode;not_claudeotherwise), 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_useotherwise); see The in-use guard.allow_while_in_use: truelifts 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 abusystatus 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": trueis 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 busytoo, on bothPOST …/terminal/messagesandPOST …/messages, so it never joins the queue’s turn and the waiting messages keep their place. Send now ("now": true, or the queue’ssend-nowroute) is the exception: it goes past waiting and answering items, and is refused with409 busyonly while another message is being typed. - A message is typed only when Claude Code’s input box shows (
not_at_inputotherwise), 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_clearedif the box does not empty), or withkeep_drafts: truekept and the message refused (409 draft_in_box). A message with"replace": truereplaces a draft even withkeep_draftson. 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 sameclaudestill runs and the Mac has had no keyboard or mouse input since, wall-clock time counted so a sleep cannot shorten it; otherwise Retry answers409 unsent_unconfirmedand you send or clear it at the Mac); another message is refused with409 box_has_unsentand never erases it, unless sent with"replace": true. The attempt log names which check stopped it (not_submitted in_use,not_in_box,promptorcancelled), 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, …), unlessallow_slash_commands: trueis 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
keywithin 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.
The in-use guard
Section titled “The in-use guard”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.
Open on Mac
Section titled “Open on Mac”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.
| Refusal | Shown on the phone |
|---|---|
busy | Claude is answering. Wait for it to finish, or Stop it first. |
open_in_terminal | Already open in a terminal on your Mac. |
no_terminal | Open on Mac needs iTerm2 on your Mac. |
not_authorized | aimux may not control iTerm2. Allow it in System Settings, Privacy & Security, Automation. |
in_use | Another aimux process is using this session. |
not_started | This session has no messages yet, so there is nothing to open. |
timeout | The 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.
Chat queue
Section titled “Chat queue”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 ofremote serve. It holds up to 20 messages per session (queue_fullbeyond 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/unlessallow_slash_commandsis 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 busystays 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:
| Reason | What to do |
|---|---|
| You used this session on your Mac | A turn you started at the Mac appeared. Resume when your queued messages should follow it. |
| This session closed on your Mac | Claude 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 running | The 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 Mac | The 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 resume | The 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 yet | The 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 iTerm2 | Fix 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 Mac | Resume 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.
Links in replies
Section titled “Links in replies”The phone and web clients turn references in Claude’s replies into links:
#123andowner/repo#123open the issue or pull request, a commit hash (7 to 40 hex characters) opens the commit, and a file path such asinternal/api/server.go:42opens that line on the session’s branch. The server reports the session’s repository inrepoonGET /v1/sessions/{id}; sessions in other folders get no such links.gitruns 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-8orISO-9001never become links:
remote_access: links: jira_base: https://issues.example.com jira_projects: [WID, OPS]Finding sessions
Section titled “Finding sessions”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.
Names and descriptions
Section titled “Names and descriptions”The title is the first of:
- The owner’s text: a title you typed (info sheet or
PATCH /v1/sessions/{id}/meta). It is never overwritten. - The name you gave the session with Claude Code’s
/rename. - Ours: a title a model wrote (below). Update summary writes this one.
- Claude Code’s own
ai-title. - 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.
When a model is used
Section titled “When a model is used”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: trueturns automatic naming on (defaultfalse). With it off, nothing is sent unless you press Update summary.sessions.naming_daily_limit(default20) 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_modelishaiku(default,claude-haiku-5-5),sonnetoropus. An unknown name falls back to the default.- Backend: the Messages API when an API key is set (
sessions.api_key, elseANTHROPIC_API_KEY); otherwiseclaude -pin 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 fixedreasonandpaused. - Token use is recorded in
~/.aimux/data/usage.jsonlunder the labelnaming. 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:
- The search box and a Working chip that narrows Now to working sessions.
- 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.
- 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.
- 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.
- 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.
Search
Section titled “Search”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 chat’s Next bar
Section titled “The chat’s Next bar”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).
Folder and place on the API
Section titled “Folder and place on the API”GET /v1/sessions gives each listed session these optional fields, so clients never derive names themselves:
| Field | Meaning |
|---|---|
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”). |
mac | Present 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. |
working | true 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}. |
activity | What 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
macis 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.enabledis not needed), so macOS may ask once after upgrading;aimux remote terminal-input checkasks 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.
Pins and the info sheet
Section titled “Pins and the info sheet”- Pin: in the web client, the ”…” menu on a row; on the iPhone, long press. Both need the
describecapability. - 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.
Security model
Section titled “Security model”- 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.1only. It is not reachable on the tailnet except throughtailscale serve. - Requests must carry this machine’s tailnet name as their
Host(astailscale serveforwards them). Requests for any other Host, including127.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 fromtailscale status; sethostnamesto override it. - Every request must carry the
Tailscale-User-Loginheader thattailscale servesets for the caller (and overwrites if a client sends its own). The login must be inallowed_logins; an empty list admits nobody. - Browser requests are accepted only from the API’s own origin (a client served by
remote serveitself) (https://<this host>:<port>, astailscale serveexposes it). A request from any other page is refused, including a plain cross-site GET such as an image tag (browsers mark those withSec-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.1and send the sameHostandTailscale-User-Loginheaders. 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/messagestypes 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 path | Purpose |
|---|---|
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/pair | Body {"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/challenge | Body {"deviceId"}, or {} for a passkey that finds its own device: {"challenge"}, valid 60 s, single use |
POST /v1/auth/login | Body {"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/logout | End 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=20 | One 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}/messages | Body {"text","key"}. Starts a turn |
POST /v1/sessions/{id}/terminal/messages | Body {"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/keys | Body {"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/sessions | Body {"cwd","text","key"}. Starts a session in one of /v1/projects and sends its first message (capability new_sessions) |
GET /v1/sessions/{id}/events | The session’s events: a server-sent event stream, or one JSON page {"events","log"} with ?wait=0 |
POST /v1/sessions/{id}/cancel | Stop the running turn |
GET /v1/sessions/{id}/queue | The 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}/queue | Body {"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/resume | Resumes a paused queue; answers the queue |
POST /v1/sessions/{id}/queue/{item}/send-now | Body {"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-mac | No 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}/meta | Body 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}/describe | Update 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/devices | Body {"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"}:
kind | data |
|---|---|
turn_started | {"text"} |
update | An 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).
Settings
Section titled “Settings”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_access | Flag | Default | Meaning |
|---|---|---|---|
port | --port | 7382 | Port on 127.0.0.1 |
allowed_logins | --allow | none | Tailscale logins admitted |
adapter | npx -y @agentclientprotocol/claude-agent-acp | ACP adapter command | |
max_runs | 2 | Sessions running at once; each is a full agent process | |
approval_timeout | 10m | Unanswered permission requests are denied after this | |
device_login | true | Only 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 | |
hostnames | detected from tailscale status | Host names to accept: this machine’s tailnet names | |
push.ntfy.topic | off | ntfy topic for approval notifications (see Approval notifications) | |
push.ntfy.server | https://ntfy.sh | ntfy server | |
push.ntfy.token_env | none | Environment variable holding an ntfy access token | |
push.ntfy.click_url | https://<tailnet name>:8443/app/ | Web client a tapped notification opens | |
push.apns.key_file, key_id, team_id, topic | off | APNs token key, its id, your Apple team id and the app’s bundle id; all four or none | |
links.jira_base | none | Jira 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_projects | none | Project keys whose issue keys become links, for example [WID, OPS]; with none listed, no key is linked | |
terminal_input.enabled | false | Type phone messages into sessions open in iTerm2 (see Typing into terminal sessions) | |
terminal_input.allow_while_in_use | false | Type 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_key | false, 20, haiku, none | Automatic names and descriptions (see Finding sessions); these keys sit under sessions, not remote_access | |
terminal_input.allow_slash_commands | false | Let a phone message start with /, so it runs as a Claude Code slash command; a leading ! is refused regardless | |
terminal_input.keep_drafts | false | Keep 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.