← Back to the collection

Hotline

Cross-workspace communication for Claude Code agents.

Your agents finally have a phone. And unlike your last phone upgrade, this one actually comes with useful new features.

Hotline lets one Claude Code workspace call another — ask a question, delegate a task, or collaborate in real-time. No copy-pasting between terminals. No “hey can you check the other project and tell me…” followed by you manually doing the checking. Just agent-to-agent communication, like nature intended.

Installation

# Add the marketplace (if not already added)
claude plugin marketplace add jtsternberg/claude-plugins

# Install the plugin
claude plugin install hotline@jtsternberg

This registers the Hotline skills (hotline-dial, hotline-ringing, hotline-pickup, hotline-add-contact, hotline-whoami, hotline-caller-id, hotline-wiretap, hotline-switchboard, hotline-call-status). Invoke them as /hotline:<skill-name> in Claude Code—for example, /hotline:hotline-dial—or $hotline:<skill-name> in Codex. Bare names are prose identifiers only.


Usage — The Three Call Modes

Hotline supports three ways to communicate with another workspace. Just tell your agent what you need:

Quick Call

“Ask my blog workspace what the site tagline is.”

A quick question that gets a quick answer. One round trip, in and out.

Work Order

“Tell the marketing workspace to draft an about page based on the company info in their repo.”

Delegate a task to another workspace and let it work autonomously. You’ll get a report when it’s done.

Conference Call

“I need you to work with the design workspace on the new landing page — coordinate the component structure and styles.”

Back-and-forth collaboration between workspaces. Multiple exchanges, iterative refinement. If cmux is available, Hotline opens a visible cmux workspace immediately; otherwise it falls back to headless Claude.

Dialing by Session ID

You can also dial a specific Claude Code session directly:

“Dial session 5b1dda91-a3c1-45f9-b967-aa9dac221e59 and ask what branch it’s on.”

Dialing a session ID dials that conversation. Hotline reverse-looks up the transcript to find the workspace, boots the callee there, and resumes the session — so “what went wrong?” reaches a callee that was actually in the room rather than a stranger in the right directory. It forks by default, keeping hotline’s protocol noise out of the original transcript; if you want to contribute to that session instead (“help that session fix its bug”), the agent passes --no-fork and the call lands in their transcript.

A session ID already names its workspace, so there is no flag for “that repo, blank slate” — ask for the workspace instead:

“Dial my-repo and run the test suite.”

Messaging a Session That’s Already Running (native fast path)

Some calls don’t need hotline to launch anything. When you name a Claude Code session you already have open — “tell my other session the migration finished,” “ask the session working on the frontend what port it’s on” — and the message is something it can answer from where it sits, hotline routes through Claude Code’s native cross-session messaging (ListAgents + SendMessage, Claude Code ≥ 2.1.224) instead of spinning up a callee. It’s a plain-text summary straight into the live session: no new surface, no scraping, no wasted launch.

The gate is your wording, not a probe. Naming a workspace or project (“dial monorepo”) always launches, even though sessions in that folder are usually live — hotline’s own callees run there. Anything the target must go read or do is a work order and launches too. Hotline owns everything native can’t: launching a workspace that isn’t running, resolving targets by project name, autonomous work orders, conference calls, and the switchboard. (Claude Code only; under Codex every call uses the launch transport.)

Adding a Workspace to the Directory

Use hotline-add-contact to register a workspace so other agents can find it:

“Add this workspace as ‘blog’”

Or with explicit arguments:

“/hotline:hotline-add-contact blog /Users/me/Code/my-blog”

If dirmap is in PATH, it uses dirmap add. Otherwise, it edits ~/.dirmap.json directly.

Identifying a Workspace

Use hotline-whoami to find out what a workspace is called in dirmap:

“/hotline:hotline-whoami”

Returns the dirmap slug for the current workspace, or suggests registering it if it’s not found.


Workspace Resolution

When you say “call the blog workspace,” Hotline needs to figure out where that workspace actually lives on disk. It uses a layered resolution strategy:

1. dirmap (preferred)

If you have the dirmap CLI tool in your PATH, Hotline uses it directly. Your existing ~/.dirmap.json entries just work — say “call the blog workspace” and Hotline resolves it through dirmap. (That dirmap is part of a larger dotfiles setup, but the concept is dead simple — point your Claude at that file and say “build me a standalone version with add/list/remove in TypeScript/Go/Cobol/cuneiform/etc.” and you’ll have one in minutes.)

