Migrate from LangGraph to OSYRA Memory (OME)
This guide maps LangGraph's checkpoint state onto OSYRA Memory (OME) claims and walks through a migration end to end. It is the reference implementation for agent-framework migrations: the AutoGen and CrewAI guides describe themselves as deltas against this document.
| Aspect | Value |
|---|---|
| Migration pattern | Agent-framework memory-adapter |
| Source scope | LangGraph BaseCheckpointSaver state (one graph per run) |
| Source-side write access required | Yes — the adapter writes to the graph's checkpoint store going forward |
| Fidelity guarantees | Thread reconstruction: message order preserved within a thread; role-tagging preserved; tool-call outputs preserved as separate claims |
| Embedding policy | OME embeds on claim ingest; LangGraph does not expose embeddings to migrate |
| Rollback | Adapter-disable plus claim-revoke (see §8) |
The two artifacts:
Artifact 1 — One-shot CLI for historical back-fill
`osyra migrate langgraph` reads the checkpoint store and emits .ome bundles.
Artifact 2 — Runtime adapter shim
`OsyraCheckpointSaver` implements LangGraph's `BaseCheckpointSaver`.
It wraps the existing saver and emits OME claims on every state mutation.§1 — Prerequisites
Source side (LangGraph)
- A LangGraph application using a supported
BaseCheckpointSaverimplementation:MemorySaver(in-memory, ephemeral — back-fill does not apply; adapter-only deployment)SqliteSaverPostgresSaver(vialanggraph-checkpoint-postgres)- Custom savers: the adapter is interface-compatible, but the back-fill CLI ships only common-case readers.
- Read access to the checkpoint store (for back-fill).
- Write access to the checkpoint store (for the live adapter).
- The list of thread IDs to migrate, or
--all-threadsfor a full back-fill. - LangGraph 0.2.x or later (the
BaseCheckpointSaverinterface in 0.1.x is materially different and not supported).
OSYRA side (OME)
- A workspace ID.
- A migration signer DID for back-fill (
did:osyra:ws:<uuid>:signer:migration). - A separate signer DID for the live adapter (recommended distinct from the back-fill signer):
- Format example:
did:osyra:ws:<uuid>:signer:agent-langgraph-<deployment-id>. Provision a dedicated runtime signer DID. - Required OPA scope:
osyra:ome:claim:create. It does not needosyra:ome:bundle:import— the adapter writes claims directly, not bundles.
- Format example:
- A tag plan for runtime claims (separate from back-fill):
- Suggested:
langgraph:<deployment-id>:thread:<thread-id>. - Back-fill uses the same tag schema; the dual-write window is collapsed by tag overlap (no duplication of historical messages).
- Suggested:
Credential handling
Supply credentials through environment variables sourced from a secret store, never as command-line flags:
export LANGGRAPH_PG_URL="$(aws secretsmanager get-secret-value --secret-id langgraph/pg --query SecretString --output text)"Network
- Outbound HTTPS to your checkpoint store (varies by saver).
- Outbound gRPC to
ome.<region>.osyra.ai:443from both the back-fill CLI host and any process running the live adapter. - For runtime: a low-latency network path between the agent host and OME, so the per-step claim-create fits within your agent's step-timeout budget.
§2 — Source-system schema (LangGraph)
LangGraph organizes data as graph → thread → checkpoint → state. State is typed by the application's StateGraph definition; the framework wraps it for persistence.
Per-checkpoint shape
| Source field | Source type | Treatment |
|---|---|---|
| thread_id | string | Becomes the tag suffix; preserved verbatim |
| checkpoint_id | UUID string | Becomes claim.metadata.checkpoint_id (audit trail) |
| state.messages (typed message list) | list[BaseMessage] | Each message becomes one claim |
| state.<custom> | application-defined typed fields | Passthrough into metadata.source_state.<name> as JSON |
| parent_checkpoint_id | UUID string | Becomes claim.metadata.parent_checkpoint_id (lets you reconstruct branches) |
| next (next-node hint) | string | Discarded — runtime control flow, not memory |
Per-message shape
LangGraph messages are typed (HumanMessage, AIMessage, SystemMessage, ToolMessage, FunctionMessage):
| Message type | Claim type | claim.metadata.role | Body source |
|---|---|---|---|
| HumanMessage | memory.observation | human | .content |
| AIMessage (no tool calls) | memory.observation | ai | .content |
| AIMessage (with tool_calls) | memory.observation (one) + memory.fact (one per tool call) | ai + tool:<name> | .content for the observation; tool_call.args for facts |
| SystemMessage | memory.observation | system | .content |
| ToolMessage | memory.fact | tool:<name> | .content (the tool's return value) |
| FunctionMessage (deprecated in 0.2+) | memory.fact | function:<name> | .content |
Concepts that are not preserved
| Source concept | Why not preserved | Implication |
|---|---|---|
| Graph topology (node definitions) | Not memory — it is code | None |
| interrupt_before / interrupt_after config | Not memory | None |
| Streaming events (token-level deltas) | Only complete messages are captured | If you stream, the adapter waits for message completion before emitting a claim |
| Long-term memory store (@with_store decorator) | This is memory, but is migrated through a separate path | If you use the experimental long-term store, that data needs its own migration |
Schema discovery: for back-fill the CLI uses BaseCheckpointSaver.list() to enumerate threads and .get_tuple() to fetch each. Dry-run reports the thread count, the (sampled) message count per thread, and the custom-state field names detected.
§3 — OSYRA target mapping
This section is the contract for both the CLI and the adapter shim.
Per-message mapping
| Source field | OME claim field | Transformation | Required/Optional |
|---|---|---|---|
| BaseMessage.content | body | NFC normalize; reject if > 64 KB; for multimodal content (list of blocks), text blocks are concatenated and non-text is logged to skipped | Required |
| BaseMessage.id (0.2+) | external_id | Verbatim copy if present; absent → no external_id (older versions may lack stable IDs) | Optional |
| (derived) message type | claim.type | Per the table in §2 | Required |
| (derived) message type | claim.metadata.role | Per the table in §2 | Required |
| (derived) thread ID | tag | Format langgraph:<deployment-id>:thread:<thread-id> | Required |
| checkpoint_id | claim.metadata.checkpoint_id | UUID string passthrough | Required |
| parent_checkpoint_id | claim.metadata.parent_checkpoint_id | UUID string passthrough | Optional (the root checkpoint has none) |
| (derived) message sequence | claim.metadata.message_seq | Integer; 0-indexed position within state.messages | Required |
| state.<custom> (non-messages keys) | claim.metadata.source_state.<name> | JSON-serialize; coerce to string if not JSON-serializable | Optional |
| message-creation timestamp (if exposed) | observed_at | Else falls back to the checkpoint write timestamp | Optional |
Tool-call expansion
A single AIMessage with N tool calls expands to 1 + N claims:
- The observation claim (the model's natural-language response, if any):
claim.type = memory.observation,metadata.role = ai. - One fact claim per tool call:
claim.type = memory.fact,metadata.role = tool:<tool_name>,metadata.tool_call_id = <id>, body = serializedtool_call.args.
Tool-call outputs (ToolMessage in the message list) become their own memory.fact claims with metadata.tool_call_id matching the originating call, so you can pair calls and results in OME.
Claim shape
{
"type": "memory.observation",
"external_id": "msg_01abc...",
"body": "Hi! How can I help you today?",
"tag": "langgraph:prod-deployment-1:thread:thread_xyz",
"observed_at": 1716566400000,
"metadata": {
"source": "langgraph",
"role": "ai",
"checkpoint_id": "ckpt_abc...",
"parent_checkpoint_id": "ckpt_def...",
"message_seq": 7,
"deployment_id": "prod-deployment-1",
"thread_id": "thread_xyz"
}
}Bundle shape
- Back-fill: one
.omebundle per thread, or per batch-size if a thread exceeds it (very long threads). - Bundle signer: the migration signer DID.
- Runtime: each claim is created via a single
osyra ome create-claimgRPC call; the runtime adapter does not bundle. Bundling is a back-fill optimization; runtime needs the durability of per-claim acknowledgement.
Adapter interface (Python)
The osyra-langgraph package ships OsyraCheckpointSaver, which wraps an existing BaseCheckpointSaver and intercepts the put() method to emit claims:
from langgraph.checkpoint.postgres import PostgresSaver
from osyra_langgraph import OsyraCheckpointSaver
inner_saver = PostgresSaver(connection_string=...)
saver = OsyraCheckpointSaver(
inner=inner_saver,
workspace_id="ws_abc123",
signer_did="did:osyra:ws:abc123:signer:agent-langgraph-prod",
deployment_id="prod-deployment-1",
# Optional: customize the tag format
tag_format="langgraph:{deployment_id}:thread:{thread_id}",
)
# Use the saver exactly as you would the inner saver; OSYRA writes are transparent.
graph = StateGraph(MyState).compile(checkpointer=saver)Adapter failure modes:
- OME unreachable: the adapter does not block the agent. Claims are buffered in a local on-disk queue and flushed when OME is reachable. Buffer size is configurable (default 10K claims).
- OME rate-limited: the adapter retries with exponential backoff.
- Inner-saver failure: passed through unchanged — the adapter does not catch inner-saver exceptions.
Override flags (CLI back-fill)
| Flag | Default | Effect |
|---|---|---|
| --source-saver-url <url> | (read from env) | Connection string for the checkpoint store |
| --source-saver-type <type> | auto | sqlite, postgres, memory, or auto |
| --threads <ids> | (all) | Comma-separated thread IDs to migrate |
| --deployment-id <id> | (required) | Identifies the LangGraph deployment; goes into the tag |
| --include-custom-state | true | Whether to pass through non-messages state fields |
| --max-message-len <bytes> | 65536 | Per-message body size cap; oversized messages are skipped |
§4 — CLI walkthrough
Dry-run
export OSYRA_WORKSPACE_ID="ws_abc123"
export OSYRA_SIGNER_DID="did:osyra:ws:abc123:signer:migration"
export LANGGRAPH_PG_URL="$(aws secretsmanager get-secret-value --secret-id langgraph/pg --query SecretString --output text)"
osyra migrate langgraph \
--source-saver-type postgres \
--source-saver-url "$LANGGRAPH_PG_URL" \
--deployment-id prod-deployment-1 \
--dry-runSingle-thread import
osyra migrate langgraph \
--source-saver-type postgres \
--source-saver-url "$LANGGRAPH_PG_URL" \
--deployment-id prod-deployment-1 \
--threads thread_xyz \
--output-dir ./langgraph-stage1/Verify with osyra ome verify ./langgraph-stage1/*.ome and inspect 5–10 claims manually.
Full back-fill (all threads)
osyra migrate langgraph \
--source-saver-type postgres \
--source-saver-url "$LANGGRAPH_PG_URL" \
--deployment-id prod-deployment-1 \
--batch-size 1000 \
--output-dir ./langgraph-full/Deploy the runtime adapter
# This is what your LangGraph application code looks like after migration.
# The shim is a drop-in wrapper.
from langgraph.checkpoint.postgres import PostgresSaver
from osyra_langgraph import OsyraCheckpointSaver
inner_saver = PostgresSaver(...)
saver = OsyraCheckpointSaver(
inner=inner_saver,
workspace_id="ws_abc123",
signer_did="did:osyra:ws:abc123:signer:agent-langgraph-prod",
deployment_id="prod-deployment-1",
)
graph = StateGraph(MyState).compile(checkpointer=saver)Recommended deployment order:
- Deploy the runtime adapter to a single canary instance. Watch OME for incoming claims.
- Run the full back-fill CLI for all historical threads.
- Verify §7 round-trip on a sample of threads (both back-filled and runtime-emitted).
- Deploy the runtime adapter to the remaining instances.
§5 — Expected output
CLI back-fill
osyra-migrate langgraph
Workspace: ws_abc123
Signer DID: did:osyra:ws:abc123:signer:migration
Source saver: postgres (postgresql://...:5432/checkpoints)
Deployment ID: prod-deployment-1
Threads found: 142
Total messages: 48,219 (across all threads, all checkpoints)
Tool calls: 11,037 (will expand to +11,037 fact claims)
Batch size: 1000
Bundles planned: ~60 (one per thread, plus extras for long threads)
Output dir: ./langgraph-full/
[001/142] Thread thread_aaa ... 47 messages, 3 tool calls ... bundle CID: bafy2bzaceabc...001
[002/142] Thread thread_bbb ... 312 messages, 89 tool calls ... bundle CID: bafy2bzaceabc...002
...
[142/142] Thread thread_zzz ... 8 messages, 0 tool calls ... bundle CID: bafy2bzaceabc...142
Summary:
Total observation claims: 48,219
Total fact claims: 11,037 (tool calls + tool results)
Total claims: 59,256
Total bundles: 87Runtime adapter
The adapter does not write to stdout. Observe its activity via OME-side metrics:
$ osyra workspace metrics ws_abc123 --since 1h
claim_create_rate: 128 claims/min (avg over 1h)
adapter_buffer_depth: 0 (no backlog)
adapter_signer: did:osyra:ws:abc123:signer:agent-langgraph-prod§6 — Caveats
1. Dual-write window during cutover
If you back-fill and then deploy the adapter, there is a window where new conversations write to the LangGraph store but not to OME. If you deploy the adapter first and then back-fill, both writes happen concurrently — but the tag overlap (back-fill and runtime use the same tag schema) means claims collide cleanly on external_id when both are present.
Recommended order: deploy the adapter first (live), then back-fill (catches everything older). The dual-write overlap is harmless because:
- Newer messages already in OME (via the adapter) have
external_idset. - Back-fill encounters them with the same
external_id, and OME idempotency rejects the duplicate. - Older messages without
external_idcannot collide because noexternal_idis present; light duplication of older history is tolerated.
2. Runtime adapter step latency
The adapter adds work to each agent step. If your agent runs on tight latency budgets (for example, a real-time voice agent), confirm the added work fits your step timeout. Mitigations:
- Run the adapter in async-only mode (default): claim emission is fire-and-forget after local buffering.
- In async mode, claim-emission failures are observable only via the local buffer-depth metric, not via the agent's exception path.
3. Custom state fields are coarse-grained
LangGraph applications often store typed application state alongside messages (for example, state.user_preferences). Custom state is serialized as JSON into claim.metadata.source_state.<name> per claim, so every claim within a checkpoint duplicates the entire custom-state blob. This preserves fidelity but is wasteful for large state objects; keep custom state small per checkpoint.
4. Streaming token-level events are not captured
The adapter emits a claim per complete message, not per token. If you rely on token-level audit, the adapter does not meet that requirement.
5. MemorySaver is ephemeral
If your application uses the in-memory MemorySaver, there is no historical state to back-fill. Adapter-only deployment is the only meaningful path.
6. Sequencing within a checkpoint
message_seq is preserved (0-indexed within state.messages). Cross-checkpoint ordering is preserved via parent_checkpoint_id linkage; full thread reconstruction requires walking the checkpoint DAG. The osyra ome reconstruct-thread command does this; manual reconstruction requires following the parent chain.
7. Idempotency
Re-running back-fill on an already-back-filled deployment is safe — external_id (where present) deduplicates. Re-deploying the adapter does not duplicate either, because the adapter only writes new claims.
8. GDPR Art-17
A successful migration creates claims subject to OME's tombstone-based erasure path. Erasure is irreversible. See the Pinecone GDPR caveat for the underlying semantics.
9. Adapter signer key custody
The runtime adapter holds a signing-key identity. Key custody follows your workspace's KMS configuration. If the adapter host is ephemeral (a Kubernetes pod, autoscaled), ensure the IRSA or equivalent role is provisioned correctly. The adapter does not fall back to other signers if its primary is unavailable — failing loudly is intentional.
§7 — Round-trip verification
Sample sizing
| Source thread count | Recommended sample size | |---|---| | ≤ 100 | All threads | | 100 – 10K | 1% (minimum 5 threads) | | > 10K | 0.1% (minimum 10 threads) |
Protocol
Pattern-B verification has two phases — history fidelity (back-fill) and runtime fidelity (adapter).
History fidelity
Sample threads
Pick a random sample of thread IDs.
Fetch from LangGraph
checkpoints = list(saver.list({"configurable": {"thread_id": thread_id}}))Compute expected claim count
For each checkpoint, walk state.messages and compute the expected claim count (len(messages) plus the count of tool_calls across AIMessages).
Query OME by tag
osyra ome list-by-tag \
--workspace ws_abc123 \
--tag "langgraph:prod-deployment-1:thread:$THREAD_ID"Verify
Verify the claim count matches, message_seq integers are contiguous 0..N-1 within each checkpoint, and the role distribution matches. Spot-check claim.body byte-equality against BaseMessage.content for a sample of messages.
Runtime fidelity
- From a canary agent step, capture the message about to be emitted.
- Wait for the adapter buffer to flush (default under one second).
- Query OME for the most-recent claim on the thread and confirm the match.
Acceptance criterion
- History fidelity: at least 99.5% of sampled checkpoints have an exact claim-count match (lower than the vector-store guides' 99.9% to accommodate edge cases in tool-call expansion).
- Runtime fidelity: 100% of canary steps observed appearing in OME within five seconds of agent completion.
When verification fails
- History-fidelity failures are usually a tool-call expansion issue (for example, a tool call inside a list-of-blocks
content). Inspect the failing thread'sAIMessagepayloads. - Runtime-fidelity failures are usually network or signer-DID auth. Check adapter logs for buffer-depth growth.
§8 — Rollback
Pattern-B rollback differs from vector-store rollback — there is no single bundle CID to tombstone, because the runtime adapter has been writing claims piecewise.
Adapter-disable
The first step in any Pattern-B rollback is to stop the runtime adapter writing new claims:
- Update the application code to use the inner saver directly (remove the
OsyraCheckpointSaverwrapper). - Re-deploy the application.
- Confirm via OME metrics that
claim_create_ratefor the runtime signer drops to zero.
Manual claim-revoke (selective)
To remove claims from a specific time window (for example, the adapter emitted bad data for an hour), use OME's bulk-tombstone-by-tag-and-time-range:
osyra ome tombstone-by-query \
--workspace ws_abc123 \
--tag "langgraph:prod-deployment-1:thread:*" \
--created-after "2026-05-24T10:00:00Z" \
--created-before "2026-05-24T11:00:00Z" \
--reason adapter_rollback \
--reason-detail "Adapter emitted incorrect message_seq during the deploy window"Tombstoning is irreversible.
Back-fill rollback
To undo a back-fill (for example, you ran it with the wrong deployment_id), tombstone the bundles emitted by back-fill:
for cid in $(cat ./langgraph-full/.cids.txt); do
osyra ome tombstone-bundle \
--workspace ws_abc123 \
--bundle-cid "$cid" \
--reason backfill_rollback
donePost-rollback state
- OME: claims tombstoned per the operation chosen; historical roots still attest prior existence.
- LangGraph checkpoint store: unchanged at all times. The adapter wraps the inner saver, so disabling the wrapper leaves the inner state intact and current.
- Application: continues running, just without OSYRA Memory attestation.
§9 — Troubleshooting
1. Adapter buffer growing unbounded
- Symptom:
osyra workspace metricsshowsadapter_buffer_depthincreasing without dropping. - Likely cause: OME unreachable from the adapter host, the workspace quota saturated, or signer-DID auth failing.
- Diagnostic: the adapter local log (
$OSYRA_ADAPTER_LOG_DIR/adapter.log) shows the reason;osyra iam describe-signer-did <runtime-signer-did>checks the signer state. - Fix: address the root cause (network, quota, auth). The buffer drains automatically once OME is reachable. If the buffer hits its cap, the adapter drops new claims with a logged warning — treat this as a data-loss event.
2. Tool-call claims missing
- Symptom: verification finds fewer claims than expected — expected
N + M(M = tool calls), got onlyN. - Likely cause: tool calls are stored in a non-standard field (some applications use the deprecated
function_callinstead oftool_calls). - Diagnostic: inspect an
AIMessage: does it have.tool_calls(list) or.additional_kwargs.function_call(dict)? - Fix: migration assumes 0.2+
tool_calls. If your application uses the deprecatedfunction_call, do not run back-fill until the CLI supports it.
3. Custom state field too large
- Symptom: back-fill logs
WARN: custom-state field 'X' is 47 KB per checkpoint; this duplicates per-claim. - Likely cause: the application stores large blobs in custom state.
- Diagnostic: check a sample checkpoint's state dictionary size.
- Fix: disable custom-state passthrough (
--no-include-custom-state) and lose that fidelity, or refactor the application to use a separate store for large blobs.
4. Adapter step latency exceeds budget
- Symptom: application step timeouts after adapter deployment.
- Likely cause: the adapter is in sync mode, is buffering to slow disk, or OME claim-create latency to your region is higher than expected.
- Diagnostic: check the adapter's
adapter_step_overhead_p99_msmetric. - Fix: switch to async-only mode (the default), move the adapter buffer to a faster disk, and confirm your region.
5. Signer DID auth fails intermittently
- Symptom: the adapter logs sporadic
OSY-AUTH-1071for the runtime signer DID; most calls succeed. - Likely cause: a signer-DID grant expired (PRO grants cap at 90 days, ENT at 365 days), Proof-of-Possession missing from a request, or a signer-public-key cache miss on a cold path.
- Diagnostic:
osyra ome describe-signer-pub-cache --did <runtime-signer-did>shows the hit/miss ratio;osyra iam describe-signer-did <runtime-signer-did>shows grant-expiry and Proof-of-Possession status. - Fix: re-grant if the grant expired (renew via the OSYRA Console), and pre-warm the signer cache by issuing a single claim-create after adapter start.