Skip to content

Latest commit

 

History

History
977 lines (841 loc) · 103 KB

imessage.md

File metadata and controls

977 lines (841 loc) · 103 KB

iMessage bridge

The iMessage bridge lets the owner talk to Firstmate from a phone. It is a small Bun service, fm-imessage@<slot>.service (behind the front unit fm-imessage.service, see Rolling deploys), that uses one or both of two transports for the iMessage line: a Photon Spectrum project (the default), and self-hosted BlueBubbles relays on Macs that you control. It is off by default.

What it does

For each message from the owner, the service does these steps:

  1. It turns the message into text. A threaded reply, a message effect, or a group of messages is unwrapped, so nothing that he writes is lost. A threaded reply also names the text that it replies to. A message kind that the service does not know becomes a note that names the kind. Tapbacks, typing, read receipts, unsends, and chat changes are only signals, and the service ignores them.
  2. For a supported desk, it starts reading fm-inbox.sh status before saving the message. It saves the full input and a stable request id in the private routing.jsonl journal before transport acknowledgement. It also appends the owner text to desk memory. It keeps authored text separate from quoted thread metadata for action and social checks.
  3. It keeps the text unread. It marks the text as read only when Firstmate or a mate takes it, and checks every 5 seconds. Firstmate takes it when its inbox note is acknowledged, including a desk task note. A mate takes it when the delivered message moves to the mate's handled folder. Arrival, a desk answer, and a desk acknowledgement do not mark it read. A failed read mark is tried again. The service keeps the last 100 pending texts in memory, so a text taken after a restart is not marked read.
  4. It combines text until 350 milliseconds of silence or 2 seconds from the first text, whichever comes first. This cap limits the batching wait, not status retrieval, model work, or delivery. Each new text cancels a running draft, resets the quiet timer, and starts a fresh status read. Text received during a draft starts a new batching wait; the next draft includes every pending message. The desk uses the latest status result after the wait without reading it again. Slow message persistence can make the status snapshot older than the batching wait. Attachments, unsupported content, group chats, and edits go to Firstmate without a model answer. A desk other than omp with claude-haiku-5-5 or inception with mercury-3 also sends every request to Firstmate.
  5. The desk model classifies the whole pending burst. The default is Claude Haiku 5.5 with thinking off, through the existing one-shot omp -p runner. Both providers use the existing omp -p tool runtime for classification. desk_provider: inception selects inception/<desk_model> without changing the provider or configured reasoning level. Inception needs an explicit desk_reasoning of instant, low, medium, or high; instant is valid only with Inception. A native provider hook preserves Inception's instant wire value; other reasoning levels pass through unchanged. A failed Inception call never falls back to another provider; requests follow the second-mate handoff rules. The Inception voice pass still uses its tool-free chat-completions request and instant reasoning. The runtime has native read, grep, glob, bash, and write tools, plus the existing zoom extension. desk-tools.yml enables secret masking; a native extension checks every write against the current owner's authorized target. Before evidence validation and the voice call, the extension uses native filters to remove credentials from replies and tool results. The bridge rejects server-tool output without this filtered result. Each classification call with server tools uses a private temporary directory with a native secret file for parent-known credentials. This includes configured relay passwords and connection passwords without adding them to the tool environment. The service removes the directory when the call exits. Automatic writes require one explicit write, set, or replace request with one absolute file path and an existing parent directory. The target is resolved before the call and checked again before writing, including symbolic links. The native-normalized destination must resolve to that same authorized target; normalization cannot select a different file. Empty writes and unapproved targets are blocked; retrieved content cannot add a target. A shebang write requires approval if the native writer would add execute permissions, including when it creates a file. Version-controlled projects, registered mate homes, and known credential stores are not automatic write targets. Direct reads stay on the local filesystem and local process or artifact interfaces. The guard decodes native file URLs before checking each target from quote removal and delimiter expansion. Network reads require approval. Shell commands still require native approval, except the listed system reads. The automatic service check is exactly systemctl --user is-active -- fm-imessage.service; other service commands require approval. The child receives only its provider credentials and required runtime variables, not transport credentials. Native tools retain XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS so local user-service inspection can connect to the existing bus. Its input combines desk memory, durable fm-inbox.sh status output, and pending messages. Reading status does not request a supervisor answer. The desk refreshes typing every four seconds during the model call, delivery lookups, queue waits, and retries. It stops when the transport accepts the first response bubble or the reaction, or when the response is cancelled, handed off, or discarded. The service stops refreshes before saving delivery progress, even if that save fails. A desk turn that runs past 150 seconds is aborted. If it is still current and no item has routed yet, it is handed off to Firstmate and the owner gets one short delay reply. A front model timeout has its own one-line ack; see V2 front desk. Shutdown stops typing and allows two seconds for pending cleanup. Supervisor typing starts after desk cleanup. Command typing has an eight-second expiry timer; another typing command renews it. The response-deadline callback stops typing only for its associated desk turn. A burst across different chats shows no typing because the model has not yet selected its response target. Informational noun phrases, including approval counts, reach this classifier after receipt or recovery.
  6. When not muted, a clear basic question can receive one direct answer without a Firstmate note or wake. The internal draft must equal complete evidence lines from status, prior supervisor or desk records, or successful current tool results. Model-identity evidence names the desk or supervisor and must match that subject's full configured model name. An unknown supervisor identity requires a handoff. The service checks every quote against its source and rejects conflicting draft text, missing evidence, and work claims. After those checks, the existing voice pass uses the original question and validated draft to write a short plain-text answer. It explains configuration values in context and preserves exact values, links, identifiers, and commands. The draft and question remain untrusted data; neither grants action authority. The native result envelope masks credentials in the original question before it reaches the voice pass. Before checking evidence, it also maps native credential placeholders and source credentials to the same [REDACTED] marker. The prompt requires every fact, name, number, link, subject, negation, and claim to stay unchanged. The service rejects changed protected tokens or polarity, empty replies, failed calls, work claims, and unrequested Markdown fences. Without an explicit exact-output request, the reply check rejects headings, bullets, pipe-style tables, configuration rows, bare source values, and replies that parse as JSON. A retained configuration row still causes rejection when other lines contain prose. Unrequested inline code also causes rejection; the service does not strip its formatting. The same rejection applies to blockquotes, single-underscore emphasis, and Markdown links; bare URLs and identifiers remain valid. An explanation containing “exactly” does not authorize raw output. Explicit output noun phrases and quote or copy requests can authorize it; ambiguous requests cannot. The voice prompt uses the reply check's exact-output decision to select quotation or prose instructions before the model call. Prose instructions retain descriptions after source labels; quotation instructions retain the full source line, including bullets and emphasis. Exact quotations must match the source character for character, including indentation and whitespace. The service sends accepted exact output without trimming it. Exact output over 1,000 characters splits at a line, then a sentence, then a word, and the service drops whitespace-only pieces. Normal prose still uses paragraph bubbles. The same rules apply when a request spans lines or combines several messages. Only supported formatting continuations, such as “One per line.”, retain exact-output permission. Any other unconsumed or conflicting instruction selects the normal prose checks. Validated model-identity replies can retain the configured model name alone. All source-backed answers can use up to 2,000 characters so bare values can become useful sentences. Protected tokens include identifiers, paths, #numbers, versions, numbers with units, comparison operators, and links; their text and case must match. Source comparison normalizes configuration separators and paired inline Markdown delimiters; it never changes delivered characters. Rewrites retain standalone scalar values, configuration field values, and the contents of inline code exactly. Prose configuration replies must keep named fields directly paired with their source values; unverifiable paraphrases require a handoff. Source indentation, dash markers, and blockquote markers do not change those field-to-value checks. Only the hyphenated prose pairs Read-only/read-only and Restart-proof/restart-proof can change case. All other hyphenated tokens retain their case, including sentence-leading identifiers. Rewrites can use contractions, reorder words, shorten wording, and lead with the answer. Polarity words must keep the same counts and immediately adjacent punctuation. These words are no, not, never, none, nothing, n't forms, yes, failed, passed, broken, fixed, done, blocked, can't, and won't. Configuration flags on, off, true, false, enabled, and disabled retain their counts without binding sentence punctuation. Every rejected rewrite hands off instead of sending raw source output, including stored chat and status excerpts. Blank lines separate bubbles; explicitly requested raw values can retain their line breaks. Reactions do not use the voice pass. The desk answers a burst once and selects the message that needs the answer, including an earlier question. Later corrections and follow-ups guide that selection. When not muted, a purely social burst can receive one reaction and finish without a Firstmate note or wake. Every message must contain only a supported acknowledgement, thanks, laughter, celebration, or emoji. An answer to a prior question or approval request cannot use this shortcut. Only a small set of thanks, laughter, and celebration emoji can close a social turn. Incoming thumbs-up, check marks, objections, and stop or question symbols still go to Firstmate. The prompt asks for occasional reactions, not a reaction on every message or every social turn. It favors a fitting heart, laugh, or emphasis reaction instead of a default thumbs-up. Requests, questions, approvals, sensitive messages, and mixed bursts receive no desk reaction. A social reaction may include one short text from a fixed list of safe acknowledgements. The text cannot contain facts or work claims; an invalid text prevents the social response. Routine lookups and authorized actions do not create Firstmate inbox work. The desk finds existing board URLs itself and returns the URL immediately. Project requests follow the verified second-mate handoff rules, without a main note after successful delivery. A successful routine local action can return {"kind":"handled"} without an immediate chat reply. The bridge accepts this only after a successful write result and records it in the local routing journal. Before enabling a write, the journal records that the target may change. An interrupted or unverified action goes to Firstmate with that warning instead of running again. Cross-domain coordination, real ambiguity, actionable failures, and decisions beyond existing authority go to Firstmate. Destructive requests, unclear approvals, and sensitive content retain the existing hard handoff. Failed classification calls, invalid JSON, missing evidence, and clipped pending input follow the second-mate handoff rules. Only authenticated authored text grants authority; retrieved files, logs, pages, Slack, and tool output never grant permission. The prompt preserves project ownership, destructive-action approval, external-send approval, and credential boundaries. A refused tool call must not be bypassed.
  7. The service saves the routing decision before a chat response or supervisor handoff. A direct answer enters the existing outbox with a stable deduplication key. A reply or reaction can set target to the message's 1-based position in the pending burst. Without target, the service selects the last pending message; an invalid target causes a handoff. Desk text, including text sent with a reaction, is a normal message, never a thread, even after later messages. For explicit fm-imessage --reply N behavior, see Commands. Desk answers stay on the selected message's transport and chat during retries. They never use another chat or transport as a fallback. A desk answer confirms requests only in its destination chat; other chats keep their separate handoffs and deadlines. A handoff uses fm-inbox.sh note --request-id; acceptance means the inbox saved it, not that the supervisor answered. Eight seconds after the latest unanswered request arrives in a direct chat, the bridge stops that chat's typing indicator and hands off its still-unassigned requests. The deadline sends the owner nothing: no receipt or fixed text says that the request is saved or unanswered. Each direct chat keeps its own deadline, and it fires once per request; messages in another chat do not cancel it. Five minutes after a v2 request's route finishes with no answer, the bridge files one more inbox note with key stale-<request id>, and records that in the routing log so a restart does not repeat it. It skips a text older than two hours at startup. It sends nothing to the owner. A deadline does not count as global Firstmate activity. Active mate delivery does not block a due deadline or keep that chat's typing indicator active. A prompt answer leaves nothing to hand off; typing alone does not. A queued supervisor response ends the wait only after the transport accepts that response. An explicit --reply N confirms only its selected message and that message's current edit requests, even with --no-thread. A send without --reply confirms only the owner texts that arrived before it was queued. A secondmate's text and a tapback confirm none. Each queued response stores its covered request ids; later arrivals cannot become covered through equal timestamps. The deadline applies to the latest unanswered request, even when a newer request already has a targeted answer. An accepted initial card counts as a response, like an accepted text. Restart replay confirms an initial card from its saved acceptance; a later card edit does not confirm newer requests. A checked desk answer already in the outbox keeps its place at the deadline. Purely social bursts and muted chats have no deadline handoff. Inbox failures retry from the durable journal; each inbox command has a five-second deadline. Named keep: synchronous inbox filing remains serial; two stalled commands can block the event loop for about ten seconds. This can delay the deadline and typing cleanup beyond eight seconds; their timers resume when filing returns. The five-second limit applies to each command, not the entire batch. The local regression test checks the eventual handoff and typing cleanup after two command timeouts. Firstmate activity cancels a draft and hands pending requests to Firstmate. A new owner text or Firstmate activity also stops the remaining bubbles of a queued desk answer. The service records already sent bubbles in memory and completes the cancelled answer without sending the remaining bubbles. Startup drops restored model answers that have unsent text bubbles and hands their requests to Firstmate, without waiting for inbound catch-up. Firstmate and service sends keep their durable recovery. Each transport checks cancellation after preparation and immediately before sending. Cancellation does not remove uncertain delivery records. For a live desk answer with an uncertain send, the service checks available transport history before cancellation. It keeps unresolved evidence in the completion receipt. Unavailable history cannot block later supervisor sends after cancellation. A history check that disproves delivery clears the uncertainty before the service decides whether to hand off.

