Back to list

Blog

Let the Server Own the Conversation History: Transcript Replay for Mobile AI Coding

Why Tactic Remote 1.8.2 moved chat history off the phone and onto the server, and how that single decision eliminated an entire class of out-of-order and history-loss bugs at the root.

Published Tags: engineering / architecture / ios / reliability / websocket
Blog

Follow product and engineering updates from this channel.

Browse category

This post is about an architectural decision: why Tactic Remote 1.8.2 moved conversation history off the phone and onto the server, and how that change made a whole category of bugs we kept patching disappear at the source.

An Approach That Was Always Going to Break

Before 1.8.2, chat history was assembled entirely on the phone in real time. The server pushed streaming agent events to the client, and the client rendered them into message bubbles one by one, building up the conversation you saw on screen.

On a desktop, that works well enough — the connection is stable. But on mobile it has a fundamental flaw: the history only exists within the current connection, within the current session. The problems followed naturally.

  • After a reconnect, the new connection has no access to old events — the history is blank, or incomplete.
  • Kill the app and reopen it, and the conversation that was in memory is gone. You start over.
  • When two devices connect to the same session, each one only sees events from the moment it joined. They each have a different view.

There was a subtler problem too, around ordering. When the user sends a message, the client optimistically renders the user bubble immediately, while the agent's first reply doesn't arrive until its process actually starts up. Any timing hiccup — a queued message that trickles in after a reconnect — could push the user's bubble below the agent's response. We fixed that "user bubble sinks to the bottom" issue several times, but every fix was treating the symptom. The root cause was structural: nothing in the system was authoritative about the true order of the conversation.

Taking a Page from tmux

Terminal mode in Tactic Remote has always been solid, and that's because of tmux. The tmux model is simple and powerful: the terminal buffer is the single source of truth. When you attach, you get a replay of that buffer. Disconnect, switch devices, reconnect — you're just re-attaching to the same buffer, and the history is there.

Chat mode was missing exactly that layer. So the 1.8.2 design was to apply the same idea:

The server holds an authoritative record of the conversation. When a client joins, it gets the full history replayed to it.

Once the server is the authority on ordering, the problems above stop being bugs to patch one by one — they become structurally impossible.

How It Works

Server Side: a Ring Buffer per Session

The server maintains a conversation record for each chat session. Every event that gets broadcast is appended in order to a ring buffer. The buffer has two caps — one on event count, one on total byte size — and when either is exceeded, oldest entries are trimmed. The one exception is the session-initialization event at the top, which is the anchor for replay and is never trimmed. When trimming occurs, the buffer is flagged as truncated, so clients can show "earlier messages not shown" at the top of the replay rather than pretending the record is complete.

One key step here: messages the user sends are now also written into this record as ordered events by the server. Previously, user messages existed only in the client's optimistic render — the server had no record of them — and mid-run steered messages had no record at all. Now that the server owns the ordering, every user turn lands at its true position in the sequence. The "user bubble sinks to the bottom" bug becomes structurally impossible.

Replay: an Envelope, Not a Raw Re-Send

The first design was to re-send the raw events on attach. That idea didn't survive contact with reality. Older clients would ignore events they didn't recognize — but these replayed events are ones they do recognize, so they'd be appended again as new live messages, doubling the history.

The approach we actually shipped wraps the full record in a new event type: structured_replay. It carries a sequence number, a "last batch" flag, a "truncated" flag, the current running state, and the event list. Older clients don't know this event type, so they ignore it silently and are completely unaffected. Newer clients unwrap the envelope and feed the events back through the same processing pipeline that handles live events, reconstructing the full conversation.

Because iOS WebSocket frames have a single-frame receive limit of around 1 MiB, replay is delivered in 256 KB chunks, sequenced in order. Tool-approval events that were already acted on are stamped with their outcome ("allowed" / "denied") during replay, so a historical approval card doesn't re-render as an actionable prompt waiting for input.

Client Side: Reconstruct on Join, Suppress Side Effects

When the client receives the chunk with sequence number 0, it clears the local conversation and feeds the event list back through the same processing function it uses for live events. Replay uses exactly the same rendering path — no separate replay logic to maintain.

There's one detail that has to be handled carefully: side effects that only make sense in a live context must be suppressed during replay — things like the send queue's auto-dispatch, timeout watchdogs, running-state transitions, and approval auto-readiness. A flag gates all of these off until the final chunk arrives, at which point the authoritative running state from the envelope is adopted.

As for the optimistic user bubble on send — we kept it. The first message takes a second or two while the agent process starts up, and waiting for a replay round-trip before showing anything leaves a noticeable blank. Instead, we changed the optimistic bubble into a "pending" placeholder: as soon as the server's acknowledgment arrives, the placeholder is removed and the message is re-inserted at its true position from the authoritative record. Out-of-order rendering can no longer persist.

Compatibility: Why "Ignore Unknown Events" Is a Design Decision

The replay design could ship safely in large part because of a convention laid down early: clients silently ignore any event type they don't recognize. That one rule is what lets the server introduce a new event like structured_replay without disturbing clients that haven't upgraded yet. They get no replay functionality, but they also produce no errors.

In a system where the iOS app, the Mac / Linux / Windows servers, and an npm CLI each ship independently and version drift is permanent, this "additive-only, ignore the unknown" protocol discipline is what makes it possible for the server to ship new capabilities ahead of clients and have clients adopt them as they update.

Limits, and Honest Trade-offs

This design has real limits, and we'd rather state them plainly.

  • The server buffer has a cap. Very long sessions will have their earliest events trimmed. The replay says "earlier messages not shown" at the top — it doesn't pretend to be complete.
  • A session the server has reclaimed starts fresh. If a session goes unsubscribed long enough, the server reclaims it. A session resumed via claude --resume afterward starts with an empty buffer; earlier history is not replayed. This is a deliberate trade-off: bounded history retention in exchange for predictable memory use.
  • Client-side persistence is a future enhancement. Right now the authoritative record only lives as long as the server is running. Persisting history to the client locally is a follow-on improvement, not part of this release.

We'd rather you know where the edges are than discover a gap in your history one day and be left guessing why.


Want to see "join and it's all there" in action? The iOS app is free on the App Store, and the companion server and CLI are free as well. If you're curious about the other hard problem in this release — the UI freeze that had nothing to do with business logic — read Hunting a UI Freeze.

Try Tactic Remote

Control your coding Agents from your phone

Connect to Claude Code, Codex, and other Agents on your Mac, Windows, or Linux computer. Check progress and send the next instruction from iPhone or iPad.