# What the phone can ask this host (developer reference)

The companion phone client is a **separate app in a separate repo** —
[receptron/mulmoserver](https://github.com/receptron/mulmoserver), deployed at
<https://mulmoserver.web.app>. It talks to MulmoTerminal over Firestore command docs, not HTTP,
so nothing in `README.md`'s `/api/*` table describes it.

This page is the contract between the two: **every command the host answers, the shapes it
answers with, and the rules that decide what belongs on which side.** Written because three
features in a row (#830, #831, #832) each had to re-derive them by reading the handlers.

> Adding or changing a command? Update this page in the same commit, and file the matching
> issue on `receptron/mulmoserver` — the phone ignores a field it was never taught about, so a
> host-only change ships as silence rather than as a feature.

## Transport

`@mulmoclaude/core/remote-host/server` owns it. The host signs in with the user's Firebase
account, subscribes to a per-user command channel, and answers each doc it sees.

- `HOST_ID = "mulmoterminal"` (`server/backends/remoteHost/index.ts`) — distinct from
  MulmoClaude's, so both can be registered to one account.
- The subscription is wrapped in **`resilientRunner`** (#823): core gives up on its listener
  permanently, so the wrapper re-subscribes and gives up on TIME rather than a retry count.
  `GET /api/remote-host/status` reports `health` (`online` / `reconnecting` / `offline`).
- A handler that **throws** turns into the phone's error message. That is the intended way to
  refuse — the phone shows the sentence, so write it for a person.

## Commands

Handlers live in `server/backends/remoteHost/handlers/`, one file per command (the same layout
MulmoClaude uses); `handlers/index.ts` is the table that names them all, and the terminal ones are
grouped in `handlers/terminalSession.ts`.

| Command | Params | Answers |
|---|---|---|
| `listTerminalSessions` | — | `{ sessions: TerminalSessionSummary[], icons: Record<string, string> }` |
| `getTerminalScreen` | `sessionId` | `SessionScreen` |
| `sendTerminalInput` | `sessionId`, `text` | `{ sent: true }` |
| `launchTerminal` | `agent`, `sessionId` | `{ ok: true }` |
| `startChat` | `message`, `attachments?` | `{ started: true, chatId }` |
| `listIssues` | — | `{ repos: RepoIssueRows[] }` |
| `startIssueWork` | `repo`, `issue`, `run?` | `{ started: true, sessionId, branch, issue, outcome, ran }` |
| `listFeeds` | — | `{ feeds }` |
| `getFeed` | `slug`, `offset?`, `limit?` | the feed page |
| `listCollections` | — | `{ collections }` (feed-backed ones excluded) |
| `getCollection` | `slug`, `offset?`, `limit?` | one page of the collection's items |
| `listShortcuts` | — | `{ shortcuts }` |
| `listSkills` | — | `{ skills }` (collection slugs excluded) |
| `listAccountingBooks` | — | `{ books: { id, name }[] }` |
| `getRemoteView` | `slug`, `viewId`, `locale?` | `{ view, srcdoc, bytes }` |
| `getRemoteViewItems` | `slug`, `viewId`, `offset?`, `limit?`, `fields?` | `{ page, inlined, omitted }` |
| `mutateRemoteViewItem` | `slug`, `viewId`, `op`, `id`, `patch?` | `{ op, item }` / `{ op, id }` |

The three `*RemoteView*` commands serve a collection's **mobile custom views**. The host wraps
each view into its sandboxed `srcdoc` (CSP + postMessage bootstrap) and the phone renders that
verbatim, so the view's own code never runs with the phone's privileges. A write is authorized
**host-side** against the view's declared surface (`editableFields` / `allowDelete`) — the
sandboxed view is not trusted to police itself. `mutateRemoteViewItem` answers
`{ applied: true, warning }` rather than throwing when the write landed but its response blew
the byte budget, so a successful edit is never shown as a failure (#747).

Image fields are **not** inlined by this host (there is no thumbnail store yet): they come back
as workspace paths, unrenderable on the phone, and count toward `omitted`.

### Starting work on an issue (#1184)

`listIssues` is the phone's half of the `/prs` issue list: the open issues of the repos in
Settings' `prRepos`, capped per repo, no bodies. Each repo row carries what
`GET /api/issues` answers with (`repo`, `issues[]`, `truncated?`, `url?`, `error?`) plus:

```ts
{ canStart: boolean; startBlocked?: string }   // the sentence only when canStart is false
```

`startIssueWork` then reads the issue, cuts its `issue/<N>-<slug>` worktree off the fetched
mainline, and spawns a session there seeded with the issue. It answers
`{ started: true, sessionId, branch, issue: { number, title }, outcome, ran }`; `sessionId` is the
id `getTerminalScreen` takes, so the phone can watch what it just started.

**An issue has ONE worktree, so a second call for it does not start a second thing** (#1219).
`outcome` says which of three happened:

| `outcome` | what happened | was the issue typed into it? |
|---|---|---|
| `created` | the worktree was cut and a session seeded in it | yes |
| `reused` | the worktree was already there and empty; the session is new | yes |
| `resumed` | the worktree's own session opened — nothing was spawned | **no** — it has its own history |

A fourth case is a refusal rather than an answer: the worktree's session is **open in another
terminal**, and the sentence says to close it there first. One working tree runs one agent
(#1207), and that rule is not suspended because the request came from the phone.

### `run` — start it, don't just type it (#1253)

By default the seed is **typed and not submitted**: the text was written by whoever opened the
issue, so the Enter is the user's. That is right on the desktop and wrong on a phone, which has no
Enter key — the work simply stops there. **`run: true`** submits it instead.

**Only the host can do this.** The seed is typed once the TUI's input box has painted
(`server/session/draft-injection.ts`), so an Enter sent from the phone would race the injection,
and nothing outside this process knows when it landed. `sendTerminalInput` cannot send a bare Enter
either (empty text is rejected), and a one-character workaround would be cleared by the
before-paste Ctrl-C. So this is a parameter here rather than a sequence the phone performs.

**Auto-running is safe because of what the seed says.** `issueSeedPrompt` ends with *"Read it
through first and confirm the approach with me before implementing"*, so the session stops for a
decision before it writes anything.

`ran` is not an echo of `run` — it is false whenever nothing was seeded, whatever was asked:

| | `run: true` | `run` absent / `false` |
|---|---|---|
| `created` / `reused` | `ran: true` — typed and submitted | `ran: false` — typed, waiting for Enter |
| `resumed` | **`ran: false`** — nothing was typed, so there is nothing to submit | `ran: false` |

`resumed` never runs, whatever was asked: that session has its own history and **nothing was typed
into it**, so submitting would send whatever the user left in its box, or an empty line. Anything
other than `true` is read as `false`, which leaves the behaviour every caller had before this
option existed.

**`ran: true` means the session was started to run its seed, not that a keystroke has landed.** The
reply is sent as soon as the session exists; the seed is typed — and submitted — afterwards, once
the TUI's input box has painted. So the phone should word it as *started*, not as *the agent has
answered*, and read the session itself (`getTerminalScreen`) for the latter.

**The desktop keeps the draft.** `POST /api/issues/start` takes no `run` — there, the person who
opened the issue and the person about to run it are often not the same, and the reviewing Enter is
the point.

**It takes no `dir`, by rule** (see "The phone never sends a path" below). The work starts in the
clone recorded for that repo — or in the only one, when the repo has exactly one here. When
several clones could host it and none has been recorded, the command **refuses** and says to
choose once on the desktop. Picking for the user is not a smaller decision than it looks: an
agent runs where it is started, that cannot be undone, and the phone has no way to show which
tree it landed in. `canStart` exists so the phone learns this before the tap rather than after.

**It has no open-tab prerequisite**, unlike `launchTerminal` below. The spawn is the host's own,
and the session is marked *unplaced* (`server/session/registry.ts`), which is how a session
nobody's browser asked for gets a cell: a desktop grid that is already on screen adopts it within
the moment, and one that isn't picks it up the next time it loads.

The same mark is what puts the session in **`listTerminalSessions`** before any of that happens.
That list is the grid's cells, and a session joins that set on a browser attach and in no other
way — so until #1184 a session the phone had just started was absent from the phone's own list,
and the work it began was findable only by holding on to the returned `sessionId`. It now answers
with cells **and** the sessions on their way to being one, which is the same set one moment later.
This covers `startChat` too, which had the same hole.

### `TerminalSessionSummary`

```ts
{ id: string; title: string; cwd: string; live: boolean; agent: "claude" | "codex" | "shell" | null; iconId?: string }
```

`live: false` means the session exists only in tmux — it outlived a restart. Still viewable
(`capture-pane` doesn't need our process) but **not writable**, and `agent` is then `null`
because the process that knew what it was launched with is gone.

The list is filtered to **grid cells, plus the sessions waiting to become one** — a chat or an
issue the phone started that no browser has adopted yet (`isPhoneListableSession`). A tmux shell
that was never a cell and that nobody spawned for one is excluded, even while live.

### `icons` — the directory images, beside the list (#1556)

The picture a project marks its cells with (`icon` in `.mulmoterminal.json`, or its detected
favicon) travels **inside the reply**: the browser fetches `/api/dir-icon?cwd=…`, and the phone
has no route to this host at all. An `http(s)` or `data:` icon is passed through as written; a
file inside the project is read and inlined as `data:<mime>;base64,…`.

A row names its image by `iconId`, which is a **content hash** — the six clones of one repository
send those bytes once between them. Absent means the phone draws what it drew before: no icon
configured, a file that could not be read, or the budget below.

Two caps, and the second is the one that matters. A single image over **48 KiB** is left out (the
largest real favicon measured on the author's machine was 25.9 KB). And the `icons` table stops at
**256 KiB** of src — because the reply is a Firestore command doc, which rejects the WHOLE
document over 1 MiB, so an unbounded pile would empty the phone's list rather than dim one row
(the #1042 failure). Icons are packed in the order the phone shows the rows, so a budget that runs
out costs the bottom of the list.

### `SessionScreen`

```ts
{
  screen: string;            // the rendered terminal, trailing blank lines trimmed
  suggestion: string;        // the agent's dim ghost text, "" when none (#563)
  quickCommands: { label, text }[];   // the user's saved phrases for THIS session (#830)
  cwd?: string;              // ─┐
  branch?: string;           //  │ absent when the host cannot answer —
  memo?: string;             //  │ see "absent vs empty" below (#786)
  summary?: string;          //  │
  prompt?: string;           //  │
  icon?: string;             //  │ the dir's image as an <img src> (#1556)
  githubUrl?: string;        // ─┘ the repository ROOT, never /tree/<branch> (#832)
}
```

`icon` is the src itself, not an `iconId`: one screen carries one image, so there is nothing to
deduplicate it against. Same two sources and the same per-image cap as the list above.

**`memo` and `summary` are two different sentences, not two spellings of one** (#1110). `memo` is
the one line the user typed about the session (#1084); `summary` is what the AI called it. The
picker's `title` carries the memo *instead of* the AI title because a row there is a single line
and the user's words win — but a header has room for both, so here they arrive as their own fields
and the memo is drawn above. Putting a handwritten note into a row labelled as the AI's summary
would mislabel it.

## The rules that keep coming up

**`SessionScreen` is the wire shape.** A field added to it reaches the phone without touching
the handler — that is stated in the handler's own comment and is the intended extension
point. Adding a command is the exception, not the rule.

**The host decides; the phone renders.** `quickCommands` is filtered to the session's kind
host-side and the `agents` scope is stripped, so the phone needs no notion of session kinds.
Same shape as `githubUrl`: the phone's whole rule is "render it if it's there."

**Absent vs empty is load-bearing.** `definedScreenMeta` drops a meta field the host can't
answer, key and all: a Firestore command doc rejects `undefined`, and the phone renders each
field it receives as its own labelled row, so `""` would read as "this session has no branch"
rather than "not known". **It calls `.trim()` on every value, so only strings may live in
`SessionScreenMeta`** — `quickCommands` is an array and sits on `SessionScreen` directly,
always present, `[]` when nothing applies.

**The phone never sends a path.** `launchTerminal` takes a session id and the host looks the
directory up (`ptys.get(id)?.cwd`). `startIssueWork` takes `owner/repo` and the host looks up the
clone recorded for it. A path parameter would let a remote client choose where a process starts.
Apply this to anything new that touches the filesystem — including when the host would then have
to refuse, which is the honest answer and not a gap to close by accepting the path.

**Authorization is the connected Firebase account, with no per-command gate.**
`sendTerminalInput` already types arbitrary text into a Claude session, which can run anything,
so a command that acts within a session the phone can already drive is not an escalation. A
command that reaches *outside* one would be, and needs its own thinking.

## `launchTerminal` has a prerequisite the others don't

The grid is **browser state**. `markDevTerminalSession()` is reached only from
`server/routes/ws-routes.ts` — "the single choke point for every grid attach" — so the host
cannot open a cell itself. It publishes to the `launch-terminal` pub/sub channel and a
connected browser opens it through the ordinary path.

Consequences the phone has to live with:

- **A MulmoTerminal tab must be open on the desktop.** With none subscribed the host refuses
  with `no MulmoTerminal browser is open — the grid opens the terminal, so a tab must be
  connected`. It does not fail silently.
- Delivery goes to **one** subscriber (`publishToOne`), not the room: a broadcast would open a
  terminal per open tab.
- `agent` is `"shell" | "claude" | "codex"` (`common/launchAgent.ts`) — deliberately not the
  user's configured `launchers`, which are arbitrary commands.

This is about opening a cell for a session that already exists, not about starting one: a command
that SPAWNS can mark its session unplaced and let a grid adopt it later, which is what
`startChat` and `startIssueWork` do. Nothing has to be open for those.

## Where the pieces are

| Concern | File |
|---|---|
| Command handlers | `server/backends/remoteHost/handlers/` (one file per command; `index.ts` is the table) |
| Session list + screen assembly | `server/backends/remoteHost/terminalScreen.ts` |
| Typing into a session (sanitize, bracketed paste, Enter timing) | `server/backends/remoteHost/terminalInput.ts` |
| Quick-command scoping | `server/backends/remoteHost/quickCommands.ts` |
| Launch validation | `server/backends/remoteHost/launchTerminal.ts` |
| Issue work (list, refuse, start) | `server/backends/remoteHost/handlers/issueWork.ts`, `server/git/issue-work.ts` |
| Whether the seed is typed or typed-and-run | `server/session/issue-spawn-options.ts` |
| Which clone a repo starts in | `server/git/repo-dirs.ts`, `common/issueStartPlan.ts` |
| Reconnect + health | `server/backends/remoteHost/resilientRunner.ts`, `healthNotice.ts` |
| Wiring (PTY table, pub/sub, config) | `server/index.ts` |
| Shared with the UI | `common/sessionAgent.ts`, `common/quickCommands.ts`, `common/launchAgent.ts` |

## Related

`docs/spawn-architecture.md` (how a session is spawned), `docs/terminal-notes.md` (the terminal
stack). Issues: #435, #445, #563, #572, #781, #786, #823, #830, #831, #832, #1184.
