Add Persistent Chat History to an AppKit Application
Persist chat conversations in Lakebase so users can revisit past conversations, using AppKit-native primitives only: AppKit.lakebase.query() for persistence, AppKit.serving("agent").asUser(req).stream() for the agent stream, and server.extend() with autoStart: false for the proxy route.
No Vercel AI SDK, no @databricks/ai-sdk-provider, no streamText. This skill builds on top of the agent wiring already established in 06-appkit-serving-wiring.
When to Use
- Adding persistent conversation storage to an AppKit chat interface built with
useServingStream - Building a sidebar with conversation history
- Supporting ephemeral mode (graceful degradation when Lakebase is unavailable)
- Capturing
trace_idserver-side for later feedback logging (see 08-appkit-feedback)
Not for agent streaming fundamentals. Use 06-appkit-serving-wiring to wire useServingStream and the base /api/serving/:alias/stream route first.
Not for plugin registration. Use 04-appkit-plugin-add + 05-appkit-lakebase-wiring to register the Lakebase plugin and declare bundle resources first.
Prerequisites
Verify all of these before proceeding:
- Lakebase plugin registered in
server/server.tswith bundle resources deployed (see 05-appkit-lakebase-wiring) - Serving plugin registered in
server/server.tswith a working agent endpoint (see 06-appkit-serving-wiring) useServingStreamrenders a basic chat UI that works end-to-end- App deployed at least once so the Service Principal exists and can create schemas
AppKit.serveris constructed withserver({ autoStart: false })andAppKit.server.start()is called afterAppKit.server.extend(...)
Upstream docs (always check for latest):
npx @databricks/appkit docs "lakebase"
npx @databricks/appkit docs "serving"
Working in Genie Code (client routing)
Everything in this skill is source editing + server-side code (DDL, proxy route, sidebar, hooks) — written the same way on both clients. The DDL runs server-side on startup (Step 1) and is already client-agnostic. Only local gates and the deployed-app checks differ:
| IDE/CLI (as written) | Genie Code substitution |
|---|---|
npm run build / npm test gates (Steps 1–8) |
IDE-only convenience — no local Node toolchain. Skip; the platform builds server-side on deploy, and the retry tests run in CI or post-clone where npm exists. Errors surface in databricks apps logs <name> |
npm run dev |
not available — verify on the deployed app |
npx @databricks/appkit docs … |
npx absent (P9) — WebFetch https://databricks.github.io/appkit/docs/plugins/ |
databricks bundle validate --profile $PROFILE (Step 9) |
run via runDatabricksCli (omit --profile; --target dev if a targetless validate is guardrail-blocked) — this is the canonical gate on both clients |
databricks apps validate (Step 9) |
hard-blocked and unsafe for Lakebase apps (boots locally) — skip on both clients, rely on bundle validate + server-side build logs |
local curl http://localhost:8000/api/… gates (Steps 3, 7) |
no local dev server — exercise the routes on the deployed app via browser, or the OAuth-session requests.Session() test in 03-appkit-deploy |
databricks apps deploy … |
see the 03-appkit-deploy deploy-routing contract (runDatabricksCli, else SDK w.apps.deploy(... SNAPSHOT)) |
Paths are relative to apps_lakebase/$APP_NAME — inside your git-cloned workshop project (artifact_root) on Genie Code, never the read-only .assistant/skills copy and never /tmp. See skills/genie-code-environment for the full manifest.
Architecture
Browser (useServingStream or direct fetch)
│
▼
AppKit Server — server.extend() proxy route /api/chat
│
├─► AppKit.lakebase.query() (persist chat record, user message)
│
├─► AppKit.serving("agent").asUser(req).stream({ messages })
│ │
│ └─ for await (chunk of stream):
│ 1. Forward chunk to client as SSE
│ 2. Accumulate assistant text via dual-format extractor
│ 3. Scan chunk for trace_id
│
└─► AppKit.lakebase.query() (INSERT assistant message + traceId on stream close)
This pattern extends the server.extend() streaming proxy already documented in 06-appkit-serving-wiring/SKILL.md Step 6b. The only additions are the Lakebase inserts and the text/traceId accumulators.
Step 1: Create the Chat Schema
Add idempotent DDL after createApp() in server/server.ts. The chat schema is deliberately isolated from application data schemas so it doesn't collide with other tables.
RULE_10 — this DDL is intentionally in-app, not a bundle resource or a
psqlscript.initChatSchema()runs server-side on every app startup viaAppKit.lakebase.query(), so the app's Service Principal owns the schema, tables, and indexes (the same Deploy-First Pattern as05-appkit-lakebase-wiringStep 1d). This is client-agnostic — it executes identically whether the app was deployed from an IDE or Genie Code, and there is no client-sidepsql/DDL step. Do not move it intodatabricks.ymlor a setup script: idempotentCREATE … IF NOT EXISTSon startup is the correct, SP-owning pattern.
async function initChatSchema() {
try {
await AppKit.lakebase.query(`CREATE SCHEMA IF NOT EXISTS chat`);
await AppKit.lakebase.query(`
CREATE TABLE IF NOT EXISTS chat."User" (
id TEXT PRIMARY KEY NOT NULL,
email VARCHAR(64) NOT NULL
)
`);
await AppKit.lakebase.query(`
CREATE TABLE IF NOT EXISTS chat."Chat" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
title TEXT NOT NULL DEFAULT 'New chat',
"userId" TEXT NOT NULL,
visibility VARCHAR(10) NOT NULL DEFAULT 'private',
"lastContext" JSONB
)
`);
await AppKit.lakebase.query(`
CREATE TABLE IF NOT EXISTS chat."Message" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"chatId" UUID NOT NULL REFERENCES chat."Chat"(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
parts JSON NOT NULL DEFAULT '[]',
attachments JSON NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"traceId" TEXT
)
`);
await AppKit.lakebase.query(`
CREATE TABLE IF NOT EXISTS chat."Vote" (
"chatId" UUID NOT NULL REFERENCES chat."Chat"(id) ON DELETE CASCADE,
"messageId" UUID NOT NULL REFERENCES chat."Message"(id) ON DELETE CASCADE,
"isUpvoted" BOOLEAN NOT NULL,
PRIMARY KEY ("chatId", "messageId")
)
`);
await AppKit.lakebase.query(`
CREATE INDEX IF NOT EXISTS idx_chat_user ON chat."Chat" ("userId", "createdAt" DESC)
`);
await AppKit.lakebase.query(`
CREATE INDEX IF NOT EXISTS idx_message_chat ON chat."Message" ("chatId", "createdAt" ASC)
`);
console.log("[Chat] Schema initialized");
} catch (err) {
console.warn("[Chat] Schema init failed (may not have DB access):", err);
}
}
await initChatSchema();
See references/chat-schema.md for the full ER diagram, per-column rationale, and optional Drizzle ORM migration path.
Gate: Schema init runs on startup. A subsequent npm run build passes, and deploying the app produces server logs containing [Chat] Schema initialized.
Step 2: Add Auth Middleware
Add Express middleware to parse the OBO user identity once per request and attach it to req.session. This avoids calling a helper function inline in every route handler.
Header contract. Databricks Apps sets four canonical user headers on every request:
x-forwarded-email— end user's email (the value AppKit and downstream agents use as the canonical user attribute)x-forwarded-preferred-username— preferred username (often equals the email)x-forwarded-user— stable workspace user idx-forwarded-access-token— downscoped OBO token (only present whenuser_api_scopesis declared)
x-forwarded-user-infois not a canonical Databricks Apps header and must not be used. Earlier internal patterns assumed a JSON-blob header existed; it does not. Read the canonical headers above directly.
interface AppSession {
userId: string;
email: string;
name?: string;
authenticated: boolean;
}
declare global {
namespace Express {
interface Request {
session?: AppSession;
}
}
}
function readCanonicalUserHeaders(req: import("express").Request) {
const email = req.headers["x-forwarded-email"];
const preferred = req.headers["x-forwarded-preferred-username"];
const user = req.headers["x-forwarded-user"];
const pick = (h: string | string[] | undefined): string | undefined =>
typeof h === "string" && h.length > 0 ? h : undefined;
return {
email: pick(email),
preferredUsername: pick(preferred),
userId: pick(user),
};
}
function authMiddleware(
req: import("express").Request,
_res: import("express").Response,
next: import("express").NextFunction,
) {
const canonical = readCanonicalUserHeaders(req);
if (canonical.email || canonical.userId) {
// userId persists in Lakebase as TEXT — prefer the email so it lines up
// with MLflow AssessmentSource.source_id. Fall back to x-forwarded-user.
const userId = canonical.email ?? canonical.userId!;
const email = canonical.email ?? canonical.preferredUsername ?? "unknown@local";
req.session = {
userId,
email,
name: canonical.preferredUsername,
authenticated: true,
};
} else {
req.session = {
userId: process.env.DEV_USER_EMAIL ?? "local-dev-user",
email: process.env.DEV_USER_EMAIL ?? "dev@local",
authenticated: false,
};
}
next();
}
function requireAuth(
req: import("express").Request,
res: import("express").Response,
next: import("express").NextFunction,
) {
if (!req.session) {
return res.status(401).json({ error: "Authentication required" });
}
next();
}
function requireChatAccess(
req: import("express").Request,
res: import("express").Response,
next: import("express").NextFunction,
) {
const chatId = req.params.chatId;
if (!chatId) return next();
AppKit.lakebase
.query(`SELECT "userId", visibility FROM chat."Chat" WHERE id = $1`, [chatId])
.then((result) => {
if (result.rows.length === 0) return res.status(404).json({ error: "Chat not found" });
const chat = result.rows[0];
if (chat.userId !== req.session?.userId && chat.visibility !== "public") {
return res.status(403).json({ error: "Access denied" });
}
next();
})
.catch(() => next());
}
Apply the middleware to all chat routes:
AppKit.server.extend((app) => {
app.use("/api/chat", authMiddleware, requireAuth);
app.use("/api/history", authMiddleware, requireAuth);
app.use("/api/messages", authMiddleware, requireAuth);
app.use("/api/feedback", authMiddleware, requireAuth);
app.use("/api/session", authMiddleware);
});
Route handlers then read req.session.userId instead of calling a helper.
See references/session-auth.md for the full middleware reference, OBO header format, and access control rules.
Gate: npm run build passes with the middleware imports. Hitting any protected route without the canonical x-forwarded-email / x-forwarded-user headers (i.e. running outside the Databricks Apps platform) returns the local-dev fallback identity.
Step 3: Add History API Routes
These routes power the sidebar and message loading. All use AppKit.lakebase.query() — no ORM.
AppKit.server.extend((app) => {
async function ensureUser(req: import("express").Request) {
const userId = req.session!.userId;
const email = req.session!.email;
await AppKit.lakebase.query(
`INSERT INTO chat."User" (id, email) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
[userId, email],
);
return userId;
}
app.get("/api/history", async (req, res) => {
try {
const userId = await ensureUser(req);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const startingAfter = req.query.starting_after as string | undefined;
const endingBefore = req.query.ending_before as string | undefined;
let sql: string;
let params: unknown[];
if (startingAfter) {
sql = `SELECT id, title, "createdAt", visibility
FROM chat."Chat"
WHERE "userId" = $1
AND "createdAt" < (SELECT "createdAt" FROM chat."Chat" WHERE id = $2)
ORDER BY "createdAt" DESC
LIMIT $3`;
params = [userId, startingAfter, limit + 1];
} else if (endingBefore) {
sql = `SELECT * FROM (
SELECT id, title, "createdAt", visibility
FROM chat."Chat"
WHERE "userId" = $1
AND "createdAt" > (SELECT "createdAt" FROM chat."Chat" WHERE id = $2)
ORDER BY "createdAt" ASC
LIMIT $3
) sub ORDER BY "createdAt" DESC`;
params = [userId, endingBefore, limit + 1];
} else {
sql = `SELECT id, title, "createdAt", visibility
FROM chat."Chat"
WHERE "userId" = $1
ORDER BY "createdAt" DESC
LIMIT $2`;
params = [userId, limit + 1];
}
const result = await AppKit.lakebase.query(sql, params);
const hasMore = result.rows.length > limit;
const data = hasMore ? result.rows.slice(0, limit) : result.rows;
res.json({ data, hasMore });
} catch (err) {
console.warn("[History] Failed, returning 204 for ephemeral mode:", err);
res.status(204).end();
}
});
app.get("/api/messages/:chatId", requireChatAccess, async (req, res) => {
try {
const result = await AppKit.lakebase.query(
`SELECT id, role, parts, "traceId", "createdAt"
FROM chat."Message"
WHERE "chatId" = $1
ORDER BY "createdAt" ASC`,
[req.params.chatId],
);
res.json({ data: result.rows, source: "live" });
} catch (err) {
console.warn("[Messages] Failed:", err);
res.json({ data: [], source: "mock" });
}
});
app.delete("/api/chat/:chatId", async (req, res) => {
try {
const userId = req.session!.userId;
const chat = await AppKit.lakebase.query(
`SELECT "userId" FROM chat."Chat" WHERE id = $1`,
[req.params.chatId],
);
if (chat.rows.length === 0 || chat.rows[0].userId !== userId) {
return res.status(404).json({ error: "Chat not found" });
}
await AppKit.lakebase.query(`DELETE FROM chat."Chat" WHERE id = $1`, [req.params.chatId]);
res.json({ success: true });
} catch (err) {
console.error("[Delete] Failed:", err);
res.status(500).json({ error: "Failed to delete chat" });
}
});
app.patch("/api/chat/:chatId/visibility", async (req, res) => {
const { visibility } = req.body;
if (!["public", "private"].includes(visibility)) {
return res.status(400).json({ error: "visibility must be 'public' or 'private'" });
}
try {
const userId = req.session!.userId;
const chat = await AppKit.lakebase.query(
`SELECT "userId" FROM chat."Chat" WHERE id = $1`,
[req.params.chatId],
);
if (chat.rows.length === 0 || chat.rows[0].userId !== userId) {
return res.status(403).json({ error: "Only the chat owner can change visibility" });
}
await AppKit.lakebase.query(
`UPDATE chat."Chat" SET visibility = $1 WHERE id = $2`,
[visibility, req.params.chatId],
);
res.json({ success: true, visibility });
} catch (err) {
console.error("[Visibility] Failed:", err);
res.status(500).json({ error: "Failed to update visibility" });
}
});
app.get("/api/session", async (req, res) => {
res.json({
user: {
id: req.session!.userId,
email: req.session!.email,
name: req.session?.name,
},
authenticated: req.session!.authenticated,
});
});
});
Gate:
curl -s "http://localhost:8000/api/history" \
-H "x-forwarded-email: dev@local" \
-H "x-forwarded-preferred-username: dev@local" \
-H "x-forwarded-user: dev-user" | jq .
Returns { data: [], hasMore: false } initially. After a conversation is saved (Step 4), it shows up here.
Step 4: Stream + Persist Assistant Message (AppKit-Native)
This is the only step that deviates significantly from the source pattern. Instead of the AI SDK's streamText() + onFinish(), use a server.extend() proxy over AppKit.serving("agent").asUser(req).stream(...) — the exact pattern from 06-appkit-serving-wiring/SKILL.md Step 6b, extended with Lakebase persistence and trace-ID capture.
Prerequisite —
autoStart: falseon the server plugin. YourcreateAppmust be declared as:const AppKit = await createApp({ plugins: [ server({ autoStart: false }), serving(), lakebase(), ], });The final
await AppKit.server.start()call happens after allserver.extend(...)blocks. Same pattern already in 06-appkit-serving-wiring Step 6a.
4a. Import the Chunk Helpers
Add to server/server.ts (or a new server/chunk-helpers.ts, since tsdown unbundle: true preserves relative imports — see 05-appkit-lakebase-wiring build system note):
type Chunk = {
type?: string;
delta?: string;
choices?: Array<{ delta?: { content?: string } }>;
databricks_output?: { trace?: { info?: { trace_id?: string } } };
trace_id?: string;
};
function extractDelta(chunk: Chunk): string {
if (chunk.type === "response.output_text.delta" && typeof chunk.delta === "string") {
return chunk.delta;
}
const openAiDelta = chunk.choices?.[0]?.delta?.content;
if (typeof openAiDelta === "string") return openAiDelta;
return "";
}
function extractTraceId(chunk: Chunk): string | null {
const tid = chunk?.databricks_output?.trace?.info?.trace_id;
if (typeof tid === "string") return tid;
if (typeof chunk?.trace_id === "string") return chunk.trace_id;
return null;
}
The extractDelta function is the same dual-format parser documented in 06-appkit-serving-wiring/references/sse-format-patterns.md. The extractTraceId function is documented in detail in 08-appkit-feedback/references/trace-extraction.md — feel free to import it from there once Step 1 of 08-appkit-feedback is complete.
4b. The /api/chat Proxy Route
AppKit.server.extend((app) => {
// In-memory fallback for ephemeral mode (when Lakebase is unavailable).
// Used by 08-appkit-feedback to look up traceId when DB lookup fails.
const messageMetaStore = new Map<string, { chatId: string; traceId: string | null }>();
let dbAvailable = true;
async function dbQuery(sql: string, params: unknown[]) {
if (!dbAvailable) return { rows: [] as any[] };
try {
return await AppKit.lakebase.query(sql, params);
} catch (err) {
console.warn("[Chat] DB query failed, entering ephemeral mode:", err);
dbAvailable = false;
return { rows: [] as any[] };
}
}
app.post("/api/chat", async (req, res) => {
const { id: chatId, messages } = req.body as {
id?: string;
messages: Array<{ role: string; content: string; id?: string }>;
};
const userId = req.session!.userId;
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ error: "messages array required" });
}
// 1. Create chat record if new, persist user message
if (chatId) {
const existing = await dbQuery(`SELECT id FROM chat."Chat" WHERE id = $1`, [chatId]);
if (existing.rows.length === 0) {
await dbQuery(
`INSERT INTO chat."User" (id, email) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
[userId, req.session!.email],
);
await dbQuery(
`INSERT INTO chat."Chat" (id, title, "userId", "createdAt")
VALUES ($1, 'New chat', $2, CURRENT_TIMESTAMP)`,
[chatId, userId],
);
// Fire-and-forget title generation
const firstUserMsg = messages.find((m) => m.role === "user");
if (firstUserMsg) {
generateTitle(firstUserMsg.content, req)
.then((title) =>
dbQuery(`UPDATE chat."Chat" SET title = $1 WHERE id = $2`, [title, chatId]),
)
.catch((err) => console.warn("[Title] Generation failed:", err));
}
}
}
const lastUserMsg = messages[messages.length - 1];
if (lastUserMsg?.role === "user") {
const userMsgId = lastUserMsg.id ?? crypto.randomUUID();
await dbQuery(
`INSERT INTO chat."Message" (id, "chatId", role, parts, "createdAt")
VALUES ($1, $2, 'user', $3, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING`,
[userMsgId, chatId, JSON.stringify([{ type: "text", text: lastUserMsg.content }])],
);
}
// 2. Open SSE stream and proxy the agent
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const assistantMsgId = crypto.randomUUID();
let accumulatedText = "";
let traceId: string | null = null;
try {
for await (const chunk of AppKit.serving("agent").asUser(req).stream({ messages })) {
// Forward chunk to client
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
// Accumulate assistant text and capture trace_id
const parsed = chunk as Chunk;
const delta = extractDelta(parsed);
if (delta) accumulatedText += delta;
const tid = extractTraceId(parsed);
if (tid) traceId = tid;
}
// Emit metadata so the client can associate feedback with this message
res.write(
`data: ${JSON.stringify({ type: "meta", assistantMessageId: assistantMsgId, traceId })}\n\n`,
);
res.write("data: [DONE]\n\n");
res.end();
} catch (err) {
console.error("[Chat] Stream error:", err);
res.write(`data: ${JSON.stringify({ type: "error", error: String(err) })}\n\n`);
res.end();
}
// 3. Persist the assistant message after the stream closes
if (accumulatedText) {
await dbQuery(
`INSERT INTO chat."Message" (id, "chatId", role, parts, "traceId", "createdAt")
VALUES ($1, $2, 'assistant', $3, $4, CURRENT_TIMESTAMP)`,
[
assistantMsgId,
chatId,
JSON.stringify([{ type: "text", text: accumulatedText }]),
traceId,
],
);
// Always populate meta store so 08-appkit-feedback can look up traceId
// even when Lakebase is unavailable.
messageMetaStore.set(assistantMsgId, { chatId: chatId ?? "", traceId });
}
});
async function generateTitle(firstUserContent: string, req: import("express").Request): Promise<string> {
try {
const result = await AppKit.serving("agent").asUser(req).invoke({
messages: [
{
role: "system",
content:
"Generate a 4-6 word title summarizing this conversation. Respond with only the title, no punctuation.",
},
{ role: "user", content: firstUserContent.slice(0, 500) },
],
});
const text =
(result as any)?.choices?.[0]?.message?.content ??
(result as any)?.output?.[0]?.content?.[0]?.text ??
"New chat";
return String(text).trim().slice(0, 80);
} catch {
return firstUserContent.slice(0, 60);
}
}
// Export for 08-appkit-feedback
(globalThis as any).__appkitMessageMetaStore = messageMetaStore;
});
await AppKit.server.start();
Key points:
- Zero AI-SDK imports. Only
@databricks/appkit(AppKit.serving,AppKit.lakebase) and native Express. - The
for await (const chunk of ...)loop is the same pattern as 06-appkit-serving-wiring/SKILL.md Step 6b — only the body is extended. accumulatedTextis assembled server-side using the dual-formatextractDelta. This is what gets persisted, not the raw chunk stream.traceIdis scanned on every chunk — it usually arrives on the last chunk but checking each one is safer.- The
metaSSE event lets the client associateassistantMessageId+traceIdwith the message it just rendered. The client stores these on the message object so feedback works even if Lakebase is unavailable. messageMetaStoreis the ephemeral-mode fallback for feedback lookups. 08-appkit-feedback reads it when the DB lookup returns no rows.
4c. Client-Side Integration
The chat client from 06-appkit-serving-wiring/references/chat-ui-patterns.md already uses useServingStream to render the conversation. To persist, replace the direct call with a fetch to /api/chat:
import { useState, useRef } from "react";
interface Message {
id?: string;
role: "user" | "assistant";
content: string;
traceId?: string | null;
}
function ChatPage({ chatId }: { chatId?: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const [streaming, setStreaming] = useState(false);
const [input, setInput] = useState("");
const cid = useRef(chatId ?? crypto.randomUUID());
async function sendMessage() {
if (!input.trim() || streaming) return;
const userMsg: Message = { role: "user", content: input };
const nextMessages = [...messages, userMsg];
setMessages(nextMessages);
setInput("");
setStreaming(true);
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: cid.current, messages: nextMessages }),
});
if (!res.body) {
setStreaming(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantText = "";
let assistantMessageId: string | undefined;
let traceId: string | null = null;
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const dataLine = block.split("\n").find((l) => l.startsWith("data: "))?.slice(6);
if (!dataLine || dataLine === "[DONE]") continue;
try {
const parsed = JSON.parse(dataLine);
if (parsed.type === "meta") {
assistantMessageId = parsed.assistantMessageId;
traceId = parsed.traceId ?? null;
} else if (parsed.type === "error") {
console.error("[Chat] stream error:", parsed.error);
} else {
// Use the same extractDelta logic on the client
const delta =
parsed.type === "response.output_text.delta"
? parsed.delta ?? ""
: parsed.choices?.[0]?.delta?.content ?? "";
if (delta) {
assistantText += delta;
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: "assistant", content: assistantText };
return next;
});
}
}
} catch {
/* keepalive or partial */
}
}
}
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = {
role: "assistant",
content: assistantText,
id: assistantMessageId,
traceId,
};
return next;
});
setStreaming(false);
}
return (
<div className="flex flex-col h-full">
{/* ... render messages, input, etc. Use MessageBubble from chat-ui-patterns.md */}
</div>
);
}
The id and traceId fields on the rendered assistant message are what 08-appkit-feedback reads from — without them, the feedback button can't link to MLflow.
Gate: npm run build passes. After a test conversation, GET /api/messages/<chatId> returns both the user and assistant messages with the assistant's traceId populated.
Step 5: Build the History Sidebar
Plain React + fetch — zero SDK dependencies.
import { useState, useEffect } from "react";
import { NavLink, useNavigate } from "react-router-dom";
interface ChatSummary {
id: string;
title: string;
createdAt: string;
}
export function ChatSidebar() {
const [chats, setChats] = useState<ChatSummary[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const navigate = useNavigate();
const loadChats = (startingAfter?: string) => {
const params = new URLSearchParams();
if (startingAfter) params.set("starting_after", startingAfter);
fetch(`/api/history?${params}`)
.then((res) => {
if (res.status === 204) {
setEphemeral(true);
return null;
}
return res.json();
})
.then((data) => {
if (data) {
setChats((prev) => (startingAfter ? [...prev, ...data.data] : data.data));
setHasMore(data.hasMore ?? false);
}
})
.catch(() => setEphemeral(true))
.finally(() => setLoading(false));
};
useEffect(() => {
loadChats();
}, []);
const deleteChat = async (chatId: string) => {
await fetch(`/api/chat/${chatId}`, { method: "DELETE" });
setChats((prev) => prev.filter((c) => c.id !== chatId));
};
if (loading) {
return (
<aside className="w-64 border-r bg-muted/30 p-3">
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-8 rounded bg-muted" />
))}
</div>
</aside>
);
}
return (
<aside className="w-64 border-r bg-muted/30 p-3 flex flex-col">
<button
=> navigate("/chat")}
className="mb-3 w-full rounded-md border bg-background px-3 py-2 text-sm font-medium hover:bg-muted"
>
+ New Chat
</button>
{ephemeral && (
<div className="mb-2 rounded bg-yellow-100 px-2 py-1 text-xs text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200">
Ephemeral — no history
</div>
)}
<div className="flex-1 overflow-y-auto space-y-1">
{chats.length === 0 && !ephemeral && (
<p className="text-xs text-muted-foreground px-2">No conversations yet</p>
)}
{chats.map((chat) => (
<div key={chat.id} className="group flex items-center">
<NavLink
to={`/chat/${chat.id}`}
className={({ isActive }) =>
`flex-1 truncate rounded px-2 py-1.5 text-sm ${
isActive ? "bg-muted font-medium" : "hover:bg-muted/50"
}`
}
>
{chat.title}
</NavLink>
<button
=> deleteChat(chat.id)}
className="hidden group-hover:block px-1 text-xs text-muted-foreground hover:text-destructive"
>
×
</button>
</div>
))}
{hasMore && (
<button
=> {
const last = chats[chats.length - 1];
if (last) loadChats(last.id);
}}
className="w-full rounded px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted/50"
>
Load more...
</button>
)}
</div>
</aside>
);
}
Wire into the router:
function ChatLayout() {
return (
<div className="flex h-[calc(100vh-4rem)]">
<ChatSidebar />
<div className="flex-1">
<Outlet />
</div>
</div>
);
}
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
path: "chat",
element: <ChatLayout />,
children: [
{ index: true, element: <ChatPage /> },
{ path: ":chatId", element: <ChatPage /> },
],
},
],
},
]);
Gate: Sidebar renders, lists conversations, and "Load more" pagination works when hasMore is true.
Step 6: Load Existing Messages on Navigation
When navigating to an existing chat, fetch prior messages and hydrate ChatPage state:
import { useParams } from "react-router-dom";
export function ChatPage() {
const { chatId } = useParams();
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
if (!chatId) {
setMessages([]);
return;
}
fetch(`/api/messages/${chatId}`)
.then((res) => res.json())
.then((data) => {
if (data?.data) {
setMessages(
data.data.map((m: { id: string; role: string; parts: Array<{ type: string; text?: string }>; traceId?: string }) => ({
id: m.id,
role: m.role as "user" | "assistant",
content: m.parts?.find((p) => p.type === "text")?.text ?? "",
traceId: m.traceId ?? null,
})),
);
}
})
.catch(() => {});
}, [chatId]);
// ... rest of ChatPage (sendMessage, render, etc.)
}
Gate: Clicking a past conversation in the sidebar loads its messages and the traceId is preserved on assistant messages (visible in React DevTools).
Step 7: Ephemeral Mode
When Lakebase is unavailable, the chat still works — only persistence is skipped. This mirrors the mock fallback pattern from 05-appkit-lakebase-wiring/SKILL.md.
Verify ephemeral mode manually by deploying without the postgres app resource binding. Expected behavior:
GET /api/historyreturns204 No Content→ sidebar shows "Ephemeral — no history"POST /api/chatstill streams a response (thedbQuerywrapper short-circuits ondbAvailable = false)- The
metaSSE event still fires withtraceId, so 08-appkit-feedback can still log MLflow assessments via the in-memorymessageMetaStore
No new code needed — the dbQuery helper in Step 4b already handles the degradation transparently.
Gate: With Lakebase resource binding removed, the chat streams responses normally and the sidebar displays the "Ephemeral" banner.
Step 8: Lakebase Cold-Start Retry Policy
Lakebase Autoscaling closes idle pool connections, so the first request after idle can hit psycopg.errors.AdminShutdown or psycopg_pool.PoolClosed even when Lakebase is healthy. The dbQuery wrapper in Step 4b currently flips dbAvailable = false on the first error — that is too aggressive for cold-start failures and will silently force the chat into ephemeral mode for the rest of the process lifetime. Add an explicit retry contract.
This mirrors the lakebase_cold_start_retry_policy contract owned by the Track A custom-agent skill — see 02-agent-framework SKILL.md ("Lakebase cold-start retry policy and lazy init"). Both the Node AppKit chat route and the Python agent must honor the same retry contract so that user-visible behavior is consistent across stacks.
Retry contract
Capture the same fields the Track A agent captures, expressed in Node terms (pg/AppKit's lakebase plugin uses node-postgres semantics — the canonical class names are listed below):
// server/lakebase-retry.ts
const lakebaseColdStartRetryPolicy = {
// Canonical first-request-after-idle failure modes. AppKit's lakebase
// plugin surfaces these as PostgresError instances with these `code`s
// and `severity: "FATAL"`. Match by code, NOT by string-matching the
// error message.
retryOn: [
"57P01", // admin_shutdown — Lakebase autoscaler closed the prior connection
"57P03", // cannot_connect_now — pool not yet ready after cold start
"ECONNRESET", // socket-level reset during cold start
"POOL_CLOSED", // synthetic — emitted when AppKit closes the pool mid-request
],
maxAttempts: 3,
initialBackoffSeconds: 5, // exponential: 5s, 10s, 20s
} as const;
// Pool close policy on graceful shutdown. AppKit handles this via its
// own `server.on("close", ...)` lifecycle — do not close the pool from
// inside a request handler.
const lakebasePoolClosePolicy = "close_on_app_shutdown" as const;
Wrap AppKit.lakebase.query with the policy and use it in place of the dbQuery helper from Step 4b:
async function withColdStartRetry<T>(fn: () => Promise<T>): Promise<T> {
const { retryOn, maxAttempts, initialBackoffSeconds } = lakebaseColdStartRetryPolicy;
let lastErr: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const code = (err as { code?: string })?.code;
const matches = code !== undefined && retryOn.includes(code as (typeof retryOn)[number]);
if (!matches || attempt + 1 === maxAttempts) throw err;
const backoffMs = initialBackoffSeconds * 1000 * 2 ** attempt;
console.warn(
`[Lakebase] cold-start retry ${attempt + 1}/${maxAttempts} after ${code} — sleeping ${backoffMs}ms`,
);
await new Promise((r) => setTimeout(r, backoffMs));
}
}
throw lastErr;
}
async function dbQuery(sql: string, params: unknown[]) {
if (!dbAvailable) return { rows: [] as any[] };
try {
return await withColdStartRetry(() => AppKit.lakebase.query(sql, params));
} catch (err) {
// Only flip to ephemeral mode after the retry budget is exhausted.
console.warn("[Chat] DB query failed after cold-start retries, entering ephemeral mode:", err);
dbAvailable = false;
return { rows: [] as any[] };
}
}
DO / DON'T
DO match by error.code against the canonical class list above. PostgresErrors have stable SQLSTATE codes; their human-readable messages are localized and change between minor versions.
DO keep the lakebaseColdStartRetryPolicy object in one module — server/lakebase-retry.ts — and import it into server.ts. Tests need to reference the same constants.
DON'T catch a bare Error or unknown and retry. Retrying on application-level errors (bad SQL, missing schema) hides bugs.
DON'T flip dbAvailable = false on the first error. The whole point of the retry policy is to absorb the cold-start kick without dropping into ephemeral mode for every subsequent request.
DON'T close the pool inside a request handler. Pool close is `la
…(truncated)