Moving a long-lived Letta agent off Letta Cloud—or from one self-hosted box to another—is not a database dump exercise. It is a contract problem: the Agent Development Environment (ADE), Broca, SMCP, and search all expect the same shapes Letta’s HTTP import/export APIs agree on. Sanctum’s production playbook treats agent files (.af), message pagination, and archival memory APIs as the migration surface—and keeps Postgres read-only except for normal Letta operation.
This post generalizes what we learned migrating multi-thousand-message agents (including persona-heavy companions) without repeating the failures that once “bricked” ADE on a host.
Three jobs people confuse
Before you touch anything, name which job you are doing:
| Job | What moves | Typical goal |
|---|---|---|
| Agent data | .af import/export, full message merge, archival backfill |
Same mind, new Letta home |
| Workers only | Broca, SMCP, Otto bridge trees, .env, sanctum.db |
Letta already has the right agent_id |
| Greenfield host | Bootstrap scripts + fresh Letta DB, then import per agent | New VPS or Sanctum node |
Mixing these without updating AGENT_ID in Broca and SMCP is how Telegram, queues, and ADE end up talking to the wrong agent.
Golden rules (the short version)
- HTTP import/export only for conversation and agent shell state—not hand-edited rows in Letta’s Postgres
messagestable. - Export may truncate embedded history; page
GET /v1/agents/{id}/messagesand merge before import. - Archival memory does not ride in the
.af—backfill witharchival_memory_insert(or equivalent HTTP) after import. - Collapse reasoning + assistant pairs into one assistant row per turn (see below)—split rows lose visible replies on Letta 0.16.x.
- Set
agent_type:letta_v1_agenton the imported shell so listed messages label visible text asassistant_message, notreasoning_message. - Core memory blocks and tools are separate from merged message history—copy blocks via
GET …/core-memory/blocksand attach withPATCHblock_ids/tool_idsafter import.
Plumbing: what ADE actually needs
ADE is a browser app. If your Letta API listens on plain HTTP while the page is served over HTTPS, you get ERR_SSL_PROTOCOL_ERROR or mixed-content blocks. A workable pattern for a dedicated migration/staging host:
- DNS to the VPS (e.g.
lettatest.example.org). - nginx terminates TLS on 443 (marketing/site) and often 8283 (ADE appends the API port).
- Letta binds loopback only (e.g.
127.0.0.1:18283)—never expose the raw API on the public internet without TLS in front. - Certbot (or your ACME flow) on the edge vhost.
Bootstrap is not “install Letta and call it done.” It is TLS + reverse proxy + loopback API so ADE and curl share one trustworthy origin.
Message shape: export ground truth vs API listing
Native GET /v1/agents/{id}/export uses one role: "assistant" row per turn when both reasoning and visible reply exist:
{
"role": "assistant",
"content": [
{ "type": "reasoning", "reasoning": "…", "is_native": true },
{ "type": "text", "text": "What the user should see in ADE and search" }
]
}
{ "role": "assistant", "content": [ { "type": "reasoning", "reasoning": "…", "is_native": true }, { "type": "text", "text": "What the user should see in ADE and search" } ] }
Source hosts often return separate API rows (reasoning_message then assistant_message). Migration must merge those into the combined export shape before POST /v1/agents/import. Two consecutive assistant rows for one turn are an anti-pattern: on 0.16.x import, the second row can be dropped—history looks complete by count but visible replies vanish.
After import, GET …/messages may still expand one embedded row into two listed types (reasoning_message + assistant_message). That expansion is normal; the import file must not use the legacy split-row format.
Other shape fixes that blocked real imports:
- Use
"content": []instead ofnullwhen there is no text buttool_callsexist. - Tool returns:
role: "tool"withcontentas[{"type":"text","text":"—raw English breaks message listing parsers."}] - Sanitize JSON tool bodies with top-level
"status": "error"to a neutral{"ok": false, "message": "…"}shape. - Drop source rows with
status: errorbefore merge. - Assign fresh synthetic message ids (
message-0, …) when Cloud pagination returns duplicate ids on different rows.
Pulling memory out of the system (without SQL)
Conversation
GET /v1/agents/{id}/exportfor shell, tools metadata, partial embedded messages.- Page
GET /v1/agents/{id}/messages?limit=1000&order=ascwithafter=until exhausted. - Merge into
agents[0].messagesusing the collapse rules above; rewritein_context_message_idsso every id exists inmessages. POST /v1/agents/importto the target base URL with the target bearer token.
Archival
GET /v1/agents/{id}/archival-memory (hyphenated path on current builds), then insert passages against the new agent id on the target—never by inserting into archival_passages via SQL.
Core memory blocks (persona, human, summaries)
The merged .af from export + messages alone does not reliably recreate core-memory blocks attached in the UI. After import:
GET /v1/agents/{source}/core-memory/blocks(or equivalent on your build).- Create or match blocks on the target (
POST /v1/blocks/, etc.). PATCHthe new agent’sblock_ids(andtool_idsfor custom tools).
Stock Letta tools may reattach automatically; custom SMCP/zero1 tools must exist on the destination host before you expect tool calls to work.
Workers (Broca / SMCP)
Filesystem migration: copy ~/sanctum/agents/, smcp, update AGENT_ID, LETTA_AGENT_ID, and secrets from each .env. Stop Broca before copying sanctum.db if you need queue continuity.
Validation checklist
After import, verify more than message count:
| Check | Why it matters |
|---|---|
agent_type is letta_v1_agent |
Correct assistant_message vs reasoning_message labeling |
| Spot-check a known user-visible reply | Text under assistant_message.content, not buried in reasoning |
| Full message pagination | No 400 mid-list; tool-return JSON sanitized |
| Archival count matches source | Passages inserted via API |
| Core blocks present | Persona / human / summary blocks in ADE |
| Live chat + one tool call | End-to-end, not just static inspection |
Export roundtrip (GET …/export again) should show [reasoning, text] pairs, not split assistant rows.
What we stopped doing
Bulk Postgres DML to “clone” an agent was a historical mistake: it bypasses Letta’s importer contract and can corrupt ADE for every agent on that database. Sanctum’s standing rule: read-only SELECT for diagnosis; state changes through import/export and archival HTTP unless there is explicit break-glass approval for a specific statement.
Tooling and deeper references
Sanctum operators maintain:
- A migration runbook (full procedure, nginx/certbot, Broca placement).
- Agent-file pattern doc (collapse pairs, tool JSON, anti-patterns).
- Scripts such as
migrate_letta_cloud_agent_to_host.pyand post-import blocks/tools repair helpers in the monday-migration-redo workspace (public patterns, private credentials).
If you are building on SanctumOS: wire Broca for channel isolation, SMCP for tools, and treat Letta as the memory authority accessed only through its API—same on Cloud and self-hosted, relative to each server’s base URL and bearer token.