Neither transport reports typing events from the owner, so only a new text starts the wait again.

When the owner edits a text on the Photon line, the service files [edited] <new text> (was: <old text>). The note wakes Firstmate. Edits of known messages keep the original chat reference, even when the raw event uses a different chat identifier. The service keeps his last 100 texts in memory for the old text. If it does not know the old text, the note says so. This can happen when the text came before the last restart. spectrum-ts does not pass edits on, so the service reads them from its own event stream on the line's client. An edit that the owner makes while that stream is down is lost. The BlueBubbles line does not report edits: an edit there files no note.

The classification runner has a 45-second process timeout, but the unanswered-response deadline can cancel it sooner. The voice pass uses the same desk model, no tools, and a separate five-second timeout. It disables omp reasoning and uses Inception's instant reasoning level. New messages and Firstmate activity cancel this pass through the same path as classification. The voice call receives the original question and checked draft, not chat history or unvalidated fleet status. The durable status reader has a separate 5-second timeout; a timeout causes a handoff. A completed desk turn records its outcome in the private desk.log, including handled and successful tool-call ids. The log also records read completion, typing start and stop, model start and result, inbox acceptance, and transport acceptance. These events contain timestamps and message ids, not model output or credentials. Typing events retain the originating message id when a newer message arrives before the transport operation completes. transport-accepted records successful transport completion before delivery-progress or response-confirmation writes. response-confirmed records routing confirmation and can repeat during recovery without another send. history-reconciled records a delivery found in transport history, not a new send. card-update-accepted records an accepted card edit separately from initial card delivery. Transport acceptance is not proof of user-visible delivery; verify the receiving device for live delivery checks.

For a standalone text, both desks accept classification answers only from one complete JSON object whose kind or decision is reply. The answer must be a non-empty string with no directive. iMessage checks the existing source evidence before passing that answer to the voice call. An action can occupy the whole trimmed output, any JSON kind or decision field, or the final line after prose. Recognized actions are REACT:<emoji>, TAPBACK:<emoji>, SKIP, HANDOFF, and the verified iMessage HANDLED result. A JSON tapback uses {"kind":"tapback","emoji":"👍"}. An action never sends the surrounding prose. A JSON iMessage tapback may include text, for example {"kind":"tapback","emoji":"❤️","text":"nice!"}. The allowed text is thanks, thank you, you're welcome, anytime, haha, nice, congrats, or love it. Capitalization may vary, and one final period or exclamation mark is allowed. Only a verified social burst can use this text, which shares the reaction's target and durable outbox item. The Instagram desk ignores this optional text. The parser validates the complete emoji; quotes, braces, and extra characters cannot become part of it. Repeated matching decision fields produce one action, while conflicts produce no direct model response. All other classification output, including plain prose, mixed prose and JSON, and malformed JSON, produces no direct model response. Each rejected draft records one short outcome without raw model text. Suppressed drafts and rejected non-social reactions retain the existing single request filing, without an extra operator request. Social reactions retain all original messages and the completion decision in the routing journal, without filing a request. Photon, BlueBubbles, and the Instagram desk share this parser.

The service replays unfinished routing after a restart. Completed routing records suppress repeated transport delivery. Keyed outbox receipts in outbox-done/ prevent a decided answer from entering the outbox twice after a crash. The journal stores response confirmations separately from routing completion. The outbox saves accepted bubble and reaction progress before recording response confirmation. If a routing-journal write or sync fails, retries confirm that saved progress without sending it again. Transport acceptance stops the desk's typing indicator before those writes. Restart recovery schedules the deadline for unresolved requests with saved receipt timestamps, regardless of their age. If no response bubble was delivered and the desk response is definitely undeliverable, the saved burst ids cause one stable-id Firstmate handoff. Dead-letter receipts recover that handoff if the bridge stops before its failure callback completes. A prior uncertain send or reaction blocks this failure handoff, except when startup drops restored desk text as described above. The outbox saves possible reaction delivery after all lookups, health checks, and capability checks, immediately before the mutation. It retains that state across retries and restarts. Read-only preparation does not add delivery uncertainty. It does not clear uncertainty from an earlier reaction attempt. For a combined social response, the service saves text delivery before attempting the reaction. Reaction retries and restarts do not repeat the text or hand it to Firstmate after delivery. Text deliveries use a stable outbox identity in memory, so restart recovery records each delivered reply once. This also recovers a reply when the bridge stops after saving delivery progress but before recording memory. Keep the journal, receipts, memory, downloads, and pending outbox together during backups or migration.

The basic-answer system prompt is deskPrompt in apps/imessage/desk.ts. The owner's name and the two model names come from the config.

The service ignores texts from all other senders. It saves attachments in ~/.local/state/fm-imessage/attachments/ for Firstmate to open. That directory is private to the account (mode 0700). The service also keeps the owner's latest text on each transport, with the time of the message (its own timestamp, or the time received when it has none), in ~/.local/state/fm-imessage/latest (and latest-<transport>). After a restart, typing and command tapbacks target the newest saved text. A file from before the time was kept counts as the oldest. The service replaces latest-message files atomically, including the direct-chat destinations described in Choose the transports.

Second-mate handoffs

The desk reads $FM_HOME/data/secondmates.md at runtime. Second-mate routing stays off when this file is missing, unreadable, empty, or contains an invalid entry. Single-home installs keep their existing behavior. Each entry uses the existing registry format:

- web-mate - Website owner (home: /srv/website-home; scope: Website pages and browser layout; projects: website; added 2026-10-23)

Each mate needs a unique id, an absolute home path, and a non-empty scope. The desk can make a separate model call to select a handoff target from these ids or firstmate. A failed or rejected basic-answer draft can still reach this call. Failed status reads or clipped handoff input go directly to Firstmate. The desk sends work to a mate only when the model reports high confidence and exactly one matching domain. It checks the whole pending burst, including recent context, rather than splitting related texts across mates. Invalid output, low confidence, ambiguous requests, and work across several domains go to Firstmate. Decisions, approvals, merges, destructive actions, external sends, credentials, and sensitive messages also stay with Firstmate. Before the desk sends a text to a mate, it looks in the newest 200 records of state/<mate>.inbox (open and handled/). A record with the line [desk request <request id>] counts as delivered, so the desk sends nothing and writes no note. Identical texts from different requests do not match. After a failed send, the desk writes one "may be incomplete" note for each message id, not one for each retry. Attachments, group chats, edits, and unsupported desk models keep their existing Firstmate handoff.

The sender is fm-send.sh, beside the resolved fm-inbox.sh executable, including an FM_INBOX_CMD override. The service sets FM_HOME to the main home that owns the second mates as tasks and holds the routing table. It passes the mate id and [from captain via desk] <verbatim text> followed by a final line [desk request <request id>] as separate arguments, without a shell. The sender uses the mate's existing inbox and normal acknowledgement recovery. The desk checks the registered id, home, and complete scope before each send. A missing or changed entry sends the remaining texts to Firstmate.

A successful delivery records routed to <mate-id> in the local journal and completes without a main inbox note. A failed sender, missing command, or five-second delivery timeout hands the request to Firstmate. The journal saves delivery uncertainty before calling the sender. The existing sender retains acknowledgement tracking; recovery never blindly repeats an uncertain send. A failed or interrupted send retains a warning so Firstmate checks the mate's inbox before sending again. The sender runs asynchronously, so inbound messages and Firstmate commands remain responsive during delivery. New owner text, Firstmate activity, and shutdown cancel the active sender and stop the remaining sends in that burst. Inbox retries and service restarts never repeat a recorded mate delivery.