2. Bundled Fallback (no dirmap installed)

Hotline includes dirmap-fallback.sh, a minimal reader that supports get <id> and list against ~/.dirmap.json. But you’ll have to create/maintain a ~/.dirmap.json file manually:

{
  "blog": "/Users/you/Code/my-blog",
  "marketing": "/Users/you/Code/marketing-site",
  "api": "/Users/you/Code/backend-api"
}

3. Fuzzy Matching via Cached Identities

Hotline also caches workspace identities (via the hotline-pickup skill) — name, description, and tags for each project. Even if you say “call the React frontend” and there’s no dirmap entry called “react-frontend,” resolution can match against cached identity metadata.


cmux Integration

cmux is an optional but preferred transport for all call modes. When available, Hotline uses it instead of claude -p to avoid consuming programmatic usage credits.

cmux gives the remote agent a proper terminal, which is handy when the conversation involves running commands, reviewing output, or doing anything more complex than a Q&A.

Where the call lands (placement)

When a call routes through cmux, Hotline opens the callee side-by-side with your current pane, in the same window — you watch the call happen beside the original conversation. It does this by calling the cmux-cli plugin’s open-side-surface.sh (resolved at runtime — Hotline keeps no copy of the split-vs-adjacent decision tree), which waits for the new surface’s PTY to attach before the prompt is sent, so the callee’s first keystrokes are never dropped. Side-by-side surfaces stay open after the call (they live in your window); the caller closes them when done.

If the tool shell lacks CMUX_* context, cmux-cli can recover the caller from its exact native agent session, validated against a live ancestor PID and the surface’s tty in the current tree. Hotline supplies caller identity separately from the callee’s agent kind. Unprovable or ambiguous callers retain the recorded surface-context→detached fallback; the focused pane is never borrowed.

A dial never takes your focus, with one exception. Every surface and workspace a call creates is made --focus false, and nothing focuses a pane afterwards: the callee appears beside you and boots there while your cursor stays where it was. That is a correctness rule, not a courtesy — focus moves the input line under your keystrokes, and a stray three characters arriving ahead of the launch command once made a callee’s shell run rkebash /tmp/… and cost the caller its whole 60-second boot budget. Every read Hotline takes of a callee’s screen is scroll-immune for the same reason: you can scroll around inside a callee’s pane while a call is in flight without the caller mistaking your frozen viewport for an idle REPL. The exception is a herdr --mode conference, which focuses the callee’s pane once the prompt is confirmed in its transcript — that session is being handed to you, and by then there is no launch command left for a stray keystroke to break.

If cmux is running but the cmux-cli plugin isn’t installed, side-by-side placement isn’t available, so Hotline falls back to the headless transport for that call (it doesn’t silently drop the callee into a detached tab). --detached and --window don’t need cmux-cli and keep working on cmux regardless.

Two opt-outs, passed as flags on the dial command:

/hotline:hotline-dial --detached dotfiles run the full test suite   # disconnected new-workspace tab
/hotline:hotline-dial --window lindris backend tests, please        # group callees in a specific window (find-or-create)

The headless path ignores both flags. herdr honors --placement side and --detached (the same split, as above) and refuses --window.

Every dial names its callee. --label "<2-4 words>" is required, and it says what the callee is doing — fix hotline titles, review pr 2393. It becomes the callee’s session name, which claude publishes as its terminal title and cmux renders live in the tab strip: ◑ hotline: review pr 2393 (work_order), activity glyph and all. Nothing pins a static tab title anywhere, precisely so that glyph survives — a pinned title outranks claude’s own for the life of the tab, and losing the glyph loses how you spot a callee that is stuck. A detached callee’s workspace is named hotline: <label> instead, since it has no tab inside your window. A follow-up ignores the label and keeps the name first contact gave it.

Follow-ups reuse the existing surface. The first call to a workspace opens a surface and leaves it open, holding a live session. A follow-up dial to that same session routes its message into that existing surface instead of opening a new one — so an N-turn conversation stays in one surface rather than stacking N. That holds for a --detached callee too: it lives in one surface inside its own workspace tab, and a follow-up re-addresses that surface while it is live. When the surface can’t take the message — it’s gone (a detached tab that already auto-closed is the common case), its REPL exited, it’s mid-turn, its input box holds unsent text — the follow-up opens a fresh one and names the reason in .fallbacks. A surface a follow-up supersedes is closed once it’s proven idle and proven to be the superseded exchange, so panes don’t accumulate behind a long conversation.

