← Knowledge

Recovering Remote Agent Clients

A recovery protocol for reconnecting clients without duplicating turns or losing authoritative state.

A remote agent client can lose its transport at the exact moment when local intent and runtime state diverge: the client knows it attempted to send, while it does not know whether the runtime received the message. Recovery should restore identity and inspect runtime-owned state before producing another side effect. Preserve input identity across retries, resume the conversation, and reconcile authoritative state before sending again.

Know which identity survives

In Letta’s session model, an agent is the persistent entity with memory, a conversation is a thread on that agent, and a session is the active connection used to send and stream. These identities have different recovery consequences.

Creating a session for an existing agent starts a new conversation; resuming by conversation ID returns to that specific thread. A reconnect handler that calls createSession(agentId) can therefore recover transport while silently abandoning the conversation it meant to continue. Persist the resolved conversationId before the first send and use resumeSession(conversationId) after connection loss.

Agent memory and conversation history persist beyond one SDK connection, while client tools, permission callbacks, cwd, environment variables, sandbox selection, and session resource links are session-scoped. Recovery must restore the conversation and then deliberately reconstruct any connection-local capabilities. This distinction is central to persistent agent execution: retained conversational state does not imply that every client-side attachment survived.

Assign input identity before transmission

The App Server protocol accepts an optional client_message_id on each user message. The protocol makes client_message_id optional and describes it as useful for UI deduplication and local optimistic rows.

Allocate that identifier before writing to the socket, then retain it with the intended conversation and payload. A client should maintain a small local send ledger with states such as prepared, sent but unconfirmed, and confirmed in conversation history. This design inference lets a restarted process distinguish an unsent local draft from an input whose delivery became ambiguous.

Keep the identifiers separate. A client_message_id identifies the user input from the client’s perspective. A request_id correlates a command with its direct response. An event’s idempotency_key identifies replayed or duplicated runtime events. Reusing one field as a substitute for another makes recovery appear simpler by discarding the distinctions the protocol provides.

The documentation does not describe client_message_id as a server-side exactly-once guarantee. Treat it as a stable correlation aid, not proof that an unacknowledged input was rejected.

Rebuild the connection around the conversation

If an SDK session closes unexpectedly, it cannot be reused. Its stream emits an error followed by a failed result, and the documented recovery path is to call resumeSession(conversationId) to obtain a new session.

A client using the lower-level hosted remote API has more transport work. A hosted custom client should resolve the current connection before opening sockets because a device’s connectionId may rotate after re-registration. It then opens the hosted control and stream relay sockets with the same agent and conversation identifiers, sends runtime_start, waits for runtime_start_response, and uses the returned runtime for later scoped commands.

The returned {agent_id, conversation_id} pair is canonical for that connection. Do not reconstruct it from earlier assumptions, especially when startup was allowed to create an agent or conversation.

Keep the hosted and direct layouts distinct. The hosted remote transport currently uses two relay sockets by convention. A directly connected App Server uses one bidirectional WebSocket and rejects the legacy split-channel layout. A custom client that supports both should select its connection strategy from the endpoint and advertised capabilities rather than treating the hosted relay shape as universal.

Reconcile state before deciding to retry

The SDK does not replay events missed while disconnected. After resuming, its documentation directs clients to inspect conversation history with listMessages() or fetch consolidated state with bootstrapState(). If live streaming has already resumed, fetched history must be merged by rebasing any in-progress accumulators onto that snapshot rather than appending both views blindly.

At the lower protocol level, a successful runtime_start replays current runtime state after restoring the subscription. An established connection can request another replay with sync; a hosted client should also request sync when it detects an event_seq gap. Replayed state arrives through ordinary events, so the same routing and deduplication rules still apply.

Approval state also needs reconciliation. The SDK documentation says a pending approval can remain on an active runtime after client disconnection. After resuming, inspect device status and request approval recovery when needed. A reconnect should not assume that transport loss denied, accepted, or erased an outstanding decision.

Deduplicate the event plane

The hosted remote client opens control and stream relay sockets, but both hosted sockets subscribe to the same event feed, so responses and events can be duplicated. Convention assigns commands to the control socket, while either endpoint can accept them.

ACK each sequenced frame on the same socket that delivered it. Correlate direct responses by request_id and ignore responses for requests already resolved. Deduplicate runtime events by idempotency_key. Track increasing event_seq values per connection and request synchronization after a gap.

These mechanisms address different failure modes: acknowledgements maintain hosted delivery, request correlation prevents duplicate command completion, and event deduplication prevents replayed output from being rendered or applied twice. Preserve unknown fields and event types so a newer server can extend the protocol without forcing an older client to discard the entire frame.

Resolve the ambiguous-send window

The SDK documentation explicitly warns against blindly retrying after a successful send() followed by connection failure because the message may already have reached the runtime. The safe decision sequence is:

  1. Mark the original input as sent but unconfirmed without changing its client_message_id.
  2. Open a new session by resuming the same conversation, or rebuild the low-level transport and run runtime_start for the same runtime scope.
  3. Fetch conversation history and current runtime state.
  4. Check for the original input, an active turn, queued work, or a pending approval associated with the interrupted conversation.
  5. If the input is present, mark the local attempt confirmed and continue from the reconciled state instead of sending it again.

Messages sent while another turn is active are queued by the runtime rather than dropped. A missing immediate response can therefore represent queued work rather than failed delivery. Queue state belongs in the reconciliation pass whenever the runtime exposes it.

Retry only after authoritative history and runtime state fail to show the original input or its active consequences, and then reuse the same logical input identity. This design recommendation does not manufacture an exactly-once guarantee. It keeps the client’s two attempts recognizable as one intended user action while the reconciled runtime state determines whether another send is warranted.

Record completion separately from connectivity

A live socket does not prove that a turn is running, and a closed socket does not prove that a turn never started. A turn’s SDK stream terminates after its final result, which carries success, stop reason, duration, final text, and the runs started by the turn. At the protocol level, a stop_reason delta normally marks completion, except when the reason is requires_approval; that state awaits a decision rather than representing a finished turn.

Run IDs link reasoning, assistant, tool, error, and retry messages to their originating runs, while the final result lists all runs started by the turn. Retain the conversation ID, client message ID, request IDs, event sequence positions, idempotency keys, run IDs, and terminal result in recovery diagnostics. That record supports agent-trajectory observability without confusing transport events with runtime outcomes.

For clients built through the higher-level agent guide, the SDK owns much of the relay, heartbeat, acknowledgement, and synchronization machinery. Custom remote clients inherit those responsibilities. Their recovery path should finish only when the client can identify the conversation it resumed, the state it reconciled, and the evidence supporting its decision to send or refrain.

Sources

  1. Letta remote client API
  2. Letta Agent SDK sessions, turns, and durability
  3. Letta App Server protocol lifecycle

Connections

Related

Linked here

Suggest a correction ↗

Appearance