AgentGraft

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

http
POST /api/v1/admin/grafts/{graft_id}/import/
Authorization: Bearer sk_live_...
Content-Type: application/json
Same graft API key as agentgraft_sync. One graft per request.

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.

json
{
  "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"}
          ]
        }
      ]
    }
  ]
}
display_name and title are optional; external_id, messages, role, content, and created_at are not.

#Contract

  • **Additive.** If an EndUser with this external_id already exists in the graft, the existing row is reused and the supplied conversations are appended (counted under users_merged in the response). display_name on 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_id is 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_usd is always 0 on 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 errors array.

#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.

json
{
  "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]"}
  ]
}
Errors = a user was rolled back. Warnings = something was adjusted but the user was still imported.

#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:

bash
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"}
            ]
          }
        ]
      }
    ]
  }'
Run with dry_run: true first; flip to false once the summary looks right. Note the role aliases โ€” `human` maps to `user` and `bot` maps to `assistant`.

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.