Add persistent chat history to an AppKit application using the Lakebase plugin and the Serving plugin (both AppKit-native, no Vercel AI SDK). Covers database schema, session auth middleware, a server-side proxy route that streams from `AppKit.serving("agent").asUser(req).stream()` while persisting assistant messages and capturing MLflow `trace_id`, the history API routes, a history sidebar, and ephemeral-mode fallback. Use when asked to persist conversations, add chat history, save messages, ...
Scanned 9/12/2026
Install to Claude Code
npx -y skills add databricks-solutions/vibe-coding-workshop-template --skill 07-appkit-chat-history --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of 07 Appkit Chat History?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/databricks-solutions-07-appkit-chat-history)More formats (shields.io, HTML) on the badges page.
---
name: 07-appkit-chat-history
description: >
Add persistent chat history to an AppKit application using the Lakebase plugin
and the Serving plugin (both AppKit-native, no Vercel AI SDK). Covers database
schema, session auth middleware, a server-side proxy route that streams from
`AppKit.serving("agent").asUser(req).stream()` while persisting assistant
messages and capturing MLflow `trace_id`, the history API routes, a history
sidebar, and ephemeral-mode fallback. Use when asked to persist conversations,
add chat history, save messages, or build a conversation sidebar. Triggers on
"chat history", "save conversations", "persistent chat", "conversation sidebar",
"message storage", "save messages", "ephemeral chat".
license: Apache-2.0
compatibility: Requires 05-appkit-lakebase-wiring and 06-appkit-serving-wiring complete, Node.js v22+, Databricks CLI >= 0.295.0
allowed-tools: Bash(databricks:*) Bash(npm:*) Bash(curl:*) Bash(node:*) Read
clients: [ide_cli, genie_code]
bundle_resource: apps
deploy_verb: apps_deploy
deploy_note: >
Chat-history persistence is source editing (server.ts DDL/routes, sidebar, hooks) — client-agnostic.
The `chat` schema/table DDL runs **server-side on app startup** under the SP (RULE_10 Deploy-First) on both
clients — never run it locally. IDE: `npm run build`/`npm test` gates, `databricks bundle validate
--profile $PROFILE`, then `databricks apps deploy --profile $PROFILE`. Genie Code: local `npm` gates are an
IDE convenience (server-side build on deploy); run `bundle validate` via `runDatabricksCli` (omit
`--profile`; `--target dev` if targetless blocked); deploy per `03-appkit-deploy`. `apps validate` is
hard-blocked and (per Step 9) unsafe for Lakebase apps — skip on both clients. Verify via browser + logs.
coverage: full
metadata:
author: prashanth subrahmanyam
version: "1.2.0"
domain: apps
role: chat-history
standalone: false
last_verified: "2026-06-02"
volatility: medium
upstream_sources:
- name: "databricks-agent-skills/databricks-lakebase"
repo: "databricks/databricks-agent-skills"
paths:
- "skills/databricks-lakebase/SKILL.md"
relationship: "extended"
last_synced: "2026-04-27"
sync_commit: "manifest-v2-2026-04-22"
- name: "databricks-agent-skills/databricks-apps"
repo: "databricks/databricks-agent-skills"
paths:
- "skills/databricks-apps/SKILL.md"
relationship: "extended"
last_synced: "2026-04-27"
sync_commit: "manifest-v2-2026-04-22"
---
# 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](../06-appkit-serving-wiring/SKILL.md).
## 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_id` server-side for later feedback logging (see [08-appkit-feedback](../08-appkit-feedback/SKILL.md))
**Not for agent streaming fundamentals.** Use [06-appkit-serving-wiring](../06-appkit-serving-wiring/SKILL.md) to wire `useServingStream` and the base `/api/serving/:alias/stream` route first.
**Not for plugin registration.** Use [04-appkit-plugin-add](../04-appkit-plugin-add/SKILL.md) + [05-appkit-lakebase-wiring](../05-appkit-lakebase-wiring/SKILL.md) to register the Lakebase plugin and declare bundle resources first.
---
## Prerequisites
Verify all of these before proceeding:
1. Lakebase plugin registered in `server/server.ts` with bundle resources deployed (see [05-appkit-lakebase-wiring](../05-appkit-lakebase-wiring/SKILL.md))
2. Serving plugin registered in `server/server.ts` with a working agent endpoint (see [06-appkit-serving-wiring](../06-appkit-serving-wiring/SKILL.md))
3. `useServingStream` renders a basic chat UI that works end-to-end
4. App deployed at least once so the Service Principal exists and can create schemas
5. `AppKit.server` is constructed with `server({ autoStart: false })` and `AppKit.server.start()` is called after `AppKit.server.extend(...)`
**Upstream docs (always check for latest):**
```bash
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](../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 `psql` script.** `initChatSchema()` runs **server-side on every app startup** via `AppKit.lakebase.query()`, so the app's Service Principal owns the schema, tables, and indexes (the same Deploy-First Pattern as `05-appkit-lakebase-wiring` Step 1d). This is **client-agnostic** — it executes identically whether the app was deployed from an IDE or Genie Code, and there is **no client-side `psql`/DDL step**. Do not move it into `databricks.yml` or a setup script: idempotent `CREATE … IF NOT EXISTS` on startup is the correct, SP-owning pattern.
```typescript
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](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 id
> - `x-forwarded-access-token` — downscoped OBO token (only present when `user_api_scopes` is declared)
>
> `x-forwarded-user-info` is **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.
```typescript
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:
```typescript
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](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.
```typescript
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:**
```bash
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](../06-appkit-serving-wiring/SKILL.md) Step 6b, extended with Lakebase persistence and trace-ID capture.
> **Prerequisite — `autoStart: false` on the server plugin.** Your `createApp` must be declared as:
>
> ```typescript
> const AppKit = await createApp({
> plugins: [
> server({ autoStart: false }),
> serving(),
> lakebase(),
> ],
> });
> ```
>
> The final `await AppKit.server.start()` call happens after all `server.extend(...)` blocks. Same pattern already in [06-appkit-serving-wiring](../06-appkit-serving-wiring/SKILL.md) 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](../05-appkit-lakebase-wiring/SKILL.md) build system note):
```typescript
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](../06-appkit-serving-wiring/references/sse-format-patterns.md). The `extractTraceId` function is documented in detail in [08-appkit-feedback/references/trace-extraction.md](../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
```typescript
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](../06-appkit-serving-wiring/SKILL.md) Step 6b — only the body is extended.
- `accumulatedText` is assembled server-side using the dual-format `extractDelta`. This is what gets persisted, not the raw chunk stream.
- `traceId` is scanned on every chunk — it usually arrives on the last chunk but checking each one is safer.
- The `meta` SSE event lets the client associate `assistantMessageId` + `traceId` with the message it just rendered. The client stores these on the message object so feedback works even if Lakebase is unavailable.
- `messageMetaStore` is the ephemeral-mode fallback for feedback lookups. [08-appkit-feedback](../08-appkit-feedback/SKILL.md) 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](../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`:
```tsx
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](../08-appkit-feedback/SKILL.md) 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.
```tsx
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
onClick={() => 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
onClick={() => deleteChat(chat.id)}
className="hidden group-hover:block px-1 text-xs text-muted-foreground hover:text-destructive"
>
×
</button>
</div>
))}
{hasMore && (
<button
onClick={() => {
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:
```tsx
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:
```tsx
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](../05-appkit-lakebase-wiring/SKILL.md).
Verify ephemeral mode manually by deploying without the `postgres` app resource binding. Expected behavior:
- `GET /api/history` returns `204 No Content` → sidebar shows "Ephemeral — no history"
- `POST /api/chat` still streams a response (the `dbQuery` wrapper short-circuits on `dbAvailable = false`)
- The `meta` SSE event still fires with `traceId`, so [08-appkit-feedback](../08-appkit-feedback/SKILL.md) can still log MLflow assessments via the in-memory `messageMetaStore`
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](../../../genai-agents/tracks/A-custom-agent-apps/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):
```typescript
// 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:
```typescript
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 `lakebasePoolClosePolicy: close_on_app_shutdown` — it belongs in the AppKit server lifecycle hook, not in route code.
### First-request-retry test
Add a test that simulates a single cold-start failure and asserts the second attempt succeeds. This is the test that gates `preflight_check_registry.lakebase_cold_start_retry_policy_present` for the AppKit stack — without it, the rollup considers the policy "documented but not exercised."
```typescript
// server/__tests__/lakebase-cold-start.test.ts
import { describe, it, expect, vi } from "vitest";
describe("Lakebase cold-start retry", () => {
it("retries on AdminShutdown (57P01) and succeeds on the second attempt", async () => {
const adminShutdown = Object.assign(new Error("terminating connection"), {
code: "57P01",
severity: "FATAL",
});
const query = vi
.fn()
.mockRejectedValueOnce(adminShutdown)
.mockResolvedValueOnce({ rows: [{ ok: 1 }] });
// Inject the mocked query into withColdStartRetry — same wrapper used in production.
const result = await withColdStartRetry(() => query());
expect(query).toHaveBeenCalledTimes(2);
expect(result.rows).toEqual([{ ok: 1 }]);
});
it("retries up to maxAttempts then re-throws", async () => {
const poolClosed = Object.assign(new Error("pool closed"), { code: "POOL_CLOSED" });
const query = vi.fn().mockRejectedValue(poolClosed);
await expect(withColdStartRetry(() => query())).rejects.toMatchObject({ code: "POOL_CLOSED" });
expect(query).toHaveBeenCalledTimes(3); // maxAttempts
});
it("does NOT retry on application-level errors (e.g. 42P01 undefined_table)", async () => {
const undefinedTable = Object.assign(new Error('relation "chat.Chat" does not exist'), {
code: "42P01",
});
const query = vi.fn().mockRejectedValue(undefinedTable);
await expect(withColdStartRetry(() => query())).rejects.toMatchObject({ code: "42P01" });
expect(query).toHaveBeenCalledTimes(1); // no retry
});
});
```
Run with `npm test -- lakebase-cold-start`. All three tests must pass before declaring Step 8 complete.
**Gate:** `npm test` passes the three retry tests above. Deploy logs show no spurious `entering ephemeral mode` warnings during the first request after a 5+ minute idle period.
---
## Step 9: Validator Choice — `bundle validate` vs `apps validate`
Two validators ship with the Databricks CLI. They check different things, and one of them is **unsafe to run locally for a Lakebase-backed AppKit project**.
| Validator | What it checks | Use it when |
|-----------|----------------|-------------|
| `databricks bundle validate --profile $PROFILE` | Bundle config: `databricks.yml` resource declarations, `valueFrom` references, `serving_endpoint` schema fields, `postgres_*` resource shape, target environment variables. **Pure config validation — never boots the app.** | Always, before every `databricks apps deploy`. This is the canonical bundle-config gate. |
| `databricks apps validate --profile $PROFILE` | App manifest: `app.yaml` schema, command line, env-var bindings. **May initialize a local boot of the app** to type-check resource bindings. | Only for app-config-only changes, in workspaces where every `valueFrom` resolves locally. |
### Why `databricks apps validate` is dangerous for Lakebase apps
`databricks apps validate` can boot the AppKit server locally to verify that env-var bindings resolve. On a Lakebase-backed app, that local boot:
1. Calls `AppKit.lakebase.query()` from `initChatSchema()` (Step 1).
2. Resolves `LAKEBASE_ENDPOINT` from the local environment, **not** the deployed app's env (because deployed `valueFrom: postgres` is only resolved at platform startup).
3. Either fails outright (no Lakebase endpoint locally) or — worse — succeeds against an unrelated dev Postgres and writes the `chat` schema there.
This produces validation errors that are **not** representative of the deployed environment. Engineers then "fix" them by adding fake local Postgres URIs to `.env`, which masks real bundle-config issues that `databricks bundle validate` would have caught.
### Required gates
For any deploy from this skill:
```bash
# REQUIRED — bundle config gate. Never skip.
databricks bundle validate --profile $PROFILE
# OPTIONAL — only safe for app-config-only changes (no schema init, no Lakebase touch).
# Do NOT run for Lakebase-backed apps unless you have a local Postgres URI that mirrors
# the deployed schema. If unsure, skip this step — `bundle validate` already covers
# bundle config and `databricks apps deploy` will surface app.yaml errors at deploy time.
databricks apps validate --profile $PROFILE
```
> **Client note — Genie Code:** run `bundle validate` via `runDatabricksCli` (omit `--profile`; add `--target dev` if a targetless validate is guardrail-blocked). **Skip `apps validate` entirely** — it is hard-blocked on Genie Code *and* unsafe for Lakebase apps (boots locally against `initChatSchema()`); rely on `bundle validate` + the server-side build logs after deploy.
> **Treat `bundle validate` warnings as errors.** This is a load-bearing rule from the rollup ("`databricks bundle validate` warnings can be load-bearing errors", Skill 06d). Common warning-as-error cases for this skill: `unknown field: endpoint_name` on a `serving_endpoint` (rename to `name:`), `valueFrom: postgres` with no `postgres_*` resource declared, missing `permission` field on a declared resource. See [03-appkit-deploy SKILL.md](../03-appkit-deploy/SKILL.md) "Common Errors" for the canonical fix table.
**Gate:** `databricks bundle validate --profile $PROFILE` exits 0 with no warnings. Deploy is gated on this; `databricks apps validate` is not run for Lakebase-backed builds (or, if it is run, the failure is correlated against `bundle validate` first).
---
## Validation Gate
All must pass before declaring chat history complete:
- [ ] `CREATE SCHEMA chat` and all four tables created successfully (check deploy logs for `[Chat] Schema initialized`)
- [ ] `npm run build` passes with zero TypeScript errors
- [ ] `GET /api/history` returns `{ data, hasMore }` (or `204` in ephemeral mode)
- [ ] `GET /api/history?starting_after=<id>` returns the next page
- [ ] `GET /api/messages/:chatId` returns message list with `traceId` populated on assistant messages
- [ ] `PATCH /api/chat/:chatId/visibility` changes visibility (owner only)
- [ ] `POST /api/chat` streams responses and persists both user and assistant messages
- [ ] Assistant messages have a non-null `traceId` when the agent endpoint has MLflow tracing enabled
- [ ] Chat sidebar renders, lists conversations, and "Load more" works
- [ ] Navigating to a past chat loads its messages
- [ ] New chats appear in the sidebar after the first exchange
- [ ] Chat titles are auto-generated from the first user message
- [ ] Deleting a chat removes it from the sidebar and cascades to messages + votes
- [ ] Auth middleware attaches `req.session` to all chat routes
- [ ] App works in ephemeral mode when Lakebase is unavailable (sidebar shows "Ephemeral")
- [ ] Zero imports of `ai` or `@databricks/ai-sdk-provider` anywhere in `server/`
- [ ] `lakebaseColdStartRetryPolicy` is captured as a named constant matching the Track A contract (`retryOn` ⊇ `["57P01", "POOL_CLOSED"]`, `maxAttempts: 3`, `initialBackoffSeconds: 5`)
- [ ] First-request-retry test passes: simulated `57P01` on attempt 1 succeeds on attempt 2 (Step 8 test suite)
- [ ] `dbAvailable` flips to `false` only after `maxAttempts` retries fail, not on the first error
- [ ] `databricks bundle validate --profile $PROFILE` exits 0 with no warnings (warnings treated as errors per Skill 06d)
- [ ] `databricks apps validate` is **not** run as a deploy gate for this Lakebase-backed app (or, if run, only after `bundle validate` is green)
---
## Gotchas
| Gotcha | Fix | Step |
|--------|-----|------|
| `server.extend()` routes never registered | Set `server({ autoStart: false })` on plugin registration; call `AppKit.server.start()` after all `extend()` blocks | 4 |
| `trace_id` is always null | Endpoint doesn't have MLflow tracing enabled. Redeploy agent with `databricks.agents.deploy(..., enable_trace=True)` | 4 |
| `accumulatedText` is empty but UI shows streaming text | Parser reads only `choices[0].delta.content` on a Databricks Responses-API endpoint. Use the dual `extractDelta` from [06-appkit-serving-wiring/references/sse-format-patterns.md](../06-appkit-serving-wiring/references/sse-format-patterns.md) | 4a |
| Assistant message is persisted but client-side UI doesn't know the id | Missing the `meta` SSE event. Verify the server emits `{ type: "meta", assistantMessageId, traceId }` before `[DONE]` | 4b |
| Sidebar always shows "Ephemeral" even when deployed | `postgres` app resource binding missing in `databricks.yml` — see [05-appkit-lakebase-wiring](../05-appkit-lakebase-wiring/SKILL.md) Phase 2 | 7 |
| Title generation blocks the response | Fire-and-forget (`.then().catch()`), never `await` the title call inside the chat route | 4b |
| 403 on `/api/chat/:chatId/visibility` | User is not the chat owner — verify `req.session.userId === chat.userId` | 3 |
| Local dev without `DEV_USER_EMAIL` | All chats are owned by `"local-dev-user"`. Set `DEV_USER_EMAIL` in `.env` to attribute correctly | 2 |
| Read `x-forwarded-user-info` to identify the user | That header does not exist on Databricks Apps. Use the canonical `x-forwarded-email` / `x-forwarded-preferred-username` / `x-forwarded-user` triplet | 2 |
| Need to forward user identity to a separate Agent App | Set `x-app-user-email` outbound from the inbound `x-forwarded-email`. See [06d-appkit-agent-app-proxy](../06d-appkit-agent-app-proxy/SKILL.md) Header Contract | — |
| First request after idle hits `57P01 admin_shutdown` and the chat silently goes ephemeral for the rest of the process | `dbQuery` flipped `dbAvailable = false` on the first error. Wrap with `withColdStartRetry` from `lakebase_cold_start_retry_policy` and only flip after `maxAttempts` exhausts | 8 |
| Retry policy catches application errors (e.g. missing `chat."Chat"` table → `42P01`) and masks bugs | Match by SQLSTATE `code` against the canonical `retryOn` list, never by `instanceof Error` or string matching | 8 |
| `databricks apps validate` boots the app locally, fails on Lakebase init, and "validation errors" don't reproduce after deploy | Stop running `databricks apps validate` as a gate. Use `databricks bundle validate` for bundle config; let the platform run app boot at deploy time | 9 |
| `bundle validate` warns but does not error; deploy succeeds; runtime fails with `error resolving resource postgres` | `bundle validate` warnings are load-bearing. Treat any warning as an error and fix before deploy. See [03-appkit-deploy SKILL.md](../03-appkit-deploy/SKILL.md) Common Errors | 9 |
---
## What's Next
- **User feedback on assistant responses:** [08-appkit-feedback](../08-appkit-feedback/SKILL.md) — add thumbs up/down linked to MLflow assessments using the `traceId` captured here.
---
## Related Skills
| Skill | Relationship |
|-------|-------------|
| [05-appkit-lakebase-wiring](../05-appkit-lakebase-wiring/SKILL.md) | Prerequisite — Lakebase plugin + bundle resources |
| [06-appkit-serving-wiring](../06-appkit-serving-wiring/SKILL.md) | Prerequisite — Serving plugin, `useServingStream`, dual-format SSE parser |
| [08-appkit-feedback](../08-appkit-feedback/SKILL.md) | Next step — user feedback + MLflow assessments |
| [04-appkit-plugin-add](../04-appkit-plugin-add/SKILL.md) | Reference — Lakebase and Serving plugin configuration |
| [03-appkit-deploy](../03-appkit-deploy/SKILL.md) | Deploy the app so the Service Principal creates the `chat` schema; also owns `bundle validate` warning-as-error contract (Step 9) |
| [Track A 02-agent-framework](../../../genai-agents/tracks/A-custom-agent-apps/02-agent-framework/SKILL.md) | Owns the canonical `lakebase_cold_start_retry_policy` contract (Step 8). Both stacks must honor the same `retry_on` / `max_attempts` / `initial_backoff_seconds` shape. |
---
## See Also
- Upstream platform skills: [`databricks-lakebase`](https://github.com/databricks/databricks-agent-skills/tree/main/skills/databricks-lakebase) and [`databricks-apps`](https://github.com/databricks/databricks-agent-skills/tree/main/skills/databricks-apps) (both tracked in `upstream_sources`).
- AppKit plugin docs: [Lakebase](https://databricks.github.io/appkit/docs/plugins/lakebase), [Serving](https://databricks.github.io/appkit/docs/plugins/serving).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!