To get a genuinely new callee session, pass --fresh. It reads this caller’s cached session and surface for the resolved target and declines both, so the callee starts new instead of being resumed — the flag for a pipeline phase whose value is a skeptical fresh read rather than a continued conversation. The abandoned session is reported as session-cache→fresh(<session-id>), and --fresh contradicts --resume <id>: pass one or the other.

Every message is delivered the same way: one paste over cmux’s control socket. First contact and follow-ups both write the payload to an owner-only file and hand cmux a single terminal.paste, then confirm the call’s nonce reached the callee — in its transcript, or failing that on its screen. Two consequences worth knowing: the payload never appears in a command line, so ps cannot leak a work order to other local users; and delivery is proven rather than assumed, so a message that did not land is reported instead of silently waited on. A cmux too old to offer terminal.paste is reported as terminal-paste-unavailable→headless and the call runs headless, without a visible pane.


Configuration

Identity Cache TTL

By default, workspace identities (cached by the pickup skill) are considered fresh for 24 hours. You can customize this by adding to your ~/.claude/settings.json:

{
  "env": {
    "HOTLINE_IDENTITY_TTL_HOURS": "48"
  }
}

Set it higher if your workspaces don’t change much, lower if you’re in rapid development across multiple projects.

Delivery timeout (HOTLINE_PASTE_BOX_TIMEOUT)

Before pasting, delivery waits for the callee’s REPL to draw its input box. That wait defaults to whatever --boot-timeout is for the call (60s on the cmux transport) — both are waiting for the same event. Override it only to decouple the two:

{
  "env": {
    "HOTLINE_PASTE_BOX_TIMEOUT": "45"
  }
}

This is a safety gate rather than a patience setting. A payload delivered to a surface that has not yet exec’d claude does not disappear — it goes to the shell that is there, and delivery submits with Return, so the shell would run it. When the box never appears, hotline refuses to deliver and reports stage: "deliver" with the prompt still on disk.

Skip permission prompts in cmux receivers (opt-in)

When Hotline routes a call through cmux (interactive claude), the receiver runs in an unattended pane. Any permission gate the receiver hits — skill invocation, a Bash command not on --allowedTools, etc. — stalls the call until a human clicks “Yes.” There’s no human watching.

If you want autonomous calls to skip that gate, set:

{
  "env": {
    "HOTLINE_DANGEROUSLY_SKIP_PERMISSIONS": "1"
  }
}

…or export it in your shell rc. Accepts 1 / true / yes (case-insensitive). Adds --dangerously-skip-permissions to the cmux-side claude invocation.

Default is off. This is a real trust decision — bypassing permissions means the receiver can run any tool, including ones you wouldn’t approve interactively. Only enable it if you trust the workspaces you’re dialing into. Headless (claude -p) calls do not need this — non-interactive mode handles permissions without prompting.

It does not cover Claude Code’s startup trust dialog. That gate is repository trust, not a permission mode: no permissions setting bypasses it — not this variable, not claude --dangerously-skip-permissions, not bypassPermissions as the settings default (only -p/--print escapes it, and an interactive callee is not that), so a dial into an untrusted repo parks on the dialog whatever the knob says. Trust attaches to the git repository, not the directory: a worktree or subdirectory of an already-trusted repo dials clean and never gets a trust record of its own, while a fresh git init is a new repo with its own boundary. A directory that is not a git repo at all is not gated. When the target is a repo Claude Code has not trusted, run claude in it once and answer Yes, I trust this folder before dialing into it unattended.

Force headless transport (opt-in)

By default, dial picks cmux when it’s available (free interactive usage) and falls back to headless-call.sh only when cmux isn’t running. Two ways to override:

Per call — pass --headless as a flag in the dial slash command:

/hotline:hotline-dial --headless dotfiles what branch are you on?

The flag is parsed by the dial skill and stripped from the args before workspace resolution. Forces just that one dial through the headless transport.

Always-on — set the env var in ~/.claude/settings.json (or your shell):

{
  "env": {
    "HOTLINE_FORCE_HEADLESS": "1"
  }
}

