Importing conversations

Move a transcript from another system into Runtype so an Agent’s next turn continues where the old system left off. The transcript is stored on the conversation, and the turn after the import replays it server-side.

The transcript shape

POST /v1/conversations and PUT /v1/conversations/{id} accept a messages array. Each message is:

FieldNotes
rolesystem, user, assistant or tool.
contentA plain string, or an array of text / image / file / reasoning parts.
toolCallsAssistant messages only. { toolCallId, toolName, args }.
toolResultsTool messages only, and required on them. { toolCallId, toolName, result }.
idOptional. Minted when omitted. Use the source system’s message id here to make the import resumable.
createdAtOptional timestamp string, kept exactly as sent. ISO 8601 when Runtype mints it.
authorOptional { type: "end_user" | "operator" | "agent" | "system", id?, externalId?, name? }.
metadataOptional key/value pairs kept with the message.

author records who wrote the message in the system you are migrating from, so a role: "user" turn stays attributable to a specific person. It is stored and returned as you sent it, and it is never replayed to a model.

The following POST /v1/conversations body imports a conversation whose assistant turn called a tool, with the tool result in place, so the next turn can replay the whole exchange:

1{
2 "title": "Refund for order 1182",
3 "agentId": "agent_01j9r4x0h7f9k4b2n8t6q3m5cd",
4 "source": { "system": "intercom", "externalId": "conv_88213" },
5 "messages": [
6 { "role": "user", "content": "Where is my refund for order 1182?" },
7 {
8 "role": "assistant",
9 "content": "",
10 "toolCalls": [
11 { "toolCallId": "call_1", "toolName": "lookup_order", "args": { "orderId": "1182" } }
12 ]
13 },
14 {
15 "role": "tool",
16 "content": "",
17 "toolResults": [
18 {
19 "toolCallId": "call_1",
20 "toolName": "lookup_order",
21 "result": { "status": "refunded" }
22 }
23 ]
24 },
25 { "role": "assistant", "content": "Your refund was issued on August 30." }
26 ]
27}

Tool pairs must be complete

Every model provider rejects a half-finished tool exchange, so Runtype rejects one at write time rather than at the next turn:

  • A tool message must carry at least one toolResults entry.
  • Every assistant toolCalls entry must be answered by a tool message before the next non-tool message.
  • No result may answer an unknown toolCallId, and no toolCallId may be answered twice.
  • toolCalls is legal only on an assistant message; toolResults only on a tool message.

A transcript that breaks any of these answers 400:

1{
2 "error": "Validation Error",
3 "code": "CONVERSATION_TRANSCRIPT_INVALID",
4 "message": "messages[1]: assistant toolCalls entry has no matching tool result before the next non-tool message (toolCallId call_1)",
5 "details": {
6 "issues": [{ "index": 1, "toolCallId": "call_1", "reason": "tool_call_without_result" }]
7 }
8}

If the source system does not record tool calls, import the text turns only. A text-only transcript is always valid.

An imported reasoning part is stored, but it is left out of the replay: a model’s thinking signature is bound to the provider that produced it, so a foreign one would fail the next turn.

Binding an agent and recording provenance

agentId binds the conversation to one Agent and must name an Agent you own; anything else answers 404. source records where the transcript came from ({ system, externalId?, importedAt? }). Both are stored on the conversation’s metadata as metadata.agentId and metadata.importSource.

Re-running an import is safe

source.externalId is an idempotency key. If a conversation with the same source.system and source.externalId already exists, POST /v1/conversations does not create a second one: it answers 200 with the existing conversation and "imported": false. A fresh create answers 201 with "imported": true. A unique index backs this, so two POSTs racing each other still produce exactly one conversation.

1{
2 "id": "rec_01j9r4x0h7f9k4b2n8t6q3m5cd",
3 "imported": false,
4 "metadata": { "importSource": { "system": "intercom", "externalId": "conv_88213" } }
5}

Use the source system’s own conversation id as externalId and a migration script can be restarted from the top without duplicating anything. A create without source.externalId is never deduplicated.

