Operations
Importing users and conversations
Backfill end users, conversations, and messages from another platform in one POST so migrated visitors arrive with history intact and the Dreamer builds long-term memory from day one.
5 min read
Most grafts start empty: the first visitor arrives, the Dreamer learns about them one conversation at a time, and memory accrues gradually. But if you're migrating from another chat platform โ or from a home-rolled bot โ every visitor lands cold. Their prior history is on the other side of a cutover; the recall agent has nothing to recall; their experience regresses the day you switch over.
The import endpoint fixes that. One POST backfills your end users, their conversations, and every message โ with original timestamps preserved โ so the Dreamer can extract long-term facts and the widget's sidebar renders the visitor's history from the moment they first load the widget on AgentGraft.
#Endpoint
POST /api/v1/admin/grafts/{graft_id}/import/
Authorization: Bearer sk_live_...
Content-Type: application/json
The import endpoint lives under /api/v1/admin/, same mount point as every other graft-scoped admin API. Auth is your existing graft API key โ no new token, no new scope. The key proves you own the graft; the graft owns the visitors you're backfilling.
#Request shape
The body is a nested JSON payload: users contain conversations, conversations contain messages. Each message carries its role, content, and original timestamp. A single request can move any number of users into one graft.
{
"dry_run": false,
"users": [
{
"external_id": "your-stable-user-id",
"display_name": "Jane Migrated",
"conversations": [
{
"title": "Billing question from March",
"messages": [
{"role": "user", "content": "How do I cancel?", "created_at": "2026-03-01T10:00:00Z"},
{"role": "assistant", "content": "From settings > billingโฆ", "created_at": "2026-03-01T10:00:05Z"}
]
}
]
}
]
}
#Contract
- **Additive.** If an
EndUserwith thisexternal_idalready exists in the graft, the existing row is reused and the supplied conversations are appended (counted underusers_mergedin the response).display_nameon the existing user is not overwritten. Conversations themselves are never deduped โ re-submitting the same payload creates duplicate conversations, so callers needing idempotency must track which conversations they have already sent. - **Your
external_idis your responsibility.** We store it verbatim; we never normalise or translate it. Keep it stable across migrations โ if it changes, you fork the visitor's identity. - **Original timestamps are preserved** on every message, so the Dreamer reads history in real chronological order and the widget sidebar sorts imported threads by their true last-activity time.
- **Token counts are recomputed** from message content so the dashboard's usage stats stay consistent.
cost_usdis always0on imports โ we didn't serve the content, so we don't retroactively bill you for it. - **Per-user isolation.** Each user is imported inside its own transactional savepoint, so one user's bad data (unknown role, invalid timestamp) rolls back just that user. Siblings still succeed. The failure lands in the response's
errorsarray.
#Role mapping
Source systems use all kinds of role names. The import endpoint silently maps common aliases to AgentGraft's canonical roles โ you don't need to normalise before sending.
userโ accepted as-is. Also:human,customer,end_user,enduser.assistantโ accepted as-is. Also:bot,ai,gpt,model.toolโ accepted as-is. Also:function(OpenAI's pre-2024 name for tool results).
#Alternation cleaning
AgentGraft's orchestrator expects conversations to follow user โ assistant alternation, with any number of tool messages sitting between an assistant's tool call and its follow-up turn. Most source systems store conversations this way already, but some drop assistant replies when a request failed โ leaving stranded user messages that break the live chat flow if they're imported verbatim.
The import endpoint fixes this on the way in: any user message whose next non-tool message is not assistant is dropped and a warning is recorded. This covers the three shapes of source noise โ two user messages in a row, a trailing user with no reply, and a user followed only by tool results. If a conversation becomes empty after cleaning, the whole conversation is skipped with a warning instead of being created as an empty shell.
#Dry-run mode
Set "dry_run": true to validate a payload without writing anything. The endpoint runs the full import โ serialiser validation, role normalisation, alternation cleaning, collision detection โ and returns the exact summary you'd see on a real run, then rolls back the transaction. No rows land, no Celery tasks queue. Useful for checking a payload end-to-end before committing to a real import.
#Response
Every import โ real or dry-run โ returns a 200 with a structured body describing exactly what happened (or would have happened). Per-user failures go in errors; per-conversation and per-message advisories go in warnings.
{
"dry_run": false,
"summary": {
"users_created": 12,
"users_merged": 1,
"users_failed": 0,
"conversations_created": 40,
"conversations_skipped_empty": 3,
"messages_created": 812,
"messages_dropped_orphan_user": 5
},
"warnings": [
{"user_external_id": "u-7", "conversation_index": 0,
"reason": "conversation empty after cleaning, skipped"},
{"user_external_id": "u-7", "conversation_index": 1, "message_index": 4,
"reason": "orphan user message dropped (no assistant response)"}
],
"errors": [
{"user_external_id": "u-99",
"reason": "unknown role 'overlord' at conversations[0].messages[2]"}
]
}
#What happens after a successful import
Two background pipelines fire automatically as soon as the outer transaction commits:
- **Embeddings** โ every imported user and assistant message is queued for polyembed. The recall agent uses those vectors to find semantically-related history when a migrated visitor asks a follow-up. Fires on every graft, independent of the memory toggle โ embeddings are search infrastructure, not curated facts.
- **Dreaming** โ only when the graft has
memory_enabled=True, each imported conversation is queued for a Dreamer pass. The Dreamer reads the full imported transcript and extracts durable facts (preferences, biography, goals, tone) into the visitor's long-term fact pool, exactly the same flow live chat uses.
#Idempotency
Idempotency applies at the user row only. Re-sending the same external_id will not create a duplicate EndUser โ the existing row is reused and the submitted conversations are appended under it (counted as users_merged). This lets you top up a visitor who was imported earlier with additional history, and it means partial failures are safe to retry for users that never landed.
Conversations are NOT deduped. Re-submitting the same conversation twice will create it twice. If you need retries to be safe at the conversation level, track which conversations you have already imported on your side and omit them from later calls.
#Size guidance
There are no hard rate or size limits on the endpoint itself, but the request is synchronous โ the HTTP response doesn't return until every row is written and transaction.on_commit has scheduled the background pipelines. Keep batches within what your reverse proxy's timeout can comfortably absorb: hundreds of users, thousands of messages per request is the sweet spot.
For large-scale migrations (tens of thousands of users), chunk client-side by visitor cohort and pace the requests. Each request is independent and the idempotency guarantee means chunks can be retried freely.
#Round-trip: visitors can export their own data
Every visitor already has a one-click "Export my data" button in the widget's settings sheet. Clicking it downloads a JSON file in exactly the shape the import endpoint accepts โ no transformation needed. A visitor who wants to move to a different chat product, or a graft owner running a server migration, can feed that file straight into the import endpoint on a fresh graft and the visitor's history re-hydrates verbatim.
- The widget endpoint is
GET /api/v1/widget/conversations/export/โ authenticated with the same HMAC headers the widget already carries, scoped to the visitor's own data. - System messages are filtered out (they're rejected by the import endpoint anyway).
- Pin/archive state and long-term facts are NOT exported โ the import schema doesn't accept them. Facts rebuild themselves when the Dreamer re-processes the imported conversations.
#Worked example: migrating from an Intercom export
Concretely, here's the shape of a small cURL import. Swap $GRAFT_ID for your graft's UUID and $API_KEY for the sk_live_... you saved at graft creation:
curl -X POST "https://agentgraft.com/api/v1/admin/grafts/$GRAFT_ID/import/" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"dry_run": true,
"users": [
{
"external_id": "intercom-user-9912",
"display_name": "Ada",
"conversations": [
{
"title": "Export question",
"messages": [
{"role": "human", "content": "How do I export my data?",
"created_at": "2025-11-03T14:22:00Z"},
{"role": "bot", "content": "Settings > Data > Exportโฆ",
"created_at": "2025-11-03T14:22:04Z"}
]
}
]
}
]
}'
The dry run returns a summary body identical in shape to a real import โ counts, warnings, errors โ without touching the database or kicking off any background work. When the output matches your expectations, remove the dry_run flag (or set it to false) and re-issue the same request.