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

$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:

$# 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:

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

Create server.mjs:

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

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.