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

# Deploy a minimal chat app

This tutorial builds a tiny web chat that keeps your LoreOS API key server-side. The
browser talks to your server; your server talks to LoreOS. A coding agent can follow
this page in a clean workspace without private repository access.

You will:

1. Create a character.
2. Run a local chat UI.
3. Send a user message.
4. Poll LoreOS events until the character reply appears.
5. Deploy the same app to Render with a persistent runtime key.

## Requirements

* Node 20+
* A LoreOS API key:
  * demo sandbox key for local testing; or
  * self-serve-issued runtime key for persistent deployment
* `curl`
* `jq`

For a local copy-paste test, issue a short-lived demo sandbox key:

```bash
export LOREOS_KEY="$(curl -sS -X POST https://api.loreos.app/v1/demo/sandbox-key \
  | jq -r '.data.api_key')"
```

Demo keys are capped to five characters, fifteen sessions, 150 messages, 2,000 input
characters, and no image generation, managed Telegram, webhook delivery, budget
changes, app creation, or extra API keys.

Use a persistent runtime key before deploying to Render. Do not deploy a demo key as a
production key because it expires automatically and intentionally blocks product
surfaces such as image generation, managed Telegram, webhooks, app creation, budget
changes, and extra API keys.

Set your key:

```bash
# If you are using a persistent runtime key instead of the demo command above:
# export LOREOS_KEY="ck_..."
export LOREOS_BASE="https://api.loreos.app"
export LOREOS_CHARACTER_SLUG="luna-demo"
export LOREOS_CHARACTER_NAME="Luna"
export LOREOS_CHARACTER_LANGUAGE="en-US"
```

## 1. Create the app files

Create a local folder:

```bash
mkdir -p loreos-node-chat
cd loreos-node-chat
```

Create `package.json`:

```json
{
  "name": "loreos-node-chat",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "setup": "node setup.mjs",
    "start": "node server.mjs"
  },
  "engines": {
    "node": ">=20.9.0"
  }
}
```

Create `setup.mjs`:

```js
const BASE = process.env.LOREOS_BASE || "https://api.loreos.app";
const KEY = process.env.LOREOS_KEY;
const SLUG = process.env.LOREOS_CHARACTER_SLUG || "luna-demo";
const NAME = process.env.LOREOS_CHARACTER_NAME || "Luna";
const LANGUAGE = process.env.LOREOS_CHARACTER_LANGUAGE || "en-US";

if (!KEY) {
  console.error("Set LOREOS_KEY first.");
  process.exit(1);
}

async function loreos(path, options = {}) {
  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 || {};
    const message = detail.fix || detail.message || JSON.stringify(body);
    const error = new Error(`${response.status} ${detail.code || "error"}: ${message}`);
    error.status = response.status;
    throw error;
  }
  return body.data;
}

async function maybeGetCharacter() {
  try {
    return await loreos(`/v1/characters/${encodeURIComponent(SLUG)}`);
  } catch (error) {
    if (error.status === 404) return null;
    throw error;
  }
}

const existing = await maybeGetCharacter();
const character =
  existing ||
  (await loreos("/v1/characters", {
    method: "POST",
    body: JSON.stringify({
      slug: SLUG,
      display_name: NAME,
      locale: LANGUAGE,
      primary_reply_language: LANGUAGE,
    }),
  }));

const readiness = await loreos(`/v1/characters/${encodeURIComponent(SLUG)}/readiness`);
console.log(JSON.stringify({
  ok: true,
  character: { slug: character.slug, status: character.status },
  readiness: { status: readiness.status },
}, null, 2));
```

Create `server.mjs`:

```js
import http from "node:http";
import crypto from "node:crypto";

const BASE = process.env.LOREOS_BASE || "https://api.loreos.app";
const KEY = process.env.LOREOS_KEY;
const CHARACTER = process.env.LOREOS_CHARACTER_SLUG || "luna-demo";
const PORT = Number(process.env.PORT || 8787);

if (!KEY) {
  console.error("Set LOREOS_KEY first.");
  process.exit(1);
}

async function readJson(request) {
  const chunks = [];
  for await (const chunk of request) chunks.push(chunk);
  return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
}

async function loreos(path, options = {}) {
  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;
}

function sendJson(response, status, body) {
  response.writeHead(status, { "Content-Type": "application/json" });
  response.end(JSON.stringify(body));
}

function sendHtml(response) {
  response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
  response.end(`<!doctype html>
<html>
  <body style="font-family: system-ui; max-width: 760px; margin: 40px auto;">
    <h1>LoreOS Node Chat</h1>
    <p>The browser talks to this Node server. The server keeps the LoreOS API key private.</p>
    <div id="log" style="display: grid; gap: 8px; min-height: 220px; border: 1px solid #ddd; padding: 12px;"></div>
    <form id="form" style="display: flex; gap: 8px; margin-top: 12px;">
      <input id="text" style="flex: 1; padding: 10px;" placeholder="Ask Luna what she is up to..." />
      <button>Send</button>
    </form>
    <p id="status"></p>
    <script>
      let sessionId = null;
      let cursor = 0;
      const log = document.querySelector("#log");
      const status = document.querySelector("#status");
      const form = document.querySelector("#form");
      const input = document.querySelector("#text");

      function add(role, text) {
        const item = document.createElement("div");
        item.textContent = role + ": " + text;
        log.appendChild(item);
      }

      async function api(path, body) {
        const response = await fetch(path, {
          method: body ? "POST" : "GET",
          headers: body ? { "Content-Type": "application/json" } : {},
          body: body ? JSON.stringify(body) : undefined,
        });
        const data = await response.json();
        if (!response.ok) throw new Error(data.error || JSON.stringify(data));
        return data;
      }

      async function ensureSession() {
        if (sessionId) return;
        const data = await api("/api/session", { externalUserRef: "demo-user" });
        sessionId = data.session_id;
      }

      async function pollForReply(startCursor) {
        let since = startCursor;
        for (let i = 0; i < 45; i += 1) {
          await new Promise((resolve) => setTimeout(resolve, 1000));
          const data = await api("/api/events?sessionId=" + encodeURIComponent(sessionId) + "&since=" + encodeURIComponent(since));
          const nextCursor = data.next_cursor || since;
          cursor = nextCursor || cursor;
          const reply = (data.events || []).find((event) => {
            const type = event.type || event.event_type;
            return type === "message.created" && event.role === "character";
          });
          if (reply) {
            const bubbles = reply.payload?.bubbles?.length ? reply.payload.bubbles : [reply.payload?.text || "(empty reply)"];
            bubbles.forEach((text) => add("Luna", text));
            status.textContent = "Reply received.";
            return;
          }
          since = nextCursor;
          status.textContent = "Waiting for async character reply...";
        }
      }

      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        const text = input.value.trim();
        if (!text) return;
        input.value = "";
        add("You", text);
        await ensureSession();
        const accepted = await api("/api/message", { sessionId, text });
        cursor = accepted.cursor || cursor;
        await pollForReply(cursor);
      });
    </script>
  </body>
</html>`);
}

const server = http.createServer(async (request, response) => {
  try {
    const url = new URL(request.url || "/", `http://${request.headers.host}`);
    if (request.method === "GET" && url.pathname === "/") return sendHtml(response);
    if (request.method === "POST" && url.pathname === "/api/session") {
      const body = await readJson(request);
      const data = await loreos("/v1/sessions", {
        method: "POST",
        body: JSON.stringify({ character: CHARACTER, external_user_ref: body.externalUserRef || "demo-user" }),
      });
      return sendJson(response, 200, data);
    }
    if (request.method === "POST" && url.pathname === "/api/message") {
      const body = await readJson(request);
      const data = await loreos(`/v1/sessions/${encodeURIComponent(body.sessionId)}/messages`, {
        method: "POST",
        headers: { "Idempotency-Key": crypto.randomUUID() },
        body: JSON.stringify({ text: body.text }),
      });
      return sendJson(response, 200, data);
    }
    if (request.method === "GET" && url.pathname === "/api/events") {
      const sessionId = url.searchParams.get("sessionId");
      const since = url.searchParams.get("since") || "0";
      const data = await loreos(`/v1/sessions/${encodeURIComponent(sessionId)}/events?since=${encodeURIComponent(since)}`);
      return sendJson(response, 200, data);
    }
    sendJson(response, 404, { error: "not found" });
  } catch (error) {
    sendJson(response, 500, { error: error.message });
  }
});

server.listen(PORT, () => {
  console.log(`LoreOS Node Chat running at http://localhost:${PORT}`);
});
```

## 2. Create the character

Run the setup script:

```bash
npm run setup
```

The script calls `POST /v1/characters` and `GET /v1/characters/{slug}/readiness`.

Expected shape:

```json
{
  "ok": true,
  "character": {
    "slug": "luna-demo",
    "status": "published"
  },
  "readiness": {
    "status": "needs_attention"
  }
}
```

`needs_attention` is expected for a bare text-only demo character because it has no
persona, voice guide, or identity image yet. The text chat flow below still works. For
production launch, add richer character state and check readiness again; image features
need an identity image and visual readiness.

## 3. Run locally

```bash
npm start
```

Open `http://localhost:8787`.

The app uses three server endpoints:

```text
POST /api/session   -> POST /v1/sessions
POST /api/message   -> POST /v1/sessions/{id}/messages
GET  /api/events    -> GET  /v1/sessions/{id}/events?since=...
```

The important detail: `POST /api/message` does not wait for the model. It returns the
LoreOS cursor, then the browser polls `/api/events` until a character event appears.

## 4. Deploy to Render

Create a new Render Web Service from the folder `examples/loreos-node-chat`.

Use:

```text
Runtime: Node
Build command: npm install
Start command: npm start
```

Set environment variables:

```text
LOREOS_KEY=ck_...
LOREOS_BASE=https://api.loreos.app
LOREOS_CHARACTER_SLUG=luna-demo
LOREOS_CHARACTER_NAME=Luna
```

Run `npm run setup` once locally before deploying, or run it from Render Shell after
the env vars are set.

## What success looks like

When you send a message, your browser should show:

```text
Message accepted. Waiting for the async character reply...
```

Then, after polling the event log, it should render one or more character bubbles from:

```json
{
  "type": "message.created",
  "role": "character",
  "payload": {
    "text": "...",
    "bubbles": ["..."]
  }
}
```

## Debug checklist

* `401 api key required` — the server did not send `Authorization: Bearer`.
* `403 api key invalid or revoked` — rotate or reissue the key.
* `404 character` — the slug is wrong or belongs to a different app.
* `402 budget_exceeded` — raise the app or end-user cap before sending another message.
* No reply yet — continue polling `GET /v1/sessions/{id}/events?since=<cursor>` and check
  session runs/events.

Never put `LOREOS_KEY` in client-side JavaScript.