Firstmate activity still cancels an unfinished routing decision and sends pending work to Firstmate. Direct short answers and social reactions stay unchanged, including evidence-backed questions about approval counts when a registry exists. Delayed handoffs follow the deadline rules.

V2 front desk

Set crewship.imessage.desk_v2: true in .local/host.yml to select the V2 front desk. The setting defaults to false; omit it or set it to false to keep the legacy loop for rollback. The service renders this setting as FM_IMESSAGE_DESK_V2=true or false. With the default desk_session: true, the desk runs as one long omp session instead of the loop below. See Desk session. The code is apps/imessage/front.ts, dispatch-policy.ts, and subagent.ts. Deployment and a one-week trial require separate authorization. The front picks one of four results: social, status, dispatch, or relay. A purely social text gets one tapback and no text. Status answers a question about what the mates are doing. The desk reads the inbox status and each mate's last line from state/<mate>.status, and answers in one tool-free call, without the routing reply. It hands the text to Firstmate when the request is a decision, approval, money, deploy, public post or write, when the status text does not answer it, or when the call fails. Dispatch runs a subagent, and its reply goes out as written, threaded to the message it answers. Relay hands the work to the owning mate or to Firstmate. An invalid front answer fails closed to a Firstmate relay. Before relay or dispatch work starts, the desk sends no fixed receipt. The one exception is a front model timeout: the desk then sends one short honest line at once, once for each burst of texts in a chat, and the next desk reply in that chat starts a new burst. The 150-second turn limit and the desk-tasks/ sweep stay as backup. The typing indicator stays on until the real answer goes out. The typing indicator stops when a newer text replaces the running job.

Token log

Every desk and compaction call appends one line to tokens.jsonl in the state directory: {ts, kind, model, input_tokens, output_tokens}, with the counts the provider returned. kind is front, status, desk, desk-session, voice, or compaction. tokens-daily.log holds one line per day with the call count and the token totals. A call whose provider returns no counts adds no line. A failed write is logged and never fails the call.

Desk session

With desk_v2: true, the desk is one long omp session by default. Set crewship.imessage.desk_session: false to roll back to the one-shot V2 loop above. The setting has no effect without desk_v2. The service renders this setting as FM_IMESSAGE_DESK_SESSION=true or false. The code is apps/imessage/desk-session.ts, desk-session-turn.ts, desk-actions.ts, desk-delegate.ts, and desk-helper.ts.

  • The bridge runs one persistent omp RPC child. The model and the reasoning level come from the desk settings.
  • The child keeps its session in the state directory. After a crash or a service restart, it resumes the same session with --continue.
  • When the child stops, the bridge starts it again with backoff: 1 second first, doubled each time, to a maximum of 60 seconds.
  • omp compacts the session at a fixed 80,000 tokens. The bridge sets this trigger in compaction.modelThresholds.
  • The memory view seeds a new session and a session that was just compacted.
  • The session can read with read, grep, glob, zoom, and fleet_status. It has no write tool. An install ask goes to its helper (see Helper below); it hands every other write to Firstmate.
  • The session acts through five action tools. The bridge checks each call before it does the action:
    • reply sends text only when deskMayAct permits it, after clean.ts cleans the text.
    • tapback reacts only when deskMayAct permits it.
    • relay_mate relays only while relayTargetCurrent confirms that the mate still owns the work.
    • handoff_main gives the text to Firstmate. With text, it first sends the owner that short ack, checked like reply. Firstmate then takes the text, and no background task, wait or follow-up text follows. The desk uses this for an owner instruction or standing rule: it acks it and never argues with it.
    • delegate_task starts background work, sends the owner the bridge's own queued text, and ends the turn. See Background tasks.
  • A text on a protected topic goes to Firstmate before any model call. A muted chat gets no action.
  • The desk sends no fixed routing receipt bubble, except the one-line ack after a front model timeout and the "queued" line of delegate_task.
  • message_mate sends a listed mate a text of its own through fm-send.sh and does not end the turn; mate_replies reads that mate's newest status lines, where its marked replies arrive. reply and handoff_main take an optional key: a key already sent with the same content today is refused, so the desk does not repeat news the owner has.
  • fleet_status lists the second mates in data/secondmates.md with their scopes, so the session picks a real mate id for relay_mate and delegate_task.
  • A turn that ends with no action tool gets one reminder to use reply. If the model still completes in words, the bridge sends those words through the reply checks. Words that the checks refuse go to Firstmate. A turn that errors or aborts goes to Firstmate with no reminder. omp RPC cannot force a tool call, so the desk does not rely on one.
  • Each assistant message adds one desk-session line to the token log.
  • A thread reply to a bubble that a second mate sent goes back to that mate, with no model turn. fm-imessage sends the header X-Fm-Sender from FM_MATE_ID, else from FM_HOME. The bridge keeps the sender of each of the last 200 bubbles in senders.json in the state directory. When the transport gives the sent message an id, a reply that names that id gets that sender. Otherwise the start of the text decides, and only when every recent bubble with that start has the same sender. A desk bubble counts as a sender, so a text that a mate and the desk both sent goes to the desk. A home path or id in data/secondmates.md maps the sender to the mate. The same protected-topic, mute and relayTargetCurrent checks apply. An unknown or main sender, or two senders with the same text and no id, gives the normal desk turn.

If the missed chat since the last session turn is longer than 8,000 characters, the session gets the newest lines and drops the oldest.

Background tasks

