> ## Documentation Index
> Fetch the complete documentation index at: https://docs.maximem.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Maximem Synap is a managed memory layer for AI agents: it ingests conversations and documents, extracts structured memory, and returns scoped context to the agent at retrieval time.
> The Python and JavaScript/TypeScript SDKs are the supported way to use Synap. Write integration code with SDK calls, not raw HTTP requests.
> Start with the Quickstart: https://docs.maximem.ai/getting-started/quickstart.md. Every SDK method is documented in the SDK reference: https://docs.maximem.ai/sdk-reference/overview.md.

# Conversations and transcripts

> What conversation content to send to Synap, and which call fits live turns, buffered turns, finished transcripts and one-off imports.

Send Synap the turns your user and your agent exchange. Synap keeps them as the conversation's short-term context and turns them into long-term memory in the background, so your agent can pick up where it left off and remember the person next time. Which call you use depends on when you have the content: turn by turn while the conversation runs, a few turns at a time, or the whole conversation after it ends.

## Choose a call

| You have | Call | Goes over |
| - | - | - |
| Live turns, one at a time | [`conversation.record_message`](/sdk-reference/conversation/record-message), or [`instance.send_message`](/sdk-reference/instance/send-message) on an open stream | REST, or the gRPC stream ([which one](/concepts/grpc-vs-rest)) |
| Several turns at once | [`conversation.record_messages_batch`](/sdk-reference/conversation/record-messages-batch) | REST |
| A finished call or session | [`conversation.ingest_transcript`](/sdk-reference/conversation/ingest-transcript) | REST |
| A conversation from another system, once, as a document | [`memories.create`](/sdk-reference/memories/create) with `document_type="ai-chat-conversation"` | REST |
| History that already exists, in bulk | [Import existing history](/getting-started/bootstrap) | REST |

Use one path per conversation. Every path above already turns the content into long-term memory, so sending the same conversation down two of them makes Synap extract it twice. [Send each conversation once](#send-each-conversation-once) explains the cases.

## What to send

* **Both sides of the conversation.** Record the user's turn and your agent's reply. Together they are the conversation Synap serves back as short-term context and later extracts memories from; a reply without its question, or a question without its answer, loses half of that.
* **Two roles only: `user` and `assistant`.** There is no `system` role. The Python SDK raises `ValueError` for any other role before sending, and the server refuses it with HTTP 422 (`InvalidInputError`). Your system prompt is instructions you control, not something the person said, so leave it out.
* **Not tool calls or reasoning.** Those have their own calls; see [Traces and tool calls](/capture/traces-and-tool-calls).
* **The person, on every call.** Send `user_id` every time. On a B2B instance also send `customer_id` on every call; without it the server refuses the turn (HTTP 400, `InvalidInputError`, on the REST calls). On a B2C instance send `user_id` only: a `customer_id` is refused, usually by the SDK before the request leaves your process. See [B2C vs B2B](/concepts/memory-scopes#b2c-vs-b2b-which-scopes-apply-to-you).

## Record live turns

Record each turn as soon as you have it: the user's message when it arrives, and the assistant's reply after your agent produces it. Use one `conversation_id` for every turn of a conversation. `record_message` requires it to be a UUID and raises `InvalidConversationIdError` (a subclass of `InvalidInputError`) for anything else, before sending.

```python Python theme={null}
import uuid
from maximem_synap import MaximemSynapSDK

sdk = MaximemSynapSDK()  # reads SYNAP_API_KEY from the environment
await sdk.initialize()

# One UUID per conversation, reused for every turn
conversation_id = str(uuid.uuid4())

await sdk.conversation.record_message(
    conversation_id=conversation_id,
    role="user",
    content="Can you move my delivery to Saturday? Weekdays don't work for me.",
    user_id="user_123",
    # customer_id="acme",  # B2B only: required there, refused on B2C
)

reply = "Done. Your delivery is now on Saturday."
await sdk.conversation.record_message(
    conversation_id=conversation_id,
    role="assistant",
    content=reply,
    user_id="user_123",
)
```

```typescript TypeScript theme={null}
import { randomUUID } from 'node:crypto';
import { SynapClient } from '@maximem/synap-js-sdk';

const sdk = new SynapClient(); // reads SYNAP_API_KEY from the environment
await sdk.initialize();

// One UUID per conversation, reused for every turn
const conversationId = randomUUID();

await sdk.conversation.record_message({
  conversation_id: conversationId,
  role: 'user',
  content: "Can you move my delivery to Saturday? Weekdays don't work for me.",
  user_id: 'user_123',
  // customer_id: 'acme', // B2B only: required there, refused on B2C
});

const reply = 'Done. Your delivery is now on Saturday.';
await sdk.conversation.record_message({
  conversation_id: conversationId,
  role: 'assistant',
  content: reply,
  user_id: 'user_123',
});
```

`record_message` returns `message_id`, `conversation_id`, `session_id` and `recorded_at` once the server has stored the turn.

### Over the gRPC stream

If your agent keeps a stream open with [`instance.listen`](/sdk-reference/instance/listen) for [real-time anticipation](/concepts/real-time-anticipation), send the turns on that stream with `instance.send_message` instead. Events with `event_type` `user_message` or `assistant_message` are stored in the conversation the same way `record_message` stores them. Set `role` and `event_type` together: the defaults are `"user"` and `"user_message"`, so the reply needs both changed.

```python Python theme={null}
await sdk.instance.listen()

await sdk.instance.send_message(
    content="Can you move my delivery to Saturday?",
    role="user",
    event_type="user_message",
    conversation_id=conversation_id,  # the same UUID you use for fetches
    user_id="user_123",
)

await sdk.instance.send_message(
    content=reply,
    role="assistant",
    event_type="assistant_message",
    conversation_id=conversation_id,
    user_id="user_123",
)
```

```typescript TypeScript theme={null}
await sdk.instance.listen();

await sdk.instance.send_message({
  content: 'Can you move my delivery to Saturday?',
  role: 'user',
  event_type: 'user_message',
  conversation_id: conversationId, // the same UUID you use for fetches
  user_id: 'user_123',
});

await sdk.instance.send_message({
  content: reply,
  role: 'assistant',
  event_type: 'assistant_message',
  conversation_id: conversationId,
  user_id: 'user_123',
});
```

Three differences from `record_message` matter:

* `send_message` raises `ListeningNotActiveError` if `listen()` has not been called.
* It returns once the event is on the stream. The server stores the turn moments later, not before the call returns.
* The SDK does not check that the id is a UUID; the server converts any other string into a UUID derived from it. Use a UUID anyway, because `conversation.context.fetch` and `get_context_for_prompt` only accept UUIDs, and you need the same id to read the conversation back.

Pick one of the two calls for a given conversation. A turn sent both ways is stored twice.

## Record several turns at once

When you buffer turns, for example writing the user's message and the reply together after each exchange, or flushing a queue from a worker, send them in one call with `record_messages_batch`. Each message takes the same fields as `record_message`.

```python Python theme={null}
result = await sdk.conversation.record_messages_batch(
    messages=[
        {
            "conversation_id": conversation_id,
            "role": "user",
            "content": "I'm vegetarian, so no meat in the meal kit please.",
            "user_id": "user_123",
        },
        {
            "conversation_id": conversation_id,
            "role": "assistant",
            "content": "Noted. I've switched your kit to the vegetarian menu.",
            "user_id": "user_123",
        },
    ]
)

if result["failed"]:
    print(result["errors"])  # [{"index": ..., "conversation_id": ..., "error": ...}]
```

```typescript TypeScript theme={null}
const result = await sdk.conversation.record_messages_batch([
  {
    conversation_id: conversationId,
    role: 'user',
    content: "I'm vegetarian, so no meat in the meal kit please.",
    user_id: 'user_123',
  },
  {
    conversation_id: conversationId,
    role: 'assistant',
    content: "Noted. I've switched your kit to the vegetarian menu.",
    user_id: 'user_123',
  },
]);

if (result.failed) {
  console.error(result.errors); // [{ index, conversation_id, error }]
}
```

The server stores each message on its own, in order. A message that fails does not stop the others and does not raise: it is counted in `failed` and described in `errors`, so check them. The response also carries `total`, `succeeded` and `results`. Problems the SDK can see before sending, such as a conversation id that is not a UUID, still raise for the whole batch.

For older conversations you are loading in bulk, use [Import existing history](/getting-started/bootstrap) instead.

## Push a finished transcript

When Synap had no part in the conversation while it ran, for example a voice call handled by your telephony stack or a support chat you process after it closes, push the whole thing at the end with `ingest_transcript`. One call records every turn as a conversation, queues extraction and starts a summary of the conversation, then returns right away with an `ingestion_id`.

```python Python theme={null}
from datetime import datetime, timezone
from maximem_synap import TranscriptTurn

resp = await sdk.conversation.ingest_transcript(
    conversation_id="call_8f2c41",  # any string; your call id works
    user_id="user_123",
    conversation_type="voice",
    transcript=[
        TranscriptTurn(role="assistant", content="Hi, this is Priya from Acme. Is now a good time?", speaker="Priya"),
        TranscriptTurn(role="user", content="Yes. Please move my delivery to Saturday."),
    ],
    analysis={"disposition": "rescheduled"},
    started_at=datetime(2026, 10, 20, 10, 0, tzinfo=timezone.utc),
    ended_at=datetime(2026, 10, 20, 10, 6, tzinfo=timezone.utc),
)

print(resp.status, resp.conversation_id)  # "queued", the UUID form of your id
await sdk.memories.wait_for_completion(resp.ingestion_id)
```

```typescript TypeScript theme={null}
const resp = await sdk.conversation.ingest_transcript({
  conversation_id: 'call_8f2c41', // any string; your call id works
  user_id: 'user_123',
  conversation_type: 'voice',
  transcript: [
    { role: 'assistant', content: 'Hi, this is Priya from Acme. Is now a good time?', speaker: 'Priya' },
    { role: 'user', content: 'Yes. Please move my delivery to Saturday.' },
  ],
  analysis: { disposition: 'rescheduled' },
  started_at: new Date('2026-10-20T10:00:00Z'),
  ended_at: new Date('2026-10-20T10:06:00Z'),
});

console.log(resp.status, resp.conversation_id); // "queued", the UUID form of your id
await sdk.memories.wait_for_completion(resp.ingestion_id);
```

What to know about each part:

* **`conversation_id` can be any string.** Unlike `record_message`, the SDK does not require a UUID. The server converts a non-UUID string into a UUID derived from it, so the same string always maps to the same conversation. The response returns both: `conversation_id` is the UUID form, which is the one to pass to `conversation.context` calls, and `external_conversation_id` is the string you sent.
* **`transcript` is a list of turns or a string.** Prefer the list: each turn keeps its own `timestamp`, a `speaker` display label and its own `metadata`. A string is split on lines that start with a label (`User:`, `Assistant:`, `Human:`, `AI:`, `System:` or `Bot:`); `User` and `Human` become user turns and every other label becomes an assistant turn. Typed turns accept `user` and `assistant` only. A transcript can hold up to 2,000 turns.
* **Send the times.** `ended_at`, `started_at` or per-turn timestamps tell Synap when the conversation happened, which is what dates its memories and orders them against newer ones. When none is present, the response carries a warning saying so. Timestamps without a timezone are read as UTC.
* **`analysis` is your own JSON about the call** (up to 64 KB), such as a disposition or sentiment from your platform. Synap stores it as is, returns it in [conversation-summary fetches](/sdk/context-fetch#conversation-summary-mode-conversation-start), and gives it to extraction as hints. Memories still have to come from what was said in the transcript.
* **`conversation_type`** is a free label up to 64 characters, such as `"voice"` or `"chat"`. **`metadata`** (up to 64 KB) is stored on the conversation.

Python exposes the response's `warnings` list through `resp.raw["warnings"]`; in TypeScript it is `resp.warnings`.

### Sending the same conversation\_id again

| What arrives | What Synap does |
| - | - |
| The same id with the same turns | Returns `status="duplicate"` with the original `ingestion_id` and processes nothing again. Retries are safe. "Same turns" means the same role, content and timestamp for each turn, in order; `speaker` and turn `metadata` do not count. |
| The same id with different turns, or an id whose turns you already recorded live | Accepts it: `status="queued"`, `turns_recorded=0` and a warning. The turns already stored stay exactly as they are, and the new transcript is extracted as one more document. |

A transcript is never replaced, so give every call its own `conversation_id`. The SDKs still define `TranscriptConflictError` for a conflicting transcript, but the current server accepts the push as described above instead of refusing it.

## Import a conversation from another system

To bring in one conversation that happened somewhere else, such as a chat export from a previous tool, send it as a document with `memories.create` and `document_type="ai-chat-conversation"` (the default for `memories.create`). Put one turn per line, starting with `User:` or `Assistant:`, so Synap can split a long conversation at turn boundaries. Set `document_created_at` to when the conversation took place, so its memories are dated correctly.

```python Python theme={null}
from datetime import datetime, timezone

result = await sdk.memories.create(
    document=(
        "User: I moved to Pune last month.\n"
        "Assistant: Thanks. I'll use Pune for delivery estimates from now on."
    ),
    document_type="ai-chat-conversation",
    user_id="user_123",
    document_created_at=datetime(2026, 9, 2, tzinfo=timezone.utc),
)
```

```typescript TypeScript theme={null}
const result = await sdk.memories.create({
  document:
    'User: I moved to Pune last month.\n' +
    "Assistant: Thanks. I'll use Pune for delivery estimates from now on.",
  document_type: 'ai-chat-conversation',
  user_id: 'user_123',
  document_created_at: '2026-09-02T00:00:00Z',
});
```

This path only produces long-term memory. No conversation is recorded, so the content does not appear in conversation context or in conversation-summary fetches. When that matters, use `ingest_transcript`. For many conversations at once, use `memories.batch_create` as described in [Import existing history](/getting-started/bootstrap).

## What happens to recorded turns

### Short-term context: right away

`record_message` and `record_messages_batch` return only after the server has stored the turn, so the next fetch of that conversation's context includes it, from any process. The SDK also keeps a local copy of the turns it sent and adds them to the next context fetch in the same process. That covers `send_message`, whose turns reach the server moments after the call returns. This read-your-writes behavior is on by default in both SDKs.

### Long-term memory: in the background

Synap does not extract memories from each turn the moment you record it. In the background, as the conversation grows and after it goes quiet or ends, Synap sends the recorded turns through the same extraction pipeline that `memories.create` uses. You do not call `memories.create` for turns you recorded. [Context, end to end](/concepts/context-end-to-end) follows a message through this, and [Context compaction](/sdk/context-compaction) covers the compacted context you can read back while the conversation runs.

A transcript pushed with `ingest_transcript` is extracted once, from the transcript itself. Synap does not extract that conversation's turns a second time later. The `ingestion_id` it returns is the handle for [`memories.status`](/sdk-reference/memories/status) and [`memories.wait_for_completion`](/sdk-reference/memories/wait-for-completion); `record_message` returns no `ingestion_id`, because nothing is extracted at that moment.

### Send each conversation once

* **Recorded turns plus `memories.create` of the same text.** The turns are already on their way to long-term memory. A `memories.create` call with the assembled conversation is a separate document, and the duplicate check only catches identical text, so Synap extracts the same content twice. Use `memories.create` for content that is not a recorded turn.
* **Recorded turns plus `ingest_transcript` under the same id.** Synap accepts the transcript and extracts it again, leaving the recorded turns in place. Do this only when the transcript adds something the live turns lack, such as `analysis`, start and end times, or a corrected version from your speech system.

## Deeper scope ladders

On the default ladder, `customer_id` and `user_id` say everything needed. If your [scope ladder](/concepts/scope-ladder) has a level between customer and user, pass `scope_path`, naming every level down to the one the conversation belongs to:

* `record_message` and `ingest_transcript` take `scope_path`, and each message in `record_messages_batch` can carry its own `scope_path`.
* `memories.create` takes the same map under the name `scope`.
* `send_message` cannot carry a path. Use `record_message` for a conversation that needs one.

For a recorded conversation, sending the path once is enough: the server fills in a missing path and never overwrites one it already stored.

## Optional fields

| Call | Field | What Synap does with it |
| - | - | - |
| `record_message`, `record_messages_batch` | `metadata` (a dict) | Stored with the turn |
| `record_message`, `record_messages_batch` | `session_id` | Groups turns within the conversation. When omitted, every turn of the conversation goes into one default session |
| `send_message` | `metadata` (string keys and string values) | Stored with the turn |
| `ingest_transcript` | `metadata`, `analysis`, `conversation_type` | See [Push a finished transcript](#push-a-finished-transcript) |
| `ingest_transcript` turns | `speaker`, `metadata`, `timestamp` | Stored with each turn; turns are stored in timestamp order |

## Common mistakes

* **A new `conversation_id` on every turn.** Each turn then becomes its own one-message conversation, and short-term context never holds more than that turn. Generate the id once per conversation and reuse it.
* **A non-UUID id on the live path.** `record_message` rejects it outright. On `send_message` it is accepted but stored under a derived UUID, which you cannot pass to `conversation.context.fetch`.
* **`customer_id` on B2C, or none on B2B.** Both are refused. Check which kind your instance is in [B2C vs B2B](/concepts/memory-scopes#b2c-vs-b2b-which-scopes-apply-to-you).
* **Recording `system` messages or tool output as turns.** Only `user` and `assistant` are accepted. Send tool activity with the calls in [Traces and tool calls](/capture/traces-and-tool-calls).
* **Sending a conversation twice.** Recorded turns plus `memories.create`, or a live conversation plus a transcript of it, extracts the content twice.
* **Reusing a call's id for the next call.** The second transcript is extracted, but its turns are not recorded as a conversation (`turns_recorded=0`), and its `analysis`, type and times replace the first call's on the conversation. Give each call its own id.
* **Recording more turns after pushing a transcript.** Synap treats a transcript-pushed conversation as finished and does not extract later turns of it into long-term memory. Start a new conversation instead.
* **Ignoring batch results.** `record_messages_batch` does not raise for a message that failed. Check `failed` and `errors`.
* **Transcripts without times.** Without `ended_at`, `started_at` or turn timestamps, Synap cannot tell when the conversation happened.

## Related pages

* Method references: [`conversation.record_message`](/sdk-reference/conversation/record-message), [`conversation.record_messages_batch`](/sdk-reference/conversation/record-messages-batch), [`conversation.ingest_transcript`](/sdk-reference/conversation/ingest-transcript), [`instance.send_message`](/sdk-reference/instance/send-message), [`memories.create`](/sdk-reference/memories/create)
* [Context compaction](/sdk/context-compaction): reading a long conversation back as compact, prompt-ready context
* [Context, end to end](/concepts/context-end-to-end): what happens to a message after you send it
* [gRPC vs REST](/concepts/grpc-vs-rest): choosing between `send_message` and `record_message`
* [Files](/capture/files): documents and uploads
* [Import existing history](/getting-started/bootstrap): loading past conversations in bulk


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.