Importing a long transcript in chunks

PUT /v1/conversations/{id} takes messagesMode:

  • replace (the default) swaps the whole stored transcript for what you send.
  • append adds your messages to the end of the stored transcript.

In append mode a message whose id the conversation already stores is skipped, so re-sending a chunk after a timeout adds nothing. Give every message the source system’s message id and each chunk becomes retryable on its own.

The combined transcript, stored plus incoming, is validated as one transcript. A tool message in this chunk may answer a toolCallId the conversation already stores; a result answering nothing is still rejected with 400 CONVERSATION_TRANSCRIPT_INVALID. Because the combined transcript is checked whole, keep an assistant toolCalls message and the tool message answering it in the same chunk: a chunk that ends on an unanswered tool call is rejected, exactly as it would be in replace mode.

1{
2 "messagesMode": "append",
3 "messages": [{ "id": "src_412", "role": "user", "content": "Can you resend the receipt?" }]
4}

The recipe for a large migration: POST /v1/conversations with source and the first chunk, then PUT each later chunk with messagesMode: "append", in order.

Size limits

A conversation is one row, and the limits are checked against the transcript the write would leave stored, so an append is measured against stored plus incoming:

LimitValue
Messages on one conversation10,000
Content of a single message1 MB
All message content on one conversation32 MB
The conversation’s metadata256 KB

A write that would exceed any of these answers 400 and changes nothing. Split a transcript that does not fit across several conversations, or drop the oldest turns.

What import does not do

An import is a write, not a run. It does not fire surface webhooks, start an Agent turn, or meter execution usage. Only Runtype’s own product analytics record that the conversation was created or updated.

Two things the transcript itself does not carry, and where they live instead:

  • Memory. Long-term facts an Agent should already know are seeded through the memory API (POST /v1/runtime/memory/save, read back with POST /v1/runtime/memory/recall), not through messages. Those routes are an Enterprise-plan feature.
  • Attachments. Send them inline as image or file content parts carrying base64 data, and a large one is offloaded to asset storage automatically as the write lands; or reference an asset you have already uploaded with an asset_ref part. Runtype does not fetch attachment URLs for you.

Continuing the conversation

The turn after an import calls POST /v1/agents/{id}/execute with history: "stored", the conversationId returned by the import, and only the new user message. The server replays the stored transcript ahead of the delta, so the imported tool pairs reach the provider. The delta is appended to the stored transcript when the turn is admitted, so a failed turn still keeps the question, and the Agent’s reply is appended once the turn completes. A turn that pauses (a client-side tool, or a detached run) appends no reply.

1{
2 "history": "stored",
3 "conversationId": "rec_01j9r4x0h7f9k4b2n8t6q3m5cd",
4 "messages": [{ "role": "user", "content": "Can you resend the receipt?" }]
5}

With the default history: "inline", the caller keeps owning the transcript and sends the whole messages array on every turn.

POST /v1/dispatch takes the same history field, alongside agent.agentId and conversationId:

1{
2 "agent": { "agentId": "agent_01j9r4x0h7f9k4b2n8t6q3m5cd" },
3 "conversationId": "rec_01j9r4x0h7f9k4b2n8t6q3m5cd",
4 "history": "stored",
5 "messages": [{ "role": "user", "content": "Can you resend the receipt?" }]
6}

A stored-history dispatch needs a saved Agent: an inline agent, a flow dispatch, a Claude Managed agent, Prefer: respond-async, a join, and a resume or client-tool continuation are all refused with STORED_HISTORY_UNSUPPORTED. As on the execute route, a turn that pauses or goes detached appends no reply: the question is stored, the answer is not, so read the reply off the execution’s events and, if you need it in the transcript, append it yourself with messagesMode: "append".

Dispatch messages also accept the tool role with toolCalls and toolResults, in the same shape the transcript uses, so a caller keeping history: "inline" can still replay full-fidelity tool turns on every request.

Next steps

Continue with these guides: