> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.loreos.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.loreos.app/_mcp/server.

# Replacing your existing chat/completions backend

If your app already calls a `POST /chat/completions`-style endpoint and renders the
returned text, this page is the migration. The shape changes in exactly one place: the
reply is no longer in the HTTP response. Everything else — your UI, your end-user auth,
your message history — stays yours.

This guide gives you a drop-in **adapter** that hides the change behind a sync-feeling
function, plus the production patterns (retry safety, typing UX, polling-vs-SSE, and a
graceful fallback to your old provider during cutover).

## The one mental shift

A classic completions call is **synchronous**: you send messages, the HTTP response
*is* the reply.

```text
POST /chat/completions  { messages: [...] }   ->   200 { choices: [{ message }] }
                        (reply is in this response)
```

LoreOS is **asynchronous**: `POST /v1/sessions/{id}/messages` returns immediately with a
**cursor** and a **`run_ref`** — an acknowledgement, not the reply. The character's reply
arrives a few seconds later as a `message.created` event on the session's event log, which
you read by polling, SSE, or webhook.

```text
POST /v1/sessions/{id}/messages  { text }
  -> 200 { accepted: true, cursor, run_ref }           (acknowledgement)
  ...a few seconds later, on the event log...
  -> { type: "message.created", role: "character", payload: { bubbles, text } }
```

**Why it works this way.** A LoreOS reply is not a single model call. The character runs
a multi-step engine per turn — retrieval over its evolving world model, relational and
emotional state, grounding and safety/accuracy gates, voice shaping, then multi-bubble
emission and delivery. That work takes seconds and can be recovered if your process dies
mid-turn (a durability ledger re-runs it). Returning a cursor immediately lets you ack the
send instantly, show a typing indicator, and read a richer multi-bubble reply when it
lands — instead of holding an HTTP request open through the whole engine.

Two consequences to internalize before you write code:

* **The reply is multi-bubble.** `payload.bubbles` is an ordered list (a messenger-style
  burst); `payload.text` is the joined convenience form. Render `bubbles` when present.
* **Don't read top-level response fields.** Every response is wrapped as
  `{ schema_version, data, next_actions }`. Read `response.data.cursor`, not
  `response.cursor`. Errors are `{ detail: { code, message, fix } }`.

## A reference adapter (sync façade over the async events)

The cleanest migration keeps your call sites looking synchronous. Write one server-side
function — `getReply(sessionId, text)` — that sends the message, waits for the
`message.created` event, and returns the bubbles. Your existing code calls it the same way
it called `chat/completions`; the async model is hidden inside.

This matches the `examples/loreos-node-chat` style: a small `loreos()` helper that unwraps
`data` and throws on the `{ detail }` error envelope, plus a cursor-poll loop.

```typescript
// loreos-adapter.ts — keep this on the server; never ship LOREOS_KEY to the browser.
const BASE = process.env.LOREOS_BASE || "https://api.loreos.app";
const KEY = process.env.LOREOS_KEY!;

async function loreos(path: string, options: RequestInit = {}) {
  const response = await fetch(`${BASE}${path}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
      ...(options.headers || {}),
    },
  });
  const body = await response.json().catch(() => ({}));
  if (!response.ok) {
    const detail = body.detail || {};
    throw new Error(
      `${response.status} ${detail.code || "error"}: ${
        detail.fix || detail.message || JSON.stringify(body)
      }`,
    );
  }
  return body.data;
}

type Reply = { bubbles: string[]; text: string; cursor: number; runRef: string };

// Sync-feeling reply: send -> poll the event log until the character replies -> return.
export async function getReply(
  sessionId: string,
  text: string,
  opts: { idempotencyKey?: string; replyMode?: "fast" | "deep"; timeoutMs?: number } = {},
): Promise<Reply> {
  // 1. Send. Returns immediately with a cursor + run_ref (NOT the reply).
  const accepted = await loreos(`/v1/sessions/${encodeURIComponent(sessionId)}/messages`, {
    method: "POST",
    // Idempotency-Key makes a retried send safe (serverless/Vercel may retry).
    headers: opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {},
    body: JSON.stringify({ text, ...(opts.replyMode ? { reply_mode: opts.replyMode } : {}) }),
  });
  const runRef: string = accepted.run_ref;
  let cursor: number = accepted.cursor;

  // 2. Poll the event log for THIS turn's reply. Poll from the user-message cursor.
  const deadline = Date.now() + (opts.timeoutMs ?? 45_000);
  while (Date.now() < deadline) {
    const page = await loreos(
      `/v1/sessions/${encodeURIComponent(sessionId)}/events?since=${cursor}`,
    );
    for (const ev of page.events || []) {
      cursor = ev.cursor; // advance so we never re-read an event
      const type = ev.type || ev.event_type;
      if (type === "message.created" && ev.role === "character") {
        const bubbles = ev.payload?.bubbles?.length
          ? ev.payload.bubbles
          : [ev.payload?.text ?? ""];
        return { bubbles, text: ev.payload?.text ?? bubbles.join("\n"), cursor, runRef };
      }
      if (type === "message.failed") {
        // role: "system"; payload { turn_index, reason, recoverable }
        throw new Error(`reply failed: ${ev.payload?.reason ?? "unknown"}`);
      }
    }
    await new Promise((r) => setTimeout(r, 1_000));
  }
  throw new Error("timed out waiting for the character reply");
}
```

Your call site barely changes:

```typescript
// before:  const text = await chatCompletions(messages);
// after:
const reply = await getReply(sessionId, userText, { idempotencyKey: messageId });
render(reply.bubbles); // multi-bubble; falls back to [text] when a single bubble
```

The session is created once per end-user (`POST /v1/sessions` with your
`external_user_ref`) and reused for the conversation — it is the persistent thread, not a
per-message construct. See **[Quickstart](https://docs.loreos.app/getting-started/quickstart)**
for character + session creation.

## reply_mode: keep messenger-grade latency

`POST /messages` accepts an optional `reply_mode`:

* **`fast` (default)** — use this for migrations. It is the same light path the managed
  Telegram channels run, tuned for messenger-grade latency. It skips **only** the advisory
  voice-quality critic (a final stylistic polish pass). Grounding, safety, knowledge, and
  the emission gates all stay on, so **factual accuracy and safety are unchanged** — you
  are not trading correctness for speed.
* **`deep`** — opts into the full critic stack, which adds the quality critic and its
  one-shot voice rewrite for maximum voice polish. It is roughly 17s slower per turn.

If you omit `reply_mode`, you get `fast`. The deep world-model and relational update runs
asynchronously after the reply **either way** — `fast` does not skip the character
learning from the turn, it only skips the synchronous voice-polish pass. The response
echoes the `reply_mode` it used, so you can confirm it.

```typescript
// messenger app: default fast is correct — omit reply_mode.
await getReply(sessionId, text, { idempotencyKey: messageId });

// a "composed letter" surface where voice polish matters more than latency:
await getReply(sessionId, text, { idempotencyKey: messageId, replyMode: "deep" });
```

## Retry safety with Idempotency-Key

Serverless platforms (Vercel, Lambda) and network proxies retry requests. Without
protection, a retried send creates a **duplicate turn and a duplicate reply**. Send an
`Idempotency-Key` header with a value that is stable per logical message (your own message
id is ideal):

```typescript
await loreos(`/v1/sessions/${sessionId}/messages`, {
  method: "POST",
  headers: { "Idempotency-Key": messageId },
  body: JSON.stringify({ text }),
});
```

A same-key re-call returns the **original** `cursor` and `sent_turn_index` plus
`idempotent_replay: true`, instead of creating a second turn. Your adapter can treat that
identically — it polls from the same cursor and gets the same reply.

Dedupe on the read side too: every event carries a monotonic `cursor`. Track the highest
cursor you have processed and ignore anything at or below it, so a re-poll or an
at-least-once webhook never double-renders a bubble.

## Typing UX off the run.status event

The instant LoreOS accepts your send, it emits a `run.status` event so your UI can show a
typing indicator while the engine works:

```json
{ "type": "run.status", "role": "character", "payload": { "status": "generating", "run_ref": "..." } }
```

Render "typing…" when you see `run.status`, and **clear it on the next
`message.created`** (role `character`). This is an additive event type — if you only handle
`message.created`, you simply won't show typing; nothing breaks. The `run_ref` in the
payload matches the `run_ref` returned by your send, so you can scope the indicator to the
exact turn.

```typescript
for (const ev of page.events || []) {
  const type = ev.type || ev.event_type;
  if (type === "run.status" && ev.payload?.status === "generating") setTyping(true);
  if (type === "message.created" && ev.role === "character") {
    setTyping(false);
    render(ev.payload.bubbles?.length ? ev.payload.bubbles : [ev.payload.text]);
  }
}
```

## Polling vs SSE vs webhook: which transport

All three transports project the **same** cursored event log, so you can mix them and
resume across them with one cursor. Pick by surface:

| Transport        | Call                                                        | Use when                                                                                                                                                                                             |
| ---------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Polling**      | `GET /v1/sessions/{id}/events?since={cursor}`               | The universal default. Simplest to operate, works on every runtime including short-lived serverless functions. Best for the sync-façade adapter above.                                               |
| **SSE stream**   | `GET /v1/sessions/{id}/events/stream?since={cursor}`        | A long-lived client UI that wants push without managing webhooks. One connection is capped at **5 minutes** — reconnect with the last cursor (polling + SSE share the cursor, so there are no gaps). |
| **Webhook push** | `POST /v1/sessions/{id}/channels` (register a URL + secret) | Server-to-server. You run a backend that can receive an inbound HTTPS callback and want events pushed instead of pulled. At-least-once — dedupe by `cursor`.                                         |

Decision guide:

* **Migrating a request/response chat app?** Start with **polling** inside the adapter.
  It is the least infrastructure and matches the synchronous call site you are replacing.
* **Building a live, always-open chat surface?** Use **SSE** for lower latency, and
  reconnect-with-cursor on the 5-minute cap.
* **No user-facing client at all (a bot, a pipeline, a backend bridge)?** Use a
  **webhook** so LoreOS pushes to your server; you don't hold connections open.

When in doubt, polling is never wrong — the other two are optimizations over the same log.

## Graceful fallback during cutover

While you migrate, keep your old LLM provider wired as a fallback so a slow or unavailable
LoreOS turn degrades instead of failing the user. Wrap the adapter with a timeout and fall
back to your existing completions call:

```typescript
export async function replyWithFallback(sessionId: string, text: string, messageId: string) {
  try {
    const reply = await getReply(sessionId, text, {
      idempotencyKey: messageId,
      replyMode: "fast",
      timeoutMs: 12_000, // tight budget during cutover
    });
    return { source: "loreos", bubbles: reply.bubbles };
  } catch (err) {
    // Timeout, transient 5xx, or message.failed: serve the user with your old path.
    const text2 = await legacyChatCompletions(text); // your existing provider
    return { source: "fallback", bubbles: [text2] };
  }
}
```

Notes on doing this safely:

* Always pass the **`Idempotency-Key`** so a fallback that races a slow-but-successful
  LoreOS turn does not create a duplicate when you retry later.
* A `402 budget_exceeded` is a *budget* signal, not an outage — it is returned **before**
  any model spend. Treat it distinctly (raise the cap or back off), not as a reason to fall
  back to your old provider.
* Fallback replies don't carry LoreOS memory or world-model continuity. Keep the fallback
  window short and prefer raising your timeout or using `fast` mode over leaning on it.

## Vercel / Next.js: keep the key server-side

`LOREOS_KEY` can create state and trigger metered model work, so it must never reach the
browser. On Next.js, put the adapter behind a **Route Handler** (`app/api/.../route.ts`)
or a **Server Action**, set `LOREOS_KEY` as a server-only environment variable, and have
the browser call your own endpoint — which proxies to LoreOS.

```typescript
// app/api/chat/route.ts — server-only; LOREOS_KEY is never sent to the client.
import { getReply } from "../lib/loreos-adapter";

export async function POST(req: Request) {
  const { sessionId, text, messageId } = await req.json();
  const reply = await getReply(sessionId, text, { idempotencyKey: messageId });
  return Response.json({ bubbles: reply.bubbles, runRef: reply.runRef });
}
```

The browser does `fetch("/api/chat", …)`; your route handler holds the key and talks to
LoreOS. The same rule applies to the SSE and webhook transports — proxy them through your
backend, or terminate them server-side.

## Migration checklist

* [ ] Stop reading the reply from the `POST /messages` response; read `data.cursor` +
  `data.run_ref`, then read the reply from the event log.
* [ ] Render `payload.bubbles` (multi-bubble), falling back to `payload.text`.
* [ ] Create a session per end-user with your `external_user_ref` and reuse it.
* [ ] Default to `reply_mode: "fast"`; reach for `deep` only where voice polish beats
  latency.
* [ ] Send an `Idempotency-Key` (your message id) on every send.
* [ ] Show typing off `run.status`; clear on `message.created`.
* [ ] Dedupe reads/webhooks by the monotonic `cursor`.
* [ ] Keep `LOREOS_KEY` server-side; proxy the browser through your backend.
* [ ] (During cutover) wrap the adapter in a timeout + fallback to your old provider.

Once you are migrated, see
**[Staging keys and repeatable evaluation](https://docs.loreos.app/guides/staging-and-evaluation)**
to set up a persistent dev key and reproducible eval runs before you cut production over.