Deploy a minimal chat app

A no-brainer server-side tutorial from API key to deployed character chat.

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 an invite key.

Requirements

  • Node 20+
  • A LoreOS API key:
    • demo sandbox key for local testing; or
    • invite key for persistent deployment
  • curl
  • jq

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

$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 three characters, five sessions, sixty messages, 1,200 input characters, and no image generation, managed Telegram, webhook delivery, budget changes, app creation, or extra API keys.

Use an invite 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:

$# If you are using an invite 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"

1. Create the app files

Create a local folder:

$mkdir -p loreos-node-chat
$cd loreos-node-chat

Create package.json:

1{
2 "name": "loreos-node-chat",
3 "version": "0.1.0",
4 "private": true,
5 "type": "module",
6 "scripts": {
7 "setup": "node setup.mjs",
8 "start": "node server.mjs"
9 },
10 "engines": {
11 "node": ">=20.9.0"
12 }
13}

Create setup.mjs:

1const BASE = process.env.LOREOS_BASE || "https://api.loreos.app";
2const KEY = process.env.LOREOS_KEY;
3const SLUG = process.env.LOREOS_CHARACTER_SLUG || "luna-demo";
4const NAME = process.env.LOREOS_CHARACTER_NAME || "Luna";
5
6if (!KEY) {
7 console.error("Set LOREOS_KEY first.");
8 process.exit(1);
9}
10
11async function loreos(path, options = {}) {
12 const response = await fetch(`${BASE}${path}`, {
13 ...options,
14 headers: {
15 Authorization: `Bearer ${KEY}`,
16 "Content-Type": "application/json",
17 ...(options.headers || {}),
18 },
19 });
20 const body = await response.json().catch(() => ({}));
21 if (!response.ok) {
22 const detail = body.detail || {};
23 const message = detail.fix || detail.message || JSON.stringify(body);
24 const error = new Error(`${response.status} ${detail.code || "error"}: ${message}`);
25 error.status = response.status;
26 throw error;
27 }
28 return body.data;
29}
30
31async function maybeGetCharacter() {
32 try {
33 return await loreos(`/v1/characters/${encodeURIComponent(SLUG)}`);
34 } catch (error) {
35 if (error.status === 404) return null;
36 throw error;
37 }
38}
39
40const existing = await maybeGetCharacter();
41const character =
42 existing ||
43 (await loreos("/v1/characters", {
44 method: "POST",
45 body: JSON.stringify({ slug: SLUG, display_name: NAME }),
46 }));
47
48const readiness = await loreos(`/v1/characters/${encodeURIComponent(SLUG)}/readiness`);
49console.log(JSON.stringify({
50 ok: true,
51 character: { slug: character.slug, status: character.status },
52 readiness: { status: readiness.status },
53}, null, 2));

Create server.mjs:

1import http from "node:http";
2
3const BASE = process.env.LOREOS_BASE || "https://api.loreos.app";
4const KEY = process.env.LOREOS_KEY;
5const CHARACTER = process.env.LOREOS_CHARACTER_SLUG || "luna-demo";
6const PORT = Number(process.env.PORT || 8787);
7
8if (!KEY) {
9 console.error("Set LOREOS_KEY first.");
10 process.exit(1);
11}
12
13async function readJson(request) {
14 const chunks = [];
15 for await (const chunk of request) chunks.push(chunk);
16 return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
17}
18
19async function loreos(path, options = {}) {
20 const response = await fetch(`${BASE}${path}`, {
21 ...options,
22 headers: {
23 Authorization: `Bearer ${KEY}`,
24 "Content-Type": "application/json",
25 ...(options.headers || {}),
26 },
27 });
28 const body = await response.json().catch(() => ({}));
29 if (!response.ok) {
30 const detail = body.detail || {};
31 throw new Error(`${response.status} ${detail.code || "error"}: ${detail.fix || detail.message || JSON.stringify(body)}`);
32 }
33 return body.data;
34}
35
36function sendJson(response, status, body) {
37 response.writeHead(status, { "Content-Type": "application/json" });
38 response.end(JSON.stringify(body));
39}
40
41function sendHtml(response) {
42 response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
43 response.end(`<!doctype html>
44<html>
45 <body style="font-family: system-ui; max-width: 760px; margin: 40px auto;">
46 <h1>LoreOS Node Chat</h1>
47 <p>The browser talks to this Node server. The server keeps LOREOS_KEY private.</p>
48 <div id="log" style="display: grid; gap: 8px; min-height: 220px; border: 1px solid #ddd; padding: 12px;"></div>
49 <form id="form" style="display: flex; gap: 8px; margin-top: 12px;">
50 <input id="text" style="flex: 1; padding: 10px;" placeholder="Ask Luna what she is up to..." />
51 <button>Send</button>
52 </form>
53 <p id="status"></p>
54 <script>
55 let sessionId = null;
56 let cursor = 0;
57 const log = document.querySelector("#log");
58 const status = document.querySelector("#status");
59 const form = document.querySelector("#form");
60 const input = document.querySelector("#text");
61
62 function add(role, text) {
63 const item = document.createElement("div");
64 item.textContent = role + ": " + text;
65 log.appendChild(item);
66 }
67
68 async function api(path, body) {
69 const response = await fetch(path, {
70 method: body ? "POST" : "GET",
71 headers: body ? { "Content-Type": "application/json" } : {},
72 body: body ? JSON.stringify(body) : undefined,
73 });
74 const data = await response.json();
75 if (!response.ok) throw new Error(data.error || JSON.stringify(data));
76 return data;
77 }
78
79 async function ensureSession() {
80 if (sessionId) return;
81 const data = await api("/api/session", { externalUserRef: "demo-user" });
82 sessionId = data.session_id;
83 }
84
85 async function pollForReply(startCursor) {
86 for (let i = 0; i < 45; i += 1) {
87 await new Promise((resolve) => setTimeout(resolve, 1000));
88 const data = await api("/api/events?sessionId=" + encodeURIComponent(sessionId) + "&since=" + encodeURIComponent(startCursor));
89 cursor = data.next_cursor || cursor;
90 const reply = (data.events || []).find((event) => {
91 const type = event.event_type || event.type;
92 return type === "message.created" && event.role === "character";
93 });
94 if (reply) {
95 const bubbles = reply.payload?.bubbles?.length ? reply.payload.bubbles : [reply.payload?.text || "(empty reply)"];
96 bubbles.forEach((text) => add("Luna", text));
97 status.textContent = "Reply received.";
98 return;
99 }
100 status.textContent = "Waiting for async character reply...";
101 }
102 }
103
104 form.addEventListener("submit", async (event) => {
105 event.preventDefault();
106 const text = input.value.trim();
107 if (!text) return;
108 input.value = "";
109 add("You", text);
110 await ensureSession();
111 const accepted = await api("/api/message", { sessionId, text });
112 cursor = accepted.cursor || cursor;
113 await pollForReply(cursor);
114 });
115 </script>
116 </body>
117</html>`);
118}
119
120const server = http.createServer(async (request, response) => {
121 try {
122 const url = new URL(request.url || "/", `http://${request.headers.host}`);
123 if (request.method === "GET" && url.pathname === "/") return sendHtml(response);
124 if (request.method === "POST" && url.pathname === "/api/session") {
125 const body = await readJson(request);
126 const data = await loreos("/v1/sessions", {
127 method: "POST",
128 body: JSON.stringify({ character: CHARACTER, external_user_ref: body.externalUserRef || "demo-user" }),
129 });
130 return sendJson(response, 200, data);
131 }
132 if (request.method === "POST" && url.pathname === "/api/message") {
133 const body = await readJson(request);
134 const data = await loreos(`/v1/sessions/${encodeURIComponent(body.sessionId)}/messages`, {
135 method: "POST",
136 body: JSON.stringify({ text: body.text }),
137 });
138 return sendJson(response, 200, data);
139 }
140 if (request.method === "GET" && url.pathname === "/api/events") {
141 const sessionId = url.searchParams.get("sessionId");
142 const since = url.searchParams.get("since") || "0";
143 const data = await loreos(`/v1/sessions/${encodeURIComponent(sessionId)}/events?since=${encodeURIComponent(since)}`);
144 return sendJson(response, 200, data);
145 }
146 sendJson(response, 404, { error: "not found" });
147 } catch (error) {
148 sendJson(response, 500, { error: error.message });
149 }
150});
151
152server.listen(PORT, () => {
153 console.log(`LoreOS Node Chat running at http://localhost:${PORT}`);
154});

2. Create the character

Run the setup script:

$npm run setup

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

Expected shape:

1{
2 "ok": true,
3 "character": {
4 "slug": "luna-demo",
5 "status": "published"
6 },
7 "readiness": {
8 "status": "needs_attention"
9 }
10}

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

$npm start

Open http://localhost:8787.

The app uses three server endpoints:

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:

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

Set environment variables:

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:

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

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

1{
2 "type": "message.created",
3 "role": "character",
4 "payload": {
5 "text": "...",
6 "bubbles": ["..."]
7 }
8}

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.