Accepts 1 / true / yes (case-insensitive). When set, check-cmux.sh always exits 1 and every dial takes the headless path regardless of cmux availability.

Use cases for either path: debugging the headless transport, A/B comparing receiver behavior across modes, or wanting claude -p’s structured stream-json output directly on stdout. Headless calls draw from the programmatic-usage credit; cmux interactive calls don’t — the opt-in default reflects that cost difference.

The herdr transport (opt-in, local or remote)

A third backend, for the one thing neither of the others can do: outlive the window it was launched from. --transport herdr hosts the callee as a named herdr agent in a persistent pane — the herdr server owns the PTY, so a long work order survives a detach, a closed lid, or a dropped SSH session.

/hotline:hotline-dial --herdr dotfiles run the 40-minute migration
# → dial.sh --transport herdr --label "run the 40-minute migration"

It requires herdr on PATH and a reachable server, and it hosts the callee here unless --remote names another box (below). Placement and mode are open: a herdr callee is always a pane split off the caller’s own, which is side-by-side, so --placement side and --detached are the same launch and differ only in the word .placement reports (side when the dial named none, exactly as over cmux). --mode conference works too — same launch, plus a focus once the prompt has landed (herdr agent focus <name>), and .awaiting_response: false because the session is the user’s now. One placement is still refused: --placement window / --window <ref>, because hotline creates no herdr workspaces or tabs and so has no window to place a callee in. --resume <someone-else's-session> is refused for Claude sessions — herdr hosts a Claude callee with the --session-id preset the transcript path depends on, and claude --resume cannot take that preset. With --agent codex, --resume <thread-id> adopts a Codex thread directly.

Three things behave differently from a cmux call, and all three are deliberate:

--remote <ssh-target> — the callee on another box

/hotline:hotline-dial --remote jt@buildbox lindris run the integration suite
# → dial.sh --transport herdr --placement detached --remote jt@buildbox \
#           --label "run integration suite"

The same transport, one ssh hop away, for work that belongs on that machine: its checkout, its GPU, its network. --remote selects herdr on its own — a cmux surface lives in this machine’s window server and claude -p is a local process, so nothing else can host anywhere but here — and it is detached by nature, so the flag alone is the whole invocation.

herdr’s own --remote cannot do this: it attaches the interactive TUI and rejects every subcommand outright. So hotline drives the ordinary herdr CLI on that box over ssh, against that box’s own local server. Every verb, refusal and proof tier is the local arm’s; the dispatch sits in one place (herdr_cli), which is why the remote half needed no second copy of the launcher, the delivery or the waiter.

Four things the wire adds:

Follow-ups behave exactly as local ones do, with one guard: the session cache records which backend and which box a host handle belongs to. surface_ref is an opaque string, so a herdr agent name from another machine is indistinguishable from a local one — re-dialing the workspace without --remote (or with a different box) is therefore refused — stage: "transport", with the cached agent, its box, and the two moves that resolve it: re-dial naming that box to continue the conversation, or --fresh to abandon it and start a new callee here. A quiet fresh callee would be worse than the refusal: the cache write would replace the entry naming the old one, leaving a live agent on the other box with nothing pointing at it. --fresh records what it abandoned, and how to close it, in .fallbacks.

cmux remains the default, and an ambient signal never selects herdr: running inside a herdr pane (HERDR_ENV=1) only makes the option available. Selecting it takes an opt-in — --transport herdr per call, or HOTLINE_TRANSPORT_AUTO=1, which lets HERDR_ENV=1 decide for a dial that names no transport. That variable is guarded on all of: exactly 1, no --transport, not headless, HERDR_ENV=1, and a passing preflight. When the preflight fails it is a degrade to the cmux default with a transport-auto→cmux(...) entry in .fallbacks, not an error — the opposite of the explicit flag, because nothing was asked for. Focus is taken in exactly one case, a conference; every other dial splits --no-focus, since moving the user’s cursor into a booting callee lands their next keystrokes in its REPL. Response extraction is shared, not forked — herdr agent wait replaces the screen poll as the when to read gate, and the same transcript-extract.sh nonce/STATUS reader produces the answer, so AWAITING_REVIEW, a turn ending on STATUS: BLOCKED (exit 7, the callee’s question as the response), and preemption behave exactly as they do over cmux. The lifecycle does add two outcomes the screen poll cannot report: a callee that settles blocked with no STATUS for the nonce is waiting on a human — a permission gate or a question — and the wait says so (exit 5, resumable) instead of spending its whole budget and calling it a timeout; and a callee that settles idle/done with no STATUS whose turn ended — on an API-error row confirmed by two settled reads, or after HOTLINE_STALL_SECONDS (default 600) with neither its transcript nor, locally, its subagents’ transcripts growing — gets exit 6 (resumable; resume with a follow-up dial). A --remote call sees only the main transcript, so a remote callee waiting on its own subagents can still hit the quiet window. First contact is gated harder than a follow-up: before the opening payload goes out, delivery settles and then re-reads agent get until herdr reports the agent interactive_ready and not blocked — a launch-time readiness claim is not a submit-time one, and a payload submitted into a startup trust prompt answers the dialog and vanishes rather than becoming a turn. It also gets a larger confirmation budget, because first contact waits on the callee’s transcript being created rather than appended. Both refusals are pre-submit (sent:false), so a re-dial is safe. A callee’s pane is a sibling split of the anchor by default; HOTLINE_HERDR_PLACEMENT=tab|workspace gives it its own tab instead, optionally inside a group workspace named by HOTLINE_HERDR_WORKSPACE, which is what keeps a run that dials a dozen callees off one pane from producing a single tab of unreachable slivers. The callee’s tab carries --label’s subject behind the call nonce, and its pane’s terminal title carries the session name hotline: <label> (<mode>); HOTLINE_HERDR_TAB_LABEL overrides the tab label verbatim. Tuning knobs: HOTLINE_HERDR_PANE, HOTLINE_HERDR_PLACEMENT, HOTLINE_HERDR_WORKSPACE, HOTLINE_HERDR_TAB_LABEL, HOTLINE_HERDR_SPLIT_DIRECTION, HOTLINE_HERDR_PANE_SETTLE, HOTLINE_HERDR_START_BUDGET, HOTLINE_HERDR_READY_POLL, HOTLINE_HERDR_START_ATTEMPTS, HOTLINE_HERDR_FIRST_SETTLE, HOTLINE_HERDR_READY_TRIES, HOTLINE_HERDR_FIRST_CONFIRM_TRIES, HOTLINE_HERDR_WAIT_SLICE_MS, HOTLINE_STALL_SECONDS, HOTLINE_HERDR_STALL_SETTLE, HOTLINE_HERDR_KEEP_FAILED_PANE, and for --remote: HOTLINE_HERDR_REMOTE_PANE, HOTLINE_REMOTE_SSH_TIMEOUT, HOTLINE_REMOTE_SSH_PERSIST, HOTLINE_REMOTE_SSH_CONNECT_TIMEOUT.


How It Works

The Complete Flow

┌─────────────────────────────────────────────────────────────────────┐
│  WORKSPACE A (Caller)                                               │
│                                                                     │
│  User: "Dial the blog workspace and ask what the tagline is"        │
│                          │                                          │
│                          ▼                                          │
│                 ┌─────────────────┐                                 │
│                 │  hotline-dial   │  (SKILL.md loaded)              │
│                 │    skill        │                                 │
│                 └────────┬────────┘                                 │
│                          │  ONE command                             │
│                 ┌────────▼────────┐                                 │
│                 │    dial.sh      │  emits ONE JSON payload         │
│                 └────────┬────────┘                                 │
│                          │                                          │
│            ┌─────────────┼──────────────┐                           │
│            ▼             ▼              ▼                           │
│    ┌──────────────┐ ┌──────────┐ ┌────────────┐                    │
│    │ session-     │ │ resolve- │ │ session-   │                    │
│    │ init.sh      │ │workspace │ │ cache.sh   │                    │
│    │              │ │ .sh      │ │            │                    │
│    │ "Who am I?"  │ │ "Where?" │ │ "Talked    │                    │
│    │              │ │          │ │  before?"  │                    │
│    └──────┬───────┘ └────┬─────┘ └─────┬──────┘                    │
│           │              │             │                            │
│           │   ┌──────────┘             │                            │
│           │   │  Uses:                 │                            │
│           │   │  • dirmap get/list     │                            │
│           │   │  • identity-cache.sh   │                            │
│           │   │  • ~/.agents-hotline/  │                            │
│           ▼   ▼                        ▼                            │
│    MY_SESSION_ID    TARGET_PATH    EXISTING_SESSION?                │
│                          │                                          │
│                          ▼                                          │
│                 ┌─────────────────────────────┐                     │
│                 │ Transport select (per mode) │                     │
│                 │                             │                     │
│                 │ --transport herdr?      ─►  │  herdr-call-async.sh│
│                 │ cmux available?             │                     │
│                 │  ├─ quick / work order ──►  │  cmux-call-async.sh │
│                 │  └─ conference call    ──►  │  cmux-call.sh       │
│                 │ cmux unavailable        ─►  │  headless-call*.sh  │
│                 └─────────────┬───────────────┘                     │
│                               │                                     │
└───────────────────────────────┼─────────────────────────────────────┘
                                │
              Either way, the launcher starts a BARE claude —
              no payload on its command line. The message waits
              in $CALL_DIR/pending_paste.md (0600) and is
              delivered after the boot wait as ONE
              terminal.paste over cmux's control socket, then
              confirmed by the call's CALL_ID nonce.

              First contact (no --resume) delivers:
                "/hotline:hotline-ringing [MODE: ...] \
                  [CALLER: ...] [SESSION: ...] \
                  <the actual prompt>"

              Follow-up (existing session) delivers:
                "/hotline:hotline-ringing [FOLLOW_UP] \
                  [MODE: ...] [CALLER: ...] [SESSION: ...] \
                  <the actual prompt>"
              into the surface that session already lives
              in, so the message lands in command-args
              rather than as pasted text the callee won't
              act on. Only when that surface refuses before
              anything is sent do we open a fresh one and
              --resume into it. Headless follow-ups send
              the raw message to `claude -p --resume`.
                                │