The desk can answer the owner at once while the work goes on in the background. The session calls delegate_task with the target helper:install, helper:lookup, helper:read-link, firstmate or the id of one mate in data/secondmates.md, and with no text: the model writes none. The bridge records the owner's texts as answered at once, sends the owner "Your text is queued for Firstmate" or "Your text is queued for the mate", and then starts one background job. The ack never says that a send happened, and a new owner text during the send cannot start the same task again.

  • The bridge sends the owner's verbatim text to the target. The model never writes the task.
  • deskMayAct applies to every target, firstmate too. A protected request goes to Firstmate with handoff_main, once.
  • A mate target needs relayTargetCurrent, as relay_mate does. Any other target, except the three helper kinds, is refused before anything is sent.
  • firstmate gets an inbox note and a mate gets fm-send.sh. Both start with [from captain via desk].
  • The task text ends with a path in desk-tasks/ in the state directory. The target writes its answer for the owner to that file. This is the only answer channel.
  • The bridge also saves a task file with the same id in desk-tasks/. It holds the owner's text and the chat of the request.
  • The bridge waits for the answer file for 10 minutes. A restart ends the wait, but not the task.
  • A sweep checks desk-tasks/ when the bridge starts and then every half second. It sends each answer that has no waiting job, for example an answer that came while the bridge was down or after the 10 minutes. It sends each answer once to the chat in its task file, then removes both files.
  • The answer goes through the same checks as reply: clean.ts, the raw status line check, and the secret filter. A refused answer is logged and not sent.
  • A muted desk drops the answer. The answer goes to the chat of the request, in the same outbox as other desk texts.
  • A task to a mate carries the line [desk request <id>]. A send that fm-send.sh reports as failed, for example one killed after 5 seconds, still counts as delivered when the mate's inbox holds that line.
  • A task is lost when the target cannot receive it, when no answer comes in 10 minutes, or when the answer is refused. For a mate task with no answer in 10 minutes, the bridge first sends the mate the owner's text again (one retry); Firstmate then gets one short note that says so, so a dead mate cannot hold the text. For every other lost task, and when both sends to the mate fail, Firstmate gets a new inbox note with the owner's text. The note says the desk stopped waiting and that the target must answer the owner directly. For a refused answer, the note also holds that answer. After that the bridge sends the owner one fixed text: "Still with Firstmate; you will hear back", or "Still with the mate; you will hear back" when the mate took the text. If no inbox takes the note, the bridge only logs it. The owner gets "I could not deliver your text to anyone. Send it again" only when the task itself never reached an inbox (the first send failed, or the helper failed, and Firstmate's inbox did not take the note either). A muted desk sends no text.
  • A task report is a late answer, so a new owner text does not cancel it. Only a muted desk does.
  • If a new owner text cancels the reply before it is sent, the bridge does not hand the first text to Firstmate again. The task already holds it, so Firstmate or the mate gets it once.

Helper

The desk handles a simple, self-contained ask itself: install a skill or tool, look something up, read a link. The desk model classifies each work ask as install, lookup, read-link or other. Only those three kinds go to a helper; the session calls delegate_task with the target helper:install, helper:lookup or helper:read-link and no text. Any other ask, including one that sends, posts, emails, replies, pays or deploys, goes to Firstmate. The bridge texts the owner at once that the text is queued.

  • The bridge starts one omp -p run with the desk model and no approval prompts. An install helper has read, grep, glob and bash. A lookup helper has read, grep, glob and web_search. A read-link helper has read, grep and glob. No helper has write. Lookup and read-link helpers read untrusted web content, so they have no bash. The run starts in a fresh scratch directory under desk-helper/ in the state directory, with the same secret redaction as the desk session.
  • The helper environment is PATH, HOME and the desk model's own key only. It has no FM_HOME, no super.env and no other provider key.
  • The desk never reads a link or looks something up on the web itself. If its own read is refused, the prompt sends the ask to the helper (helper:read-link or helper:lookup) and not to Firstmate.
  • The helper gets the owner's verbatim text. Its last message is the answer. It goes through the same checks as reply, like any task answer.
  • One helper runs at a time. A second ask while it runs is refused, so the session hands it to Firstmate. A run has a 10-minute limit.
  • The bridge refuses a helper target that is not one of the three kinds, a protected request (deskMayAct), and text with a money, send or post word. This regex is only an extra deny: the allowlist of kinds is the rule, and the desk model's classification decides it. Refused asks go to Firstmate with handoff_main, as do approvals, decisions, deploys, credentials and work for several mates.
  • If the helper fails, times out, or its answer is refused, Firstmate gets an inbox note with the owner's text and the owner gets the fixed text "Still with Firstmate; you will hear back".
  • The log shows desk helper: started (<kind>), then answered or failed. A bridge restart ends a running helper and sends no answer.
  • Residual risk: an install helper runs bash with no approval prompt and the user's HOME, so a page or package it reads could still steer it to run commands in that account. The narrow environment keeps FM_HOME and other keys out, but not the files under HOME or the desk model's own key. Only an install ask reaches bash, and the helper prompt treats pages and command output as untrusted.

The smoke command stays the same: run it with FM_DESK_SMOKE_STRICT=1.

Herdr status alerts

The bridge can text the owner when a worker agent changes status, so the agents do not need to report it themselves. It is off by default. Set crewship.imessage.herdr_alerts: true in .local/host.yml to turn it on. The service renders this setting as FM_IMESSAGE_HERDR_ALERTS=true or false. The code is apps/imessage/herdr-watch.ts. It runs only herdr agent list, which only reads status.

Every poll compares each worker pane with its last confirmed status. A new status counts only when it holds for the debounce time, so a status that flips back is ignored. One poll sends one text, with one sentence for each confirmed change. The sentence names the project and the task and has no pane id. The first poll only records the starting statuses. Supervisor panes are skipped: a pane with a who token, or one whose project folder is in the skip list. When a pane closes while a new status is held but not yet confirmed, the bridge sends the alert for that held status once, if the status is in the alert list.

Variable Default Meaning
FM_IMESSAGE_HERDR_ALERTS false true turns the alerts on; crewship.imessage.herdr_alerts sets it.
FM_IMESSAGE_HERDR_STATUSES done,blocked,idle The new statuses that send an alert. blocked means the agent needs input.
FM_IMESSAGE_HERDR_POLL_MS 15000 Time between polls.
FM_IMESSAGE_HERDR_DEBOUNCE_MS 20000 A new status must hold this long.
FM_IMESSAGE_HERDR_SKIP firstmate Project folders that are not workers.
FM_IMESSAGE_HERDR_BIN herdr The herdr executable.

Guarded front modules

The repository also contains a separate front, worker, dispatch policy, and relay validator. The service uses front.ts from bridge.ts for the V2 loop; it does not call the other modules. These modules do not enable a deployment or replace transport, memory, outbox recovery, or the acknowledgement rules.

  • Front: front.ts calls Mercury-3 directly with low reasoning, no tools, at most two attempts, and one shared 15-second deadline. It returns work proposals, not an executed action or delivery receipt; a social turn returns no intents. Other failures return a Firstmate relay proposal; a 15-second timeout fallback also sets timedOut so the desk can send that line. Caller cancellation throws instead of returning a stale result. loadFrontRedactor loads native masking before the front turn, including configured secrets and parent-known credentials. Each attempt masks all message data, including retry history, without changing protocol fields or the authorization header. Numeric references identify advertised models, reasoning levels, and mates even when masking makes their labels identical. The front resolves these references against the original catalog; unknown references cannot select a model or mate. Original input objects and strings remain unchanged.
  • Dispatch policy: dispatch-policy.ts reads the installed omp catalog, host settings, model overrides, and Firstmate's dispatch file. The caller supplies native credential availability; the loader does not open credential storage, execute credential commands, or refresh tokens. It filters unavailable and disabled providers before resolving enabled model patterns. General work excludes Fable and Mercury; a trusted simple-coding purpose permits Mercury only at high reasoning. Explicit dispatch efforts restrict choices, and Opus reasoning never exceeds high. Selection returns execution and credential fields from the trusted catalog, not model-supplied fields.
  • Worker: subagent.ts reuses the guarded native tools in a private call directory. The caller validates the model choice and journals any authorized write target before starting the worker. The selected worker model cannot change through model fallback, context promotion, or Anthropic server-side fallback. Tool answers select one to eight distinct physical lines from successful results; selections preserve complete lines and quotation whitespace. Only these verified tool selections can retain blank lines; the shared quote validator still rejects blank evidence. A checked answer receives one tool-free Mercury-3 voice pass at low reasoning. Prose must retain source words in order, with only limited connecting words; exact-output rules still apply. Failed or unverified output returns a handoff instead of raw text. Cancellation or stale work returns no result.
  • Relay: relay.ts accepts only eligible, unprotected work for one registered domain. It checks authenticated request restrictions independently of the front proposal. The caller must recheck the registered id, home, and scope before each send through relayTargetCurrent.

The caller still owns durable request identity, routing decisions, delivery, and memory. The caller must check current() again before committing a worker result. The front, dispatch, worker, and relay regressions cover these module boundaries.

Self-healing

The line repairs itself without polling or periodic texts. The code is apps/imessage/selfheal.ts.

  • Watchdog. The unit sets WatchdogSec=300. The bridge sends WATCHDOG=1 through systemd-notify every 60 s, and only when GET /health on the bridge port answers, no desk turn has run longer than 15 minutes (FM_IMESSAGE_TURN_DEADLINE_MS), and the pi-natives .node loads. When a check fails, or the process is stopped or blocked, systemd stops the unit with SIGTERM (SIGKILL after TimeoutStopSec) and restarts it. The desk turn deadline is a backstop beyond the 10-minute delegate wait.
  • Natives. ExecStartPre runs bun selfheal.ts start, and each beat runs the same check. It loads every ~/.omp/natives/<omp version>/*.node in a child process. If the file for this CPU is missing or does not load, it runs omp --help with a scratch HOME, which makes the omp binary unpack its own natives, and copies the result in with an atomic rename. It then imports @oh-my-pi/pi-natives the way the bridge resolves it (often the bun install cache) in a child process. If that fails, it copies the .node files from the sibling pi-natives-<platform>-<arch> package into the package's native/ directory with an atomic rename, and probes again. When the repair cannot make it load, ExecStartPre exits non-zero, so systemd restarts the unit and the failure counts toward the alert.
  • Alert. ~/.local/state/fm-imessage/selfheal.json counts failed heals. A start that never reached a healthy beat counts, and so does a failed natives repair or an OnFailure= give-up. A healthy beat clears the count. The second failure in a row sends one text with fm-imessage if the line answers, else appends one failed line to $FM_IMESSAGE_ALERT_STATUS (default $FM_HOME/state/imessage-selfheal.status). It alerts once per failure run.
  • Give-up. After 5 starts in 10 minutes systemd stops retrying and starts fm-imessage-heal.service, which counts the failure, alerts if due, and starts the active slot again (imessage-deploy.ts boot).

To test a fault on a copy, set STATE_DIRECTORY, HOME, FM_IMESSAGE_SEND=false and FM_IMESSAGE_ALERT_STATUS, then run bun selfheal.ts start.

Rolling deploys

An install never restarts the bridge in place: a new process needs about 100 s before it listens, and a build with broken natives can start while systemd says active. bun ~/.local/bin/imessage-deploy.ts rolls the build in <imessage dir>/stage/ out beside the running one. The Ansible role runs it as its only upgrade step; it is the only call workers and the heal unit make. The code is apps/imessage/deploy.ts, ownership.ts and standby.ts.

  • Slots. The bridge runs as fm-imessage@blue, fm-imessage@green or fm-imessage@legacy (the files from before slots, kept as the rollback target of the first rolling install). The slot named in ~/.local/state/fm-imessage/active owns the line. fm-imessage.service is only the front: its process starts the active slot and exits while that slot is gone, so systemctl --user is-active fm-imessage.service is active only while a slot serves the line (it reads activating while the front waits to restart), and stopping it stops the slots. When the front process exits on its own because the slot is gone, the slots are not stopped, so the slot unit's own restart and start limit run.
  • Standby. A slot that is not active starts, connects the transports and serves only GET /ready and /health on 127.0.0.1:8766 (FM_IMESSAGE_NEXT_PORT). It builds no memory, outbox, routing replay or inbound loop, so it can send nothing.
  • Gate. The deploy passes only when /ready answers: the pi-natives load, one real desk model call with a stub prompt gives a reply (no text is sent), and every configured transport (Photon) is connected. The checks run in order: after the first failure the later ones are skipped, so a build with broken natives makes no paid desk call. A standby slot binds no webhook port and starts no BlueBubbles polling. A failure or a dead unit stops the new slot. A slot that already owns the line (a reboot) needs only the first transport and degrades to the others. The deploy stops a target slot left running before it installs the new build. The serving slot is never touched.
  • Handover. The deploy writes active, then stops the old slot. The new slot takes the exclusive lock owner.lock, waits for the main port, replays routing and the durable outbox, and listens. Two outbox senders cannot run: the lock and the port each allow one owner. Outbox items keep their ids, and keyed dedupe stops a repeat. While the old slot stops, the main port answers 503 stopping for 0.5 s, then drops or refuses connections (curl exit 52, 56 or 7). All of these mean nothing was queued, so fm-imessage sends the request again every second for up to 150 s (FM_IMESSAGE_WAIT_S). A handover or a rollback costs no text, and a retry cannot repeat one. Any other failure ends the command at once.
  • Confirm. POST /probe queues one item in its own outbox lane, so a retrying text never holds it; its delivery reads the owner's latest text back from the transport and sends no text. With no latest text known the probe fails. If the new slot does not answer /health or the probe in time, the deploy stops it, sets active back and starts the old slot again (about 100 s). The old slot is stopped during the handover, not kept running: both cannot hold the port and the outbox.
  • Retry after a failure. A deploy records a hash of the staged files and the rendered units in ~/.local/state/fm-imessage/deployed only when it succeeds. The Ansible role runs imessage-deploy.ts needed on every apply (exit 3: not deployed) and deploys whenever it answers 3, so a failed deploy runs again at the next apply.
  • No slot serving. A deploy first waits up to 120 s (FM_DEPLOY_SETTLE_MS) for a slot that is starting or waiting to restart. A slot that settles active is gated as above. If the old slot is already down (failed or inactive), there is no health gate: the new slot starts as the owner and must answer /health in time. If it does not, the deploy stops it, puts the previous slot back as active, starts it, and exits 1.
  • One deploy at a time. deploy.lock serializes deploys.

Run imessage-deploy.ts with FM_IMESSAGE_DIR set; exit 0 means the new slot serves the line, 1 means the new build was rolled back or failed. After exit 1 the old slot serves only if it was serving before the deploy.

Upstream outages

The Photon service can be unavailable for minutes. It then answers UNAVAILABLE (gRPC) or HTTP 502 or 503. The queue code is apps/imessage/outbox.ts. Desk cancellation and restart exceptions follow the routing recovery contract.

  • The outbox. Each send and tapback enters the private outbox/ directory as one file before delivery. This includes Firstmate answers and direct desk answers. The service syncs each temporary file before its atomic rename, then syncs the queue directory. The service acknowledges the enqueue only after both syncs succeed. A failed directory sync can leave a renamed packet on disk. A keyed retry syncs the directory without adding another packet. During a transient outage, an active desk answer stays queued without a supervisor handoff.
  • Delivery. One loop per lane sends the oldest file of its lane. A lane is one chat on one line; the front desk ack has its own lane. It archives keyed direct answers in outbox-done/ and deletes other completed items. A failed send retries after 1 second, then 2, 4, and so on, up to 60 seconds, with jitter. Unknown errors also retry, so the queue keeps its order. After each accepted bubble, the service saves its progress. Delivery is at least once: a lost answer or a stop before saving progress can cause a duplicate bubble. If the reply target is gone, the service sends without a thread.
  • Downloads. If an attachment download fails, the note says that the attachment could not be saved yet. The service records the message id in ~/.local/state/fm-imessage/downloads/ and tries the download again with the same waits. When the download works, the service files a second note with the saved path. If the download moves to the dead-letter folder (see below), the service files a second note with the message id that says the attachment is lost, so Firstmate stops waiting for a path.
  • Restarts. The outbox and downloads survive a service restart on disk. Delivery follows the routing recovery contract, including its desk-text exception.
  • Typing. Typing bubbles are best effort. A typing error is logged and never fails or delays a send.
  • Logs. Each failed try writes one log line with the operation and the code, for example outbox item 3 failed (try 4), next try in 6.2 s: UNAVAILABLE: .... A transient error never writes a stack trace.

Permanent errors also move an item out of the queue. These errors include missing or expired messages and attachments (HTTP 404 or 410, gRPC NOT_FOUND). They include invalid items (HTTP 400 or 422, gRPC INVALID_ARGUMENT) and files that do not parse. The service moves the file to ~/.local/state/fm-imessage/outbox-dead/ (for a download: downloads-dead/). It writes one log line and continues with the next item. Nothing is dropped without a trace. Retry only unkeyed items by moving their files back to the queue directory under new sequence numbers. Never requeue a direct desk answer by hand; follow the routing recovery contract. Delete an unkeyed item's file to drop it by hand. Keep keyed receipts with the journal as routing recovery requires. An error that can be systemic (for example HTTP 429 or 500, or PERMISSION_DENIED) holds its lane in order while it retries. The log shows each try. FM_IMESSAGE_RETRY_MS sets the first wait in milliseconds (default 1000); the longest wait is 60 times that value.

Desk memory

The desk remembers the whole conversation, after the design in UniiChat: one chat that never ends. The code is apps/imessage/memory.ts.

  • The log. The service records owner texts, Firstmate texts and tapbacks, and desk texts and tapbacks. The records are in ~/.local/state/fm-imessage/memory/main/YYYY-MM-DD.jsonl. The service never edits or deletes a record. Inbound records also keep their stable request id, so a repaired memory write does not repeat after another restart.
  • The tree. In the background, the service compresses the log into a binary tree of one-line summaries of at most 512 bytes. A text that fits in 512 bytes is its own line. Two adjacent lines merge into one line, again and again. The nodes are in memory/tree/YYYY-MM-DD.jsonl. At most 3 compaction calls run at the same time, and they never delay a desk turn.
  • The view. Each desk turn gets a list of lines that covers the whole conversation: recent lines are fine, old lines are coarse. The view grows by one line for each message. When it is larger than 96 KB, one batch merges lines until it is 64 KB or less. The view is in memory/view.json, and the service loads it at start.
  • Recent originals. Each desk turn also gets up to 12 messages before the pending burst, oldest first, beside the summary view. JSON lines include only the message id, kind, and text for owner, supervisor, and desk messages. Text stays verbatim up to 500 UTF-8 bytes; longer text keeps its head and tail with a clipping marker. The block stays within 6,000 UTF-8 bytes and drops the oldest messages first. The prompt budget can drop more messages, including for JSON escaping with Inception. Pending messages stay in their separate latest-burst block. Recent originals work before summaries finish; zoom still opens full messages. Owner text supplies context, not evidence for a direct answer.
  • Zoom. When a line is too vague, the desk calls zoom(id, n) to open the line into the two lines under it. zoom(id, 1) gives one message whole. A text that is not summarized yet shows as "(not summarized yet: zoom it)".
  • The input cap. The initial view keeps the existing 180,000-byte omp budget and the smaller Inception input budget. The view gets what is left of that budget after the system prompt, status, 6,000 bytes of recent originals and 8,000 bytes for the latest texts, up to 96 KB. These byte limits estimate token use; they are not exact token counts. The native tool runtime owns later request framing and context limits. The desk clips fleet status, long texts, and each zoom result (16 KB) to their head and tail. After its budget, zoom returns "context limit reached", so the model answers from what it has. The separate Inception voice request refuses serialized bodies above 100,000 bytes. Compaction uses the smaller budget below and does not clip source text.
  • Compaction size. Each conversation starts with at most 2,048 bytes of context and 4,096 bytes of source text. Long messages use complete UTF-8 chunks; the model then merges their summaries. Each chunk and reduction task states the source message's kind. Retries share a 16,000-byte input budget, including prior replies and reserved framing. Each model call keeps its 60-second limit. Compaction requests Luna 6 (openai-codex/gpt-6-luna) with --thinking=off and --service-tier=priority (the Fast tier, requested but not observed). The configured desk model stays unchanged. In omp 18.7.0, thinking off omits the reasoning setting rather than explicitly disabling reasoning. The measured provider responses reported serviceTier=default, despite the priority request; some responses contained thinking blocks. The runner uses a private temporary cwd and explicitly sets memory.backend: off in an omp configuration overlay. The existing flags skip extensions, tools, skills, and rules; sessions preserve shortening turns. Inherited native memory work can delay exit after the model produces a valid summary. Each call runs in its own process group. At 60 seconds the bridge sends SIGTERM to the group, and SIGKILL 2 seconds later. The group also gets SIGKILL when the call ends and when the output passes 1 MB, so no child outlives the call. A stalled exit still fails the call; a partial response is not accepted. Each saved model summary logs compacted summary, its range, and its byte count.
  • Compaction failures. A failed node leaves the source messages, existing summaries, and view intact. All model compactions share one cooldown: 5 minutes, then 10, 20, 40, and at most 60 minutes. Three initial workers can finish after the first failure; recovery uses one worker until a model compaction succeeds. A continuous outage permits at most six failed node attempts in any hour, regardless of the backlog. New messages do not trigger an early retry, and desk replies continue during the cooldown. Short source messages still build without a model call, so later desk turns see their text during an outage. memory/retry.json saves the failure count and next allowed time with an atomic rename. A restart restores this cooldown before rebuilding unfinished nodes from the unchanged message log. Missing retry state needs no migration; the service creates it after the first failure.

The memory, routing journal, desk session, background tasks, and sender record hold owner texts on the host in private state. Deleting only memory/ does not erase this history; routing recovery restores missing inbound records. For complete erasure, first finish every pending routing request, download, and outbox delivery. Then stop the bridge through the normal service procedure. Remove memory/, routing.jsonl, senders.json, desk-session/, desk-session.seen, desk-tasks/, and completed outbox receipts in outbox-done/ and outbox-dead/ together. This also clears local routing deduplication, so retain transport seen records and do not replay old message history. Never erase these files while requests or uncertain sends still need recovery.

A memory error does not stop an outgoing send, a tapback, or a supervisor handoff. The service logs the error. An inbound memory-write failure hands the durable routing record to Firstmate instead of attempting a basic answer. At start, the service skips a memory log line that does not parse.

Instagram front desk

The Instagram desk uses the same decision parser, not the iMessage routing rules. instagram-desk.service runs apps/imessage/instagram-desk.ts and reuses the memory, runner, input budget, and timing helpers. Its channel-specific prompt does not use iMessage direct routing. It has no Instagram client. It follows two logs of the instagram-dm listener:

  • state/instagram-dms.jsonl (INSTAGRAM_DM_LOG): one row for each new DM from the operator. Each DM restarts the quiet period, 8 seconds (crewship_instagram_desk_quiet_ms), or 60 seconds (crewship_instagram_desk_reel_quiet_ms) once the burst holds a reel link.
  • state/.instagram-dms/sent.jsonl (INSTAGRAM_DM_SENT): one row for each fm-insta-reply text, tapback, and typing indicator, with by, the value of INSTAGRAM_DM_ACTOR (firstmate by default). Any row that is not by: desk stops the desk for that burst of DMs, like a Firstmate send on iMessage.

When the quiet period ends without a Firstmate action, the desk handles the whole burst in one turn. The service sends only the selected action or answer with fm-insta-reply --thread ID and INSTAGRAM_DM_ACTOR=desk. A desk reply does not stop the next desk turn. A new DM or Firstmate action cancels a draft before delivery. If the call fails or exceeds 45 seconds, the desk sends nothing; Firstmate already has the note. A burst with two or more different reel links gets no model call. After the long quiet, the service sends got N reels and one eyes tapback in one fm-insta-reply call. The log records batch got N reels for that batch.

The Instagram thread keeps its memory in ~/.local/state/instagram-desk/memory/; see Desk memory. The private ~/.local/state/instagram-desk/desk.log stores the time, message ID, and a short outcome without raw model text. Each model turn records reply, skip, handoff, react <emoji>, or skip (desk failed). The service saves its position in each log. After a restart, only rows from the last 60 seconds can trigger a desk turn. Older rows fill the memory but do not trigger replies. With no saved position, the service starts at the end of both logs. The desk does not show a typing indicator.

Set crewship_instagram_dm_operator, and the playbook installs the unit with the listener. The settings are the crewship_instagram_desk_* variables in blueprint/ansible/group_vars/all/instagram_dm.yml: model (default claude-haiku-5-5), owner_name, supervisor_model, quiet_ms, and reel_quiet_ms.

Set up the Photon project

Skip this section when transports is [bluebubbles].

  1. Sign in to the Photon dashboard and create a project with the iMessage provider. The free plan gives a shared line.

  2. In the project settings, copy the project ID and the project secret.

  3. Add them to the host's secret store, ~/super.env, as these two lines:

    PHOTON_PROJECT_ID=...
    PHOTON_PROJECT_SECRET=...

    Edit super.env on the primary VPS, push it, and fetch it on the host, as Shared credentials describes. On a host without the shared store, add the lines to ~/super.env directly and keep the file at mode 600. A later fetch replaces that file.

See Enable it for credential loading and shell requirements.

Use self-hosted BlueBubbles relays

A relay is a Mac that runs BlueBubbles server and is signed in to Messages. It costs nothing per month and does not depend on an outside messaging service. You can run the same Apple Account on several Macs: the bridge uses them as one line, in the order of the config.

Set up a relay

  1. Make an Apple Account for the line. An account with only an email address and no phone number works: the owner texts that email address, and imessage.owner stays his own phone number.

  2. On each Mac, sign in to Messages with that account. Download BlueBubbles server and copy the app into Applications, but do not open it or enable automatic start. Copy this repository onto the Mac.

  3. Disconnect the Mac from all networks, then run this command from the repository:

    bash apps/imessage/bluebubbles-relay prepare

    The command creates bluebubbles.yml in the account's home directory with a random password and proxy_service: "lan-url" (all tunnels off). It refuses to overwrite an existing file or continue while BlueBubbles or a tunnel runs. A fresh server otherwise starts its default public Cloudflare tunnel before you can set the password. BlueBubbles reads this file before startup. persist-config: false applies the settings synchronously, before services start. Do not create config.db yourself: an existing database disables initial schema creation. Keep the file for every launch; its settings override the UI settings. Protect the file and the server logs: BlueBubbles logs configuration values, including the password.

  4. Open BlueBubbles while the Mac remains disconnected. Give it Full Disk Access and Accessibility permission. Keep Proxy Setup on LAN URL during its setup. Read the password from bluebubbles.yml, then run this one-command check and enter the password at its hidden prompt:

    bash apps/imessage/bluebubbles-relay check

    The check requires no cloudflared, ngrok, or zrok process. It requires HTTP 401 without a password and with a wrong password, then HTTP 200 with the password. A failed check exits with a clear error; quit BlueBubbles and keep the Mac disconnected until you fix the cause. Do not continue until the check passes.

  5. Limit port 1234 to the agent host with the Mac's firewall or your private network before reconnecting the Mac. Do not configure port forwarding or select Cloudflare, ngrok, or zrok. Run the same check after reconnecting and after every server update.

  6. Enable the BlueBubbles Private API if you want typing bubbles, tapbacks, threaded replies, and read receipts. Plain texts and attachments work without it. The Private API needs System Integrity Protection partly off; read the BlueBubbles docs first.

  7. On each relay, register a webhook to http://<webhook_listen>/bluebubbles with the "New Messages" event. Use the server's API and Webhooks settings or POST /api/v1/webhook. Without a webhook on that relay, inbound messages wait for the bridge's per-minute catch-up.

  8. Make sure that the Mac does not sleep. Enable automatic start only after the safety check passes.

Add each password to ~/super.env as Shared credentials describes, for example BLUEBUBBLES_PASSWORD=.... The host config names the variable, never the password.

Choose the transports

First enable the bridge. Then add transports and the bluebubbles block to the imessage block in .local/host.yml, and run ./ship.sh launch:

    transports: [photon, bluebubbles]   # in order; [bluebubbles] uses the relays alone
    bluebubbles:
      relays:                      # in failover order
        - url: http://<relay-1>:1234
        - url: http://<relay-2>:1234
          password_env: RELAY_2_PASSWORD   # optional; the default is BLUEBUBBLES_PASSWORD
      webhook_listen: <agent host address>:8766   # where the relays post their webhooks

webhook_listen must be an address that the relays reach and nothing else does, such as the agent host's address on your private network. Its port must not be the standby port (FM_IMESSAGE_NEXT_PORT, 8766 by default) on a wildcard or loopback address: the standby slot would fail to bind it while the live slot holds the webhook, and every deploy would fail. To use Photon alone again, remove transports and the bluebubbles block.

The bridge uses these routing rules with one or both transports:

  • Sends. Each send and tapback goes through the outbox first. A proactive send without --reply stays in the owner's latest direct one-to-one chat, on that chat's transport. An owner message in a group does not replace this destination. The bridge saves this destination in latest-direct for Photon and latest-bluebubbles-direct for BlueBubbles, under ~/.local/state/fm-imessage/. Without a known direct destination, the command returns HTTP 503 and queues nothing. The command selects that chat when it queues the text. An outage on that transport holds the send in the queue; it does not move the text to another line. An explicit threaded reply tries its target's transport first. Only a provably unsent reply can use another transport. See What it does for desk-answer routing. A timeout or an unreadable answer saves the possible transport and chat in maybe, with the attempt time. The outbox retries the item or reports a final error through its existing dead-letter path.
  • A retry after an unsure send. BlueBubbles searches the original chat for the text. If the text exists, the bubble counts as sent; otherwise the bridge retries in its permitted transport order. Photon cannot check an unsure send, so the bubble retries only on Photon. A Photon item waits if Photon does not start after a restart. The saved state stays in the outbox file. A saved transport that is no longer configured is ignored.
  • Where a fallback text arrives. A fallback send goes into the owner's latest conversation on that transport. Before he texts the BlueBubbles line, it goes to a new chat with his number from the line's Apple Account. Photon can only answer in a conversation that he started.
  • Replies, typing, and tapbacks. See Commands for reply behavior. A command's read-only failure can use a fallback because no send has happened yet. Typing bubbles and command tapbacks target the latest text across transports, including after a restart. See What it does for direct desk answers. A typing error never fails a send. A tapback and typing are best effort and never block the outbox. A tapback that is not delivered in 2 minutes (FM_IMESSAGE_REACT_MAX_MS, in milliseconds) moves to the dead-letter folder with one log line. The sends behind it go on.
  • Inbound. The bridge reads both transports and applies the routing contract to each stable request id. It selects the latest text by message time, or receipt time when the transport gives none. A late text does not replace a newer latest text.
  • Start. The bridge starts all configured transports together, but waits only for the first transport before opening the local listener. Fallback transports and saved-message reads run in the background, so they cannot delay the local listener. Photon startup HTTP requests have a 3 second timeout. After Photon startup succeeds or fails, later requests keep their own deadlines, including token renewals. A transport that is still starting rejects sends without waiting. BlueBubbles retries through its existing health checks and per-minute catch-up. If Photon cannot start while another transport is set, the bridge logs one line and runs on the others until its next restart. Saved destinations from every configured transport remain eligible even when that transport cannot start. Older latest files also qualify when they identify a direct chat. Proactive texts for an unavailable transport stay queued until it runs again.

How the relay set behaves

  • Health. The bridge reads /api/v1/server/info on each relay at start and during health checks. Each check uses the relay password, has a 3 second timeout, and keeps the result for 10 seconds. The bridge logs one line when a relay goes down or comes back.
  • Private API. Each health check reads the relay's private_api flag. When it is off, the bridge sends no typing or tapback request to that relay and sends replies there as plain messages. Typing and tapbacks use the next healthy relay with the Private API on, or send no request if none supports them. An unsupported tapback reports failure; see delivery recovery. Unsupported typing remains silent. The bridge logs one line per relay when it first finds the Private API off or when the flag changes.
  • Sends. Text sends and read receipts go to the first healthy relay, then to the next one if it fails. Typing and tapbacks follow the Private API rule above. When all relays are down, the outbox keeps the item and tries again.
  • Reply separation. The bridge reads the target's chat and message time, then checks ordered history on each healthy relay. A bubble from either side counts only when its message time is later than the target's time. Older history on a relay does not count; tapbacks and other chats do not count. A later bubble on any relay permits threading, even when the first relay has old history. Thread support follows the Private API rule above. A failed history read follows the transport fallback rules if no relay proves separation.
  • No double sends. Each outbox item keeps a client GUID, and each bubble sends with its own one (BlueBubbles tempGuid). If a relay may have sent a text but did not answer (a timeout, a server error, or an answer that cannot be read), the bridge asks the relays whether the text is in the chat before it sends that bubble again, on any relay or transport (see above). It looks for the same text from the line in the last minute or so. The relay that may have sent is asked first, even when its health check says it is down. Known limits: when that relay cannot answer, another relay can still send the text a second time; a text that reaches the other Macs through iCloud late can be sent twice; a text identical to one sent in the same minute counts as sent; and a text that may be out on Photon cannot be checked, so Photon sends it again (at least once). History must match the complete message text, including leading and trailing whitespace.
  • Inbound. Every relay posts its webhook. A webhook carries only the message GUID that the bridge acts on. The bridge reads the message back from a relay with the password, so a forged webhook cannot inject text. The bridge keeps the last 1000 message GUIDs in ~/.local/state/fm-imessage/bluebubbles-seen. It records a GUID only after it durably saves the request or ignores the message. It ignores other senders, outbound texts, tapbacks, and other signals. If handling fails or the bridge stops before completion, the next webhook or catch-up retries the message. See What it does for durable routing and duplicate suppression. At start and each minute, the bridge asks every reachable relay for the last 15 minutes of messages. It handles each unseen message. This recovers a missed webhook or restart gap if a relay is reachable within 15 minutes. It does not recover older messages that missed their webhooks.
  • Attachments. The bridge downloads a file from whichever relay has it. The first try runs while the bridge handles the message, so it has the short bound of 15 seconds, body included, and never holds up the next message. If it fails or is too slow, the note says that the attachment could not be saved yet, and the download queue tries again with a bound of 10 minutes, like a Photon download. A download that is cut off fails as a timeout, and the queue retries it. The item moves to the dead-letter folder only when every configured relay was asked and every one answered 404 (or another permanent error). If a relay is down, or its download was cut off, the queue retries, so a relay that comes back is asked again. The same rule holds for reading a message.
  • Tapbacks. BlueBubbles has six: ❤️ 👍 👎 😂 ‼️ ❓. A tapback with another emoji moves to the dead-letter folder.
  • Location. fm-location works only with Photon.

Enable it

The iMessage bridge and optional card tunnel use /bin/sh to source ~/super.env and export its assignments before starting. Use POSIX shell assignments, including export KEY=value, and quote values that contain spaces. Neither service passes this file to systemd's EnvironmentFile parser. Only source trusted content: shell commands in this file run as the service user. The credentials never go into the repository, the unit, or .local/host.yml.

Add this block to .local/host.yml, then run ./ship.sh launch. The firstmate profile must be on.

crewship:
  imessage:
    owner: "+<country code><number>"  # the owner's phone number, E.164 form
    owner_name: the owner           # optional; this is the default; how the desk prompt names him
    desk_provider: omp              # optional; this is the default; or inception
    desk_model: claude-haiku-5-5    # optional; this is the default; mercury-3 with inception
    desk_reasoning: "off"           # optional; this is the default; the omp thinking level or Inception reasoning effort
    desk_v2: false                  # optional; select the V2 loop with true
    desk_session: true              # optional; this is the default; with desk_v2, false keeps the one-shot V2 loop
    herdr_alerts: false             # optional; true alerts you from herdr agent status
    supervisor_model: ""            # optional; the model that runs Firstmate

See What it does for model-identity evidence and handoff rules.

Apply installs these items:

  • the build in ~/.local/share/crewship/imessage/stage/, with the latest spectrum-ts in ~/.local/share/crewship/imessage/node_modules, rolled out into a slot by imessage-deploy.ts, which gives each slot its own copy of node_modules, so a rollback keeps the SDK that the old slot ran with. The one exception is the first rolling install: it snapshots the pre-slot files into legacy after the SDK upgrade has already rewritten node_modules, so a rollback to legacy on that install runs on the new SDK
  • the slot unit ~/.config/systemd/user/fm-imessage@.service and the front unit fm-imessage.service, mode 0600 because they hold the owner's number
  • the commands fm-imessage and fm-location and the deploy command imessage-deploy.ts in ~/.local/bin

The service listens on 127.0.0.1:8765. To use a different port, set FM_IMESSAGE_PORT in a drop-in of the slot unit (systemctl --user edit fm-imessage@.service) and in the environment of the commands, including imessage-deploy.ts, which reads the port only from its own environment. FM_INBOX_CMD overrides the inbox command, which is <workspace>/firstmate/bin/fm-inbox.sh by default.

The service is for the host's own commands (fm-imessage, fm-location) only. Except during shutdown, it checks the header X-Firstmate: 1 and requires Host to be exactly 127.0.0.1:<port> before looking at the path. It answers 403 if either check fails. A web page open in a browser on the host cannot set a custom header without a CORS preflight, which the service never answers. The Host check stops DNS rebinding. So a page cannot send messages as Firstmate or read the location. If you call the service with your own curl, add -H "X-Firstmate: 1".

On SIGTERM or SIGINT, the bridge refuses new work and sends SIGTERM to its running desk, compaction, and inbox-status children. During shutdown, the command endpoint and BlueBubbles webhook answer 503 before other request checks. The bridge waits up to two seconds, then sends SIGKILL to children that remain. It waits up to another 500 ms for those children to exit, then exits with status 0. The unit sets KillMode=mixed and TimeoutStopSec=10 so systemd kills remaining service processes if shutdown stalls. See Desk memory for compaction recovery after a restart.

Install and upgrade only through Rolling deploys: imessage-deploy.ts is the one install path, so a bare systemctl --user restart fm-imessage is not an upgrade. After an update, check the installed slot with GET /health and one fm-imessage text to a test chat; use isolated transport fixtures and synthetic owner messages, never a real recipient.

To turn it off, remove the block, then run systemctl --user disable --now fm-imessage (the front unit stops the slots) and delete the units.

Commands

Command What it does
fm-imessage 'text' Queues a plain message using the proactive routing rules, even when his latest text is a thread reply. Without an argument, it reads the text from stdin. Paragraphs that a blank line separates become separate chat bubbles. The lines of one paragraph, for example a list or a schedule, stay in one bubble while it has 1,000 characters or fewer; a longer paragraph splits at a line, then a sentence, then a word, so that iMessage never cuts a bubble short. Before each bubble after the first, the typing bubble shows for 400 ms plus 25 ms for each character, 2.5 seconds at most.
fm-imessage --reply N 'text' Targets the owner's Nth most recent text (1 is the latest). The first bubble sends plain if the target is still the last bubble in its chat at delivery. It threads only when a later bubble exists from either side; tapbacks do not count. See Reply separation for checks across BlueBubbles relays. A transport that cannot thread sends plain. The command picks the target when it runs, and the queued message keeps that target's id through retries and restarts. The service keeps at most 10 texts received since it started that were the newest text at that time. A text that arrives late (see Inbound) is not kept. If the Nth text is not kept, nothing is queued and the command exits non-zero. Any other value of N queues nothing and exits with code 2.
fm-imessage --reply-id ID 'text' Threads to the kept text with that exact message id, or the inbox request id <line>-<id> (for example photon-<id>), as --reply N does for position N. A newer text cannot move the target. --reply and --reply-id together are refused with code 2. An id that is not among the kept texts queues nothing and the command exits non-zero with the error. The routing log records the chosen id as a replyTarget row.
fm-imessage --no-thread 'text' Forces a plain send, even with --reply N, regardless of option order.
fm-imessage --key TOPIC 'text' Logs the text under a topic key. The same key with the same content on the same local day is skipped: nothing is queued and the command exits 0 with a skipped message. A second mate pushes a text to the owner with FM_HOME=<mate home> fm-imessage --key TOPIC 'text'; the sender log (sent.jsonl in the state directory) holds key, sender, content hash and time for every text sent by a mate, the desk or Firstmate. Unkeyed texts are logged and never skipped. A (repost) marker and spacing do not make a text new. With --image, the caption is logged and deduped; the photo is not.
fm-imessage --image PATH ['caption'] Queues a photo (png, jpg or webp), then the caption as text bubbles. See Send a photo. --reply N, --reply-id ID and --no-thread act on the photo as they act on the first bubble of a text.
fm-imessage --typing Shows the typing bubble on a best-effort basis. See the typing lifecycle for expiry and cleanup.
fm-imessage --react '👍' Queues a tapback in the outbox, in order with the sends. The tapback goes on the text that was his latest when the command ran.
fm-imessage --card '<json>' Queues a card: a link to a page with options to tap, a chart, a timeline, or a table. The argument is the card JSON, - for stdin, or a file. See Cards.
fm-imessage --help Shows the usage. It queues nothing.
fm-desk Lists the newest 20 turns of the desk session (local time, kind, first words), then asks which to open. fm-desk N opens turn N (1 is the newest); -n COUNT sets the list length. omp has no read-only viewer, so the command opens a temporary copy of the session file, cut after the chosen turn, with tools off; the session file is only read.
fm-desk --follow Waits for the next desk turn, then prints its session entries live as they are appended (time, role, text; tool calls and results as one-liners) and exits when the turn's final assistant message arrives (stopReason other than toolUse) and nothing follows within FM_DESK_GRACE seconds (default 10, so a retry reminder keeps the follow going), or, if none appears, once the file has been quiet for FM_DESK_SETTLE seconds (default 60). fm-desk --loop then waits for the next turn again. omp is not started and the session file is only read.
fm-location Prints the location that the owner shares with the line in Find My, as JSON.
fm-location --check Prints one line, ok, age <N> s, accuracy <M> m, and no coordinates. Use it to prove a read works. On failure both forms print one line to stderr (fm-location: HTTP <code>: ...) and exit 1.

Use -- before text that starts with a dash: fm-imessage -- '- first item'. Place send options before --: fm-imessage --reply 1 --no-thread -- '- first item'. All arguments after -- are text, not options.

If a spectrum-ts upgrade changes access to the Photon client, the service logs a warning. Until the bridge is updated, fm-location returns HTTP 503 and the service does not see edits. Plain Photon sends still use the public SDK. Explicit threaded replies can use a configured fallback because the bridge cannot check the latest bubble.

fm-imessage exits 0 only when the message is on disk in the outbox, and prints that it is queued. It exits non-zero when it queues nothing. An unknown option queues nothing and exits with code 2.

Mute

To block new outgoing items, create ~/.local/state/fm-imessage/muted. STATE_DIRECTORY overrides the default state directory. While that file exists, the bridge queues no outgoing item. Items already in the outbox can still send. Firstmate sends, desk answers, cards, photos, voice memos, and tapbacks all stay out of the queue. Inbound texts still arrive, and their notes still reach Firstmate. The bridge refuses send, reaction, card, photo, and typing commands with HTTP 503 and nothing queued: muted. fm-imessage exits non-zero for these refused commands. The bridge also refuses a command if mute starts while it reads the request. When mute blocks a desk answer or tapback, the bridge stops typing and hands every request to Firstmate. The bridge keeps each request until the inbox accepts its note, including across a restart. The desk can still show typing while it classifies inbound text. Refused photo and card commands leave no new files behind. Remove the file to resume sending.

touch ~/.local/state/fm-imessage/muted
rm ~/.local/state/fm-imessage/muted

Send a photo

fm-imessage --image PATH ['caption'] sends one photo. The command gives the file to the service, which sends it with the spectrum-ts attachment path (attachment() in space.send). The caption is optional. Blank lines in it make separate bubbles, as in a text. The photo goes first, and the caption bubbles follow it.

  • Checks. The command checks that the file is readable, that its name ends in .png, .jpg, .jpeg or .webp, and that it is 25 MB or less. The service checks the content, not the name, so a renamed GIF or a text file is refused. A refused photo queues nothing, and the command exits non-zero with the reason.
  • Downscale. A photo with a side over 2048 px (for example, a 4K screenshot) is downscaled to fit 2048 px, in the same format and with the same proportions. The EXIF orientation is applied. Smaller photos go out unchanged. The service uses Bun's built-in Bun.Image, so the bridge has no new dependency and the host needs no image tool. If the result is still over 10 MB, the photo is refused.
  • Queue. The photo is an outbox item like a text. The service saves the downscaled file in ~/.local/state/fm-imessage/outgoing/ (mode 0700) and queues the item after the file is on disk. The retries, the order, the restarts, and the dead-letter folder work as in Upstream outages. The service deletes the file after the last bubble of the item is sent. A photo that moves to outbox-dead/ keeps its file.
  • Memory. The desk memory records the send as [image: name], then the caption. The name has the extension of the real format.
  • Transport. Photos go over the Photon line only. The BlueBubbles code of the bridge has no attachment send. When the owner's chat is on BlueBubbles, the photo uses the Photon line's latest chat. If the Photon line is not running, the command exits non-zero with HTTP 503.

Voice memos and audio

A voice memo or an audio attachment (.caf, .m4a, .amr, .mp3, .aac, .wav, .ogg, .opus, .flac, .aiff, or any audio/* type) is saved in ~/.local/state/fm-imessage/attachments/, then transcribed on the host. apps/imessage/transcribe.ts runs ffmpeg to make a 16 kHz mono wav, then whisperx on the CPU with int8. No API is called.

The note reads [voice memo, 4s] <words>, then (audio saved for Firstmate at <path>). The desk memory records the same text, so the desk sees the transcript. If the audio holds no speech, the words are (no speech heard). If ffmpeg or whisperx fails or times out, the service logs the error, and the note still has the path and says transcription failed. If the file cannot be saved, the download retries as for any attachment, and the retry transcribes it.

The inbound loop waits for the transcription, so the next inbound message waits too (about 15 s on a warm host, up to 45 s on a cold start). Sends are not held up.

Variable Default Meaning
FM_IMESSAGE_WHISPERX ~/.cache/watch/whisperx-venv/bin/whisperx The whisperx executable. The watch skill's venv has it.
FM_IMESSAGE_WHISPER_MODEL small The Whisper model name.
FM_IMESSAGE_TRANSCRIBE_MS 120000 The time limit for each of the two commands.

Cards

fm-imessage --card sends a card: by default, a Spectrum app bubble. A tap opens the card's page as a full-width sheet inside Messages, in Photon's free "Spectrum" iMessage app; the bridge serves the page. He installs that app once, from the App Store prompt in Messages. The code is apps/imessage/cards.ts.

fm-imessage --card '{"kind":"choice","key":"layout","title":"Board layout?",
  "options":["Short list, one screen","Cards with a mock each","Table with all details"]}'

Every card has kind, key (1 to 40 letters, digits, ., _ or -), and title (up to 120 characters). Optional fields: subtitle (up to 200 characters), hours until the card expires (1 to 168, default 24), app (default true; false sends a link in place of the app bubble; see below), and live (default false: a tap opens the full sheet; true asks for the app's live view, which draws the page inside the bubble at a narrow width that Messages sets and the page cannot change). The kinds:

Kind Fields The page shows
choice options: 2 to 6 texts One button per option, A to F. One tap picks.
bars bars: 1 to 30 {"label", "value"}, optional unit A bar chart. With "unit": "%" a bar fills its track at 100; with any other unit, at the largest value.
timeline items: 1 to 50 {"when", "text"} A vertical timeline.
table columns: 1 to 8 texts; rows: 1 to 100 rows of that length A table.
text lines: 1 to 60 texts of up to 200 characters The lines of fm-text-card: # x starts a section, - x is a bullet, k: v is a row, and other lines are plain rows.

A bad card queues nothing, and the command names the first bad field.

  • Pick. A tap on an option files the inbox note [iMessage card pick] <key>: <letter>. <option>. The note wakes Firstmate. The desk memory records the note. The page shows the pick with a check. An app bubble also changes in place to "Picked B: ...".
  • Delivery. A card goes through the outbox, in order with texts, to his latest direct chat, like a proactive text. The desk memory records [card <key>: <title>], never the URL. An outage retries like a text.
  • App bubbles. The card uses spectrum-ts app(url, {layout, live}) on the Photon transport. Photon's docs say that the app delivers the URL to the Spectrum extension on tap and opens it in Messages, so the view is the card's page. The page has no fixed width: it fills the sheet, and text, bars and tables reflow to it. spectrum-ts app() has no size or presentation option; the sheet is the full view, and the live bubble is the narrow one. The bubble's layout is the title and one status line (no trailing caption: without the app, Messages runs the two together). The service keeps the card session that the send returns, and uses it with edit for the change in place, also after a restart. Measured on the free shared line: Photon delivers the bubble with no error. Without the Spectrum app, his phone shows only a plain balloon with the layout text. Photon's pricing lists iMessage mini apps for the Business plan only. Any error other than an outage moves the card to outbox-dead/, files the note (card <key> was not sent: <reason>), and the texts behind it go on.
  • Link cards. With "app": false, the card is its page's URL as a plain text, which iMessage shows as a link preview from the page's Open Graph tags (og:title, og:description). A link card goes on any transport, and a tap opens the browser.
  • Mac. The Spectrum app is for iPhone only. In Messages on a Mac, an app card stays a plain balloon with the title and the status line, and a tap does not open the page. Open the card on the iPhone, or send it with "app": false when he reads on the Mac.
  • PNG cards stay the default. fm-text-card --send < text and eta_timeline.py --send send their PNG with fm-imessage --image. With --card, they first send a text or a timeline card, and send the PNG only when fm-imessage --card queues nothing (cards off, or an older bridge). Choice cards are always cards.
  • Page. The page looks like an iOS view: the system font and text sizes, the system colours in light and dark mode, inset grouped rows of 44 pt or more, safe-area padding, and a pressed state with no tap delay. It has no script.
  • Page listener. A second listener serves the pages. It listens on 127.0.0.1:8767 (FM_IMESSAGE_CARD_PORT), apart from the private command port. It answers only GET /c/<token> and POST /c/<token>/pick; every other request gets 404. The command port does not answer card paths. Cards are off until FM_IMESSAGE_CARD_URL is set: then /card queues cards, and the bubbles open <FM_IMESSAGE_CARD_URL>/c/<token>.
  • Instagram webhook. With INSTAGRAM_DM_BACKEND=graph, INSTAGRAM_GRAPH_APP_SECRET and INSTAGRAM_GRAPH_VERIFY_TOKEN set, the page listener also answers /instagram/webhook (apps/imessage/instagram-webhook.ts), even when cards are off. GET is Meta's verification: the right verify token gets the challenge, any other gets 403. POST must carry a valid X-Hub-Signature-256; an unsigned or badly signed request gets 403 and files nothing. Each new DM from INSTAGRAM_DM_OPERATOR goes to INSTAGRAM_DM_NOTIFY first and then to INSTAGRAM_DM_LOG. A DM from any other sender files nothing, and the service logs its sender id once per DM. A refused note gets 500, so that Meta sends the DM again; the mid stops a second copy. With the webhook on, the body limit is 64 KiB, not 4 KiB.
  • Tokens. Each card has its own token: 256 random bits, base64url. The token is the only key to the card: no login, no password. A wrong, unknown, or expired token gets the same 404 page. A choice card takes one pick: a second pick gets 409 and files no note. If the inbox refuses the note, the pick is undone and the page says to try again. The service never logs a token and never prints one; fm-imessage prints only the key. The pages send Referrer-Policy: no-referrer, Cache-Control: no-store, and a Content Security Policy that allows no script and no outside content.
  • Records. Each card is one file, ~/.local/state/fm-imessage/cards/<token>.json (the directory is private to the account). The service deletes a card file one day after the card expires.

Publish the card pages

The bubble opens on his phone, so the page listener needs a public HTTPS address. Use a named Cloudflare tunnel, because a quick tunnel gets a new address at each restart, and the old cards stop working.

  1. In the Cloudflare account that holds the domain, make a remotely managed tunnel (for example, crewship-cards). Give it one public hostname, for example cards.example.com, to http://127.0.0.1:8767. The catch-all rule stays http_status:404.

  2. Make sure that the DNS record of that hostname is a proxied CNAME to <tunnel id>.cfargotunnel.com.

  3. Add the tunnel token to ~/super.env as CARDS_TUNNEL_TOKEN=..., as Shared credentials describes.

  4. Add the cards block to the imessage block in .local/host.yml, then run ./ship.sh launch:

        cards:
          url: https://cards.example.com   # the public hostname of the tunnel
          port: 8767                       # optional; this is the default
          tunnel_token_env: CARDS_TUNNEL_TOKEN   # optional; this is the default

Apply then installs cloudflared and the unit fm-cards-tunnel.service, which runs the tunnel with the token from ~/super.env, and sets the card variables in the bridge unit. The tunnel reaches only the page listener. To turn cards off, remove the block, run ./ship.sh launch, then run systemctl --user disable --now fm-cards-tunnel and delete its unit.

Outbound text filter

fm-imessage cleans each text and photo caption before it sends (apps/imessage/clean.ts, run with bun). The bridge refuses a text that is only a PR link, and any paragraph that starts with a task or mate id, has key= or corr= tokens, or has two or more commit hashes after a colon (hex inside a URL does not count as a hash). It answers HTTP 422 and queues nothing. Sub-agent answers pass the same filter in subagent.ts. The filter removes [Dxxxx] decision tags, [key=...], key= and corr= tags, pr=, mode=, yolo=, head= and task= tokens, mate ids (<x>-mate-<y> always; cf-<word> and sm-<word> only in backticks or as a leading label:), absolute paths with two or more segments (/a/b) and ~/... paths, state/ and data/ paths that end in a file extension, a status prefix (working:, done:, needs-decision:, paused:) at the start of a line, and a child <id> working: style prefix (also done, failed, blocked, paused, needs-decision, with an optional supervisor: before it). A line that starts with "decision stuck after N attempts" is dropped whole; inline, only the phrase is removed. A bare colon at the start of a line is dropped, also when the text had one before; emoticons such as ":)" stay. "the owner" and "the captain" (also the owner's) become "you" ("your"). A bare "owner" or "captain", "the owner of", "the captain Smith", and owner.json stay. URLs stay as they are. Text with none of these stays byte-exact, except that a bare colon at the start of a line is still dropped. If nothing is left, the original is sent without a leading colon. Without bun, or if the filter fails, the CLI warns and sends the text unchanged.

STE check on desk replies

Before the bridge sends a desk reply, it checks the text with Vale and the style in apps/imessage/ste-vale/. The style flags dashes between clauses, contractions, semicolons, sentences over 25 words, and a short list of idioms. If the check fails, the bridge asks the front model (mercury-3) for one rewrite. It sends the rewrite when the rewrite has fewer violations. Otherwise it sends the original text. The check runs once for each reply, before the first bubble. Vale gets 3 s, the rewrite gets 12 s. A missing vale binary (FM_STE_VALE or PATH), a timeout, or an error sends the original text, so the check never blocks a send. Text with a code fence is not checked.

Plain messages and the shared line

See Commands for plain sends and explicit replies. On Photon's free shared line, plain sends into the owner's conversation work. A conversation that the service opens itself is refused ("Target not allowed for this project"). See Upstream outages for retries and permanent refusals. Before the owner sends the first text, the commands fail with HTTP 503.

Location is personal data

Every home on this host calls fm-location from ~/.local/bin; no key or setup is needed.

fm-location works only while the owner shares his location with the line in Find My. The location is personal data. Use it only when the work needs it. Keep it on the host: do not put it in a commit, an issue, a PR, a log, or a chat.