┌──────────────────────────┼──────────────────────────────────────────┐
│  WORKSPACE B (Receiver)  ▼                                          │
│                                                                     │
│                 ┌─────────────────┐                                 │
│                 │ hotline-ringing │  (SKILL.md loaded via           │
│                 │    skill        │   /hotline:hotline-ringing in prompt)   │
│                 └────────┬────────┘                                 │
│                          │                                          │
│            Parses: MODE, CALLER, SESSION                            │
│            from the prompt metadata                                 │
│                          │                                          │
│                          ▼                                          │
│            ┌─────────────────────────┐                              │
│            │  Agent B does the work  │                              │
│            │  (reads files, answers  │                              │
│            │   questions, makes      │                              │
│            │   changes, etc.)        │                              │
│            └─────────────┬───────────┘                              │
│                          │                                          │
│            (no logging step — Workspace A logs the call;            │
│             dial-history.sh lives in A's plugin dir, which          │
│             B's workspace-isolation rule forbids touching)          │
│                          │                                          │
│            Response + STATUS signal                                 │
│            (DONE / WORK_COMPLETE / WORK_IN_PROGRESS /               │
│             AWAITING_REVIEW / OUT_OF_SCOPE)                          │
│            + optional HOTLINE_NOTE for protocol issues               │
│                          │                                          │
└──────────────────────────┼──────────────────────────────────────────┘
                           │
                           │  JSON response with session_id
                           │
┌──────────────────────────┼──────────────────────────────────────────┐
│  WORKSPACE A (back)      ▼                                          │
│                                                                     │
│            ┌──────────────────────────────┐                         │
│            │  register-call.sh            │                         │
│            │   ├─ session-cache.sh set    │  (caches for reuse)     │
│            │   └─ dial-history.sh append  │  (logs the call)        │
│            └─────────────┬────────────────┘                         │
│                          │                                          │
│                          ▼                                          │
│  Agent A reports to user:                                           │
│  "Connected to blog (session: abc123).                              │
│   Their response: [answer]"                                         │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

The dial wrapper

skills/dial/scripts/dial.sh is the single entry point for everything in that first box. It composes the scripts below it — it modifies none of them — and emits one JSON object:

dial.sh --target "<the user's words>" --mode work_order \
        --label "<2-4 word slug of the task>" --prompt-file /tmp/msg.txt
.status exit Meaning
connected 0 The callee is up; .remote_session_id / .call_dir / .surface_ref describe the call
replay 2 Legacy fallback only — identity needs one more pass; run the identical command again
needs_disambiguation 3 .candidates holds the matches; ask the user, re-run with a path
error 1 .stage + .detail + .recovery

Every degradation it absorbs rather than handing back as a decision lands in a fallbacks array: transport re-fires to headless (no cmux, no cmux-cli, no python3, an unreachable control socket, a cmux that doesn’t offer terminal.paste), placement dropping from side-by-side to a detached tab, a refused surface reuse falling through to a fresh surface, closing the surface a follow-up superseded, and an identity refresh. skills/dial/SKILL.md carries the full table of entries and what each one means for the caller.

wait-for-response.sh stays outside the wrapper on purpose — a work order can outlast a tool-call timeout, and the caller has to report the connection to the user in between boot and response.

Session identity (the “Know Thyself” step)

Identity resolves inline, in the same dial.sh invocation as the rest of the call. Claude Code exports $CLAUDE_CODE_SESSION_ID into every Bash subprocess (2.1.132 and up) and that value is the resumable session ID, so session-init.sh validates it as a UUID and returns. Codex callers land on $CODEX_THREAD_ID the same way.

session-init.sh
      │
      ├── $HOTLINE_CALLER_SESSION_ID set? ──────► caller_kind "override" ──► done
      │
      ├── $CLAUDE_CODE_SESSION_ID valid UUID? ──► caller_kind "native"   ──► done
      │        (Claude Code >= 2.1.132 — the normal path, one call)
      │
      ├── $CODEX_THREAD_ID set? ────────────────► caller_kind "codex"    ──► done
      │
      └── LEGACY FALLBACK (pre-2.1.132 Claude, or a stripped environment)
                │
                ▼
          session-fingerprint.sh ──► Cache hit? ──YES──► session ID (exit 0)
                │
                NO (exit 1) ──► stderr: SESSION_FINGERPRINT_<uuid>
                │
                │  (the fingerprint reaches the transcript only *after*
                │   this tool call returns — hence a second call)
                │
                ▼  [SEPARATE TOOL CALL]
          session-init.sh discover <fingerprint>
                │
                ▼
          session-discover.sh greps recent .jsonl transcripts
          (3 retries, 1s apart, for the async flush; then the 10
           most recent project dirs) ──► Cache to /tmp, return ID

dial.sh drives that fallback itself: on a legacy cache miss it plants the fingerprint, persists it keyed by the claude PID, and returns {"status":"replay"} so an identical re-run completes the call. The two-tool-call shape is unavoidable there, but it’s the only path that produces a replay, and nothing outside the wrapper has to know about it.

Workspace Resolution

resolve-workspace.sh "<user's words>"
      │
      ├── Starts with / or ~ ? ──► Validate path exists ──► Done
      │
      ├── Contains / ? ──► Try $PWD/<ref> (relative path) ──► Done
      │
      ├── Looks like a UUID? ──► Session cache ──► Transcript reverse lookup ──► Done
      │
      ├── dirmap get "<ref>" ──► Exact match? ──► Done
      │
      └── dirmap list --json ──► Enrich with identity caches
              │                   from ~/.agents-hotline/identities/
              ▼
          Candidates JSON on stderr (exit 1)
          Agent picks the best match or asks user

The Skills

State

All hotline state lives in ~/.agents-hotline/:


The Switchboard

A live, read-only dashboard of every hotline conversation — ask Claude to “open the switchboard” (or run the script directly):

Hotline Switchboard — call board with patch cords and lamps on the left, both ends of a call rendered side-by-side

bash plugins/hotline/skills/switchboard/scripts/switchboard.sh start   # opens the browser
bash plugins/hotline/skills/switchboard/scripts/switchboard.sh stop
bash plugins/hotline/skills/switchboard/scripts/switchboard.sh status

What you get:

Registration is script-level on the dial side: wait-for-session.sh records each call in the sessions registry the moment the remote session ID is known, so calls appear on the board without relying on the dialing agent to cache them.

Zero dependencies: a single-file Node server with an inline UI — no npm install, no build step. It never writes to the registry or transcripts. Default port 4160 (--port=<n> or HOTLINE_SWITCHBOARD_PORT to change).


Roadmap

Hybrid Protocol

Per-mode transport selection — using the best tool for each call type rather than defaulting to headless-with-optional-cmux-escalation. Think: different transport backends optimized for quick calls vs. deep collaboration.

Non-Claude Agent Support

You may have noticed the state directory is ~/.agents-hotline/, not ~/.claude-hotline/. That naming was deliberate. The long-term vision is cross-agent communication — not just Claude-to-Claude, but any agent that speaks the protocol. Claude just happens to be the first tenant.


Bonus: Session ID Discovery

A running agent can read its own session ID from $CLAUDE_CODE_SESSION_ID — Claude Code exports it into every Bash subprocess as of 2.1.132, closing a gap the community had asked about in anthropics/claude-code#25642 and friends. Hotline uses it directly.

Hotline also still ships the fingerprint-and-grep discovery it was built on, as a standalone utility and as the fallback for clients older than 2.1.132 (or environments where the variable never arrives).

Full docs and usage: SESSION-ID-DISCOVERY.md


Selecting a Codex account

Set HOTLINE_CODEX_HOME to the account’s existing absolute Codex home and dial with --agent codex --transport cmux, or --agent codex --fresh plus HOTLINE_HERDR_PLACEMENT=tab (or workspace with HOTLINE_HERDR_WORKSPACE). Explicit thread resume also works over local cmux and herdr tab/workspace. The tab receives CODEX_HOME before Codex starts; cmux exports it in its launch script; HOTLINE_CALLEE_ENV only configures tool subprocesses and cannot select the process account. The home is canonicalized and stored as codex_home.txt in the call directory for rollout discovery and independent response waiters.

When a thread-id lookup misses the default account, hotline searches local $HOME/.codex*/sessions directories unless an explicit home was selected. A unique matching account is selected and reported in .fallbacks; multiple accounts are refused with their paths. Automatic selection defaults unset herdr placement to tab. Local resume launches require lsof to check that no live Codex process already owns that rollout; an existing owner is refused with its PID before delivery. Close that pane/surface before retrying. Headless resume and explicit account selection over headless/remote remain unsupported.

A cached continuation to the same target/caller uses --agent codex without --fresh. Supply the same home or omit the override: the cache retains its selected account regardless of ambient CODEX_HOME. Follow-up call directories copy codex_home.txt, codex_rollout.txt and host metadata. Reuse validates the pinned rollout and live Codex pane before delivery. Herdr may omit its thread field; local lsof must show one foreground Codex process holding exactly that rollout even when a matching thread is reported; missing or ambiguous ownership refuses reuse. Explicit home mismatches, inconsistent/missing metadata and dead selected-account agents fail closed. Recover with a new explicit-home --fresh call. Legacy unpinned Codex calls retain their existing dead-agent resume behavior.

Ownership verification matches descriptor device/inode to the current regular rollout file and checks process birth identity and the live pane around that observation. Framed lsof evidence must be complete; replaced files and any unresolved, deleted or additional rollout records refuse before input delivery. Fresh startup and first-contact readiness share one absolute boot deadline.

Herdr’s Codex integration reports session identity from a SessionStart hook over its local socket. Codex’s --disable hooks disables that reporting too, so an absent agent_session is expected on this path. Hotline does not install or enable hooks to repair it. Selected-account continuation supports that runtime by requiring the live kind/name/pane, intact home/thread metadata and session header, and unique foreground process ownership of the exact pinned rollout. A PID alone never proves identity. Missing ownership evidence refuses before input delivery. Operators needing Herdr’s hook-based session reporting/native restore must separately review its integration and configured hooks in the selected CODEX_HOME, including any shared hooks.json symlink, and resolve real Codex trust decisions themselves. Enabling hooks or reporting a matching thread never waives Hotline’s rollout-ownership verification.

An explicit home refuses herdr split launches, remote calls, headless and non-Codex agents. An empty, relative, missing or inaccessible home is an error. New calls without an override preserve the existing defaults. For both Claude Code and Codex callers, run the bundled dial.sh with the same options; no credential contents need to be read or forwarded.

When reporting to another Codex agent through codex queue, start every message with the sender’s name or role, for example [Hotline builder] Tested contract ready; no live smoke started. Use the same prefix for checkpoints, completion reports, blockers and callbacks. Queue acceptance does not prove delivery or that the receiver acted on the message.

Codex startup readiness uses bounded read-only observations of the same callee. It waits for two current composer/ready snapshots, without retrying agent start or typing into a gate. Isolated older gate headings above the composer are stale evidence; a complete trust-choice UI remains a refusal. Unknown or unreadable startup state times out without asserting that a human trust decision is needed.