From 7ef5c4d84ad267d9edde18d403892764eee02aaf Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Thu, 30 Jul 2026 15:37:15 +0300 Subject: [PATCH 1/8] feat(examples): add an AI chatbot example over a private Postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A streaming chatbot container that answers questions about a private Postgres through a SQL tool run on read-only sessions. No API keys and no database credentials in the app: the AI binding routes through the gateway's ambient cloud identity, and the Postgres password resolves at runtime from the cloud secret store via postgres("db").connection(). Both alien packages stay serverExternalPackages — their native halves (napi addon, gateway binary) resolve with dynamic requires the bundler cannot see — and the per-platform prebuild packages are traced into the standalone output explicitly for the same reason. The image base is glibc because the bindings addon ships no musl prebuild. --- examples/ai-chatbot-ts/.dockerignore | 8 + examples/ai-chatbot-ts/.gitignore | 18 + examples/ai-chatbot-ts/Dockerfile | 22 + examples/ai-chatbot-ts/README.md | 48 + examples/ai-chatbot-ts/alien.ts | 38 + examples/ai-chatbot-ts/app/api/chat/route.ts | 81 + .../ai-chatbot-ts/app/api/models/route.ts | 6 + examples/ai-chatbot-ts/app/api/seed/route.ts | 55 + .../app/components/grain-background.tsx | 37 + .../ai-chatbot-ts/app/components/message.tsx | 59 + .../app/components/query-card.tsx | 118 ++ .../ai-chatbot-ts/app/components/spinner.tsx | 18 + examples/ai-chatbot-ts/app/globals.css | 14 + examples/ai-chatbot-ts/app/layout.tsx | 18 + examples/ai-chatbot-ts/app/page.tsx | 235 +++ examples/ai-chatbot-ts/next.config.ts | 20 + examples/ai-chatbot-ts/package.json | 35 + examples/ai-chatbot-ts/postcss.config.mjs | 5 + examples/ai-chatbot-ts/public/robots.txt | 2 + examples/ai-chatbot-ts/template.toml | 3 + examples/ai-chatbot-ts/tsconfig.json | 30 + examples/pnpm-lock.yaml | 1337 ++++++++++++++++- examples/pnpm-workspace.yaml | 1 + 23 files changed, 2198 insertions(+), 10 deletions(-) create mode 100644 examples/ai-chatbot-ts/.dockerignore create mode 100644 examples/ai-chatbot-ts/.gitignore create mode 100644 examples/ai-chatbot-ts/Dockerfile create mode 100644 examples/ai-chatbot-ts/README.md create mode 100644 examples/ai-chatbot-ts/alien.ts create mode 100644 examples/ai-chatbot-ts/app/api/chat/route.ts create mode 100644 examples/ai-chatbot-ts/app/api/models/route.ts create mode 100644 examples/ai-chatbot-ts/app/api/seed/route.ts create mode 100644 examples/ai-chatbot-ts/app/components/grain-background.tsx create mode 100644 examples/ai-chatbot-ts/app/components/message.tsx create mode 100644 examples/ai-chatbot-ts/app/components/query-card.tsx create mode 100644 examples/ai-chatbot-ts/app/components/spinner.tsx create mode 100644 examples/ai-chatbot-ts/app/globals.css create mode 100644 examples/ai-chatbot-ts/app/layout.tsx create mode 100644 examples/ai-chatbot-ts/app/page.tsx create mode 100644 examples/ai-chatbot-ts/next.config.ts create mode 100644 examples/ai-chatbot-ts/package.json create mode 100644 examples/ai-chatbot-ts/postcss.config.mjs create mode 100644 examples/ai-chatbot-ts/public/robots.txt create mode 100644 examples/ai-chatbot-ts/template.toml create mode 100644 examples/ai-chatbot-ts/tsconfig.json diff --git a/examples/ai-chatbot-ts/.dockerignore b/examples/ai-chatbot-ts/.dockerignore new file mode 100644 index 000000000..c4946c1ae --- /dev/null +++ b/examples/ai-chatbot-ts/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.alien +alien.ts +template.toml +README.md +.env*.local diff --git a/examples/ai-chatbot-ts/.gitignore b/examples/ai-chatbot-ts/.gitignore new file mode 100644 index 000000000..330682341 --- /dev/null +++ b/examples/ai-chatbot-ts/.gitignore @@ -0,0 +1,18 @@ +# Node +node_modules/ +package-lock.json +pnpm-lock.yaml + +# Next.js +.next/ +next-env.d.ts +*.tsbuildinfo + +# Alien +.alien/ + +# Env +.env*.local + +# OS +.DS_Store diff --git a/examples/ai-chatbot-ts/Dockerfile b/examples/ai-chatbot-ts/Dockerfile new file mode 100644 index 000000000..d8293a0e3 --- /dev/null +++ b/examples/ai-chatbot-ts/Dockerfile @@ -0,0 +1,22 @@ +# The bindings addon ships glibc-only prebuilds built against glibc 2.39, so the +# base must be glibc >= 2.39 in both stages: alpine (musl) cannot install them and +# bookworm (2.36) cannot load them. +FROM node:22-trixie-slim AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +FROM node:22-trixie-slim +WORKDIR /app +ENV NODE_ENV=production +# .next/static and public are not part of the standalone output and must be +# copied alongside it for server.js to serve them. +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/examples/ai-chatbot-ts/README.md b/examples/ai-chatbot-ts/README.md new file mode 100644 index 000000000..45bb90242 --- /dev/null +++ b/examples/ai-chatbot-ts/README.md @@ -0,0 +1,48 @@ +# AI chatbot + +A streaming chatbot that runs as a single container in the customer's cloud and +answers questions about a private **Postgres** it queries with a tool. No API +keys and no database credentials in the app. + +## How it works + +- `alien.ts` declares a model-less `alien.AI("llm")` and a private + `alien.Postgres("db")`, and links both to the container. At deploy time Alien + grants the workload `ai/invoke` + `postgres/data-access` and injects + `ALIEN_LLM_BINDING` and `ALIEN_DB_BINDING`. +- `app/api/chat/route.ts` resolves the model endpoint with + `getAiConnection("llm")` and streams with the Vercel AI SDK's `streamText`. + On a cloud, the binding routes through Alien's embedded OpenAI-compatible + gateway, which injects the workload's ambient cloud credential. On `alien dev` + the binding carries the developer's own provider key, and the app calls the + provider directly. +- The chat route gives the model a `queryDatabase` tool: a single SQL statement + per call, run on read-only sessions so the model cannot write. It reads the + connection with `postgres("db").connection()`, which resolves the password at + runtime using the workload's own identity, so the password never sits in + checked-in config. +- `app/api/models/route.ts` calls `ai("llm").getAvailableModels()` so the UI's + model picker reflects the binding's model set. +- The UI (`app/page.tsx`) is a full chat surface built on `useChat`: streamed + markdown answers, a card for every `queryDatabase` call showing the SQL and + the rows it returned, suggested questions, a model picker, and stop/retry. + +## Run it + +In the customer's cloud: + +```bash +alien deploy +``` + +Locally, bring your own provider key: + +```bash +OPENAI_API_KEY=sk-... alien dev +``` + +Open the app URL, click **Seed demo data** (or `curl -X POST /api/seed` +— it drops and recreates the demo tables, so anyone with the URL can reset them), +and ask a data question, e.g. *"How many enterprise customers do we have and +what's the total MRR?"* The model writes the SQL, calls `queryDatabase`, and +summarizes the result. diff --git a/examples/ai-chatbot-ts/alien.ts b/examples/ai-chatbot-ts/alien.ts new file mode 100644 index 000000000..40f712ddd --- /dev/null +++ b/examples/ai-chatbot-ts/alien.ts @@ -0,0 +1,38 @@ +import * as alien from "@alienplatform/core" + +// A model-less AI resource. The customer's cloud serves the inference; the +// embedded gateway injects the workload's ambient identity, so no API keys. +const llm = new alien.AI("llm").build() + +// A private Postgres, reachable only from same-stack workloads; the app resolves +// its connection at runtime from the binding, never from a checked-in secret. +const db = new alien.Postgres("db").build() + +const app = new alien.Container("app") + .code({ type: "source", src: ".", toolchain: { type: "docker", dockerfile: "Dockerfile" } }) + .cpu(0.5) + .memory("512Mi") + .port(3000) + .publicEndpoint("web", 3000, "http") + // Next's standalone server reads these; HOSTNAME=0.0.0.0 binds all interfaces. + .environment({ PORT: "3000", HOSTNAME: "0.0.0.0" }) + // Linking injects ALIEN_LLM_BINDING (and starts the gateway, exposed as + // ALIEN_AI_GATEWAY_URL) and ALIEN_DB_BINDING (the Postgres connection). + .link(llm) + .link(db) + .permissions("app") + .build() + +export default new alien.Stack("ai-chatbot") + .platforms(["aws", "gcp", "azure"]) + .add(llm, "live") + .add(db, "live") + .add(app, "live") + .permissions({ + profiles: { + app: { + "*": ["ai/invoke", "postgres/data-access"], + }, + }, + }) + .build() diff --git a/examples/ai-chatbot-ts/app/api/chat/route.ts b/examples/ai-chatbot-ts/app/api/chat/route.ts new file mode 100644 index 000000000..2000e1ddb --- /dev/null +++ b/examples/ai-chatbot-ts/app/api/chat/route.ts @@ -0,0 +1,81 @@ +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { ai, getAiConnection, postgres } from "@alienplatform/sdk" +import { type UIMessage, convertToModelMessages, stepCountIs, streamText, tool } from "ai" +import { Pool } from "pg" +import { z } from "zod" + +// The binding reads the password from the cloud secret store at runtime with the +// workload's own identity — it is never in the environment. +let dbPool: Promise | undefined +function db(): Promise { + if (!dbPool) { + dbPool = (async () => { + const conn = await postgres("db").connection() + // Field style + conn.ssl, NOT conn.connectionString: node-postgres parses the + // URL's sslmode and overrides ssl, which breaks the managed-cloud cert path. + return new Pool({ + host: conn.host, + port: conn.port, + database: conn.database, + user: conn.username, + password: conn.password, + ssl: conn.ssl, + // The model's tool only reads; enforce it in the session, not by parsing SQL. + options: "-c default_transaction_read_only=on", + }) + })().catch(err => { + // Don't cache a failed resolution; let the next request retry. + dbPool = undefined + throw err + }) + } + return dbPool +} + +const queryDatabase = tool({ + description: + "Run a read-only SQL query against the company's private Postgres database. " + + "Tables: customers(id, name, plan, country, mrr_usd), orders(id, customer_id, amount_usd, status, created).", + inputSchema: z.object({ + sql: z.string().describe("a single read-only SELECT or WITH statement for Postgres"), + }), + execute: async ({ sql }) => { + // node-postgres runs semicolon-separated statements, so reject chained SQL here; + // writes are stopped by the pool's read-only sessions, not by parsing. + const statement = sql.trim().replace(/;\s*$/, "") + if (!/^(select|with)\b/i.test(statement) || statement.includes(";")) { + return { error: "only a single read-only SELECT or WITH statement is allowed" } + } + const pool = await db() + const result = await pool.query(statement) + // Cap what the model sees; rowCount still reports the real total. + return { rowCount: result.rowCount, rows: result.rows.slice(0, 50) } + }, +}) + +export async function POST(req: Request) { + const { messages, model }: { messages: UIMessage[]; model?: string } = await req.json() + + // Resolved per request: the binding env exists only in the running workload, not at build. + const provider = createOpenAICompatible({ name: "alien", ...(await getAiConnection("llm")) }) + + // Model ids differ per cloud, so the fallback is the binding's first model, not a hardcoded id. + const modelId = model || (await ai("llm").getAvailableModels())[0]?.id + if (!modelId) { + return Response.json({ error: "the AI binding exposes no models" }, { status: 503 }) + } + + const result = streamText({ + model: provider(modelId), + system: + "You answer questions about the company's data. When a question needs data, write a " + + "single read-only Postgres SELECT and call the queryDatabase tool, then summarize the " + + "result for the user in plain English.", + messages: await convertToModelMessages(messages), + // Without a stop condition the model never streams the answer after the tool result. + stopWhen: stepCountIs(6), + tools: { queryDatabase }, + }) + + return result.toUIMessageStreamResponse() +} diff --git a/examples/ai-chatbot-ts/app/api/models/route.ts b/examples/ai-chatbot-ts/app/api/models/route.ts new file mode 100644 index 000000000..da57ccb04 --- /dev/null +++ b/examples/ai-chatbot-ts/app/api/models/route.ts @@ -0,0 +1,6 @@ +import { ai } from "@alienplatform/sdk" + +export async function GET() { + const models = await ai("llm").getAvailableModels() + return Response.json({ models: models.map(m => m.id) }) +} diff --git a/examples/ai-chatbot-ts/app/api/seed/route.ts b/examples/ai-chatbot-ts/app/api/seed/route.ts new file mode 100644 index 000000000..c0b3d63ec --- /dev/null +++ b/examples/ai-chatbot-ts/app/api/seed/route.ts @@ -0,0 +1,55 @@ +import { postgres } from "@alienplatform/sdk" +import { Client } from "pg" + +// Postgres is private (same-stack only), so seeding runs inside the deployed app: +// `curl -X POST https:///api/seed` drops and recreates the demo tables. +export async function POST() { + const conn = await postgres("db").connection() + // Field style, not conn.connectionString — see the chat route's pool for the sslmode footgun. + const client = new Client({ + host: conn.host, + port: conn.port, + database: conn.database, + user: conn.username, + password: conn.password, + ssl: conn.ssl, + }) + await client.connect() + try { + await client.query("drop table if exists orders; drop table if exists customers;") + await client.query(` + create table customers ( + id serial primary key, name text, plan text, country text, mrr_usd int + ); + create table orders ( + id serial primary key, customer_id int references customers(id), + amount_usd int, status text, created date + ); + `) + await client.query(` + insert into customers (name, plan, country, mrr_usd) values + ('Acme Corp','enterprise','US',4200), + ('Globex','enterprise','DE',3800), + ('Initech','pro','US',900), + ('Umbrella','enterprise','UK',5100), + ('Hooli','pro','IL',1200), + ('Stark Industries','enterprise','US',6400), + ('Wayne Enterprises','pro','US',1500), + ('Soylent','starter','FR',150); + `) + await client.query(` + insert into orders (customer_id, amount_usd, status, created) values + (1,1200,'paid','2026-05-02'),(1,800,'paid','2026-06-01'), + (2,3800,'paid','2026-06-03'),(4,5100,'paid','2026-06-05'), + (6,6400,'paid','2026-06-06'),(3,900,'refunded','2026-05-20'), + (5,1200,'paid','2026-06-10'),(7,1500,'pending','2026-06-12'), + (8,150,'paid','2026-06-14'),(6,2000,'paid','2026-06-20'); + `) + const summary = await client.query( + "select count(*)::int as customers, sum(mrr_usd)::int as total_mrr from customers", + ) + return Response.json({ seeded: true, ...summary.rows[0] }) + } finally { + await client.end() + } +} diff --git a/examples/ai-chatbot-ts/app/components/grain-background.tsx b/examples/ai-chatbot-ts/app/components/grain-background.tsx new file mode 100644 index 000000000..bf9c9e00c --- /dev/null +++ b/examples/ai-chatbot-ts/app/components/grain-background.tsx @@ -0,0 +1,37 @@ +"use client" + +import dynamic from "next/dynamic" + +const GrainGradient = dynamic( + () => import("@paper-design/shaders-react").then(mod => mod.GrainGradient), + { ssr: false, loading: () => }, +) + +export function GrainBackground() { + return ( +
+ + +
+ ) +} + +function Fallback() { + return ( +
+ ) +} diff --git a/examples/ai-chatbot-ts/app/components/message.tsx b/examples/ai-chatbot-ts/app/components/message.tsx new file mode 100644 index 000000000..61611c89e --- /dev/null +++ b/examples/ai-chatbot-ts/app/components/message.tsx @@ -0,0 +1,59 @@ +"use client" + +import type { UIMessage } from "ai" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { QueryCard, type QueryInput, type QueryOutput } from "./query-card" + +type QueryToolPart = { + type: "tool-queryDatabase" + toolCallId: string + state: "input-streaming" | "input-available" | "output-available" | "output-error" + input?: QueryInput + output?: QueryOutput + errorText?: string +} + +export function Message({ message }: { message: UIMessage }) { + if (message.role === "user") { + const text = message.parts.map(part => (part.type === "text" ? part.text : "")).join("") + return ( +
+
+ {text} +
+
+ ) + } + + return ( +
+ {message.parts.map((part, i) => { + const key = `${message.id}-${i}` + if (part.type === "text") { + return ( +
+ {part.text} +
+ ) + } + if (part.type === "tool-queryDatabase") { + const tool = part as unknown as QueryToolPart + return ( + + ) + } + return null + })} +
+ ) +} diff --git a/examples/ai-chatbot-ts/app/components/query-card.tsx b/examples/ai-chatbot-ts/app/components/query-card.tsx new file mode 100644 index 000000000..2c3c0bd54 --- /dev/null +++ b/examples/ai-chatbot-ts/app/components/query-card.tsx @@ -0,0 +1,118 @@ +"use client" + +import { Spinner } from "./spinner" + +const PREVIEW_ROWS = 5 + +export type QueryInput = { sql?: string } +export type QueryOutput = { + rowCount?: number | null + rows?: Record[] + error?: string +} + +export function QueryCard({ + state, + input, + output, + errorText, +}: { + state: "input-streaming" | "input-available" | "output-available" | "output-error" + input?: QueryInput + output?: QueryOutput + errorText?: string +}) { + const running = state === "input-streaming" || state === "input-available" + const failure = + state === "output-error" ? (errorText ?? "The query could not be run.") : output?.error + const rows = output?.rows ?? [] + const columns = rows.length > 0 ? Object.keys(rows[0]) : [] + const numeric = new Set(columns.filter(column => typeof rows[0]?.[column] === "number")) + + return ( +
+
+ + + {running ? "Querying the database" : failure ? "Query failed" : "Queried the database"} + + {running && } + {typeof output?.rowCount === "number" && ( + + {output.rowCount} {output.rowCount === 1 ? "row" : "rows"} + + )} +
+ + {input?.sql && ( +
+          {input.sql}
+        
+ )} + + {failure &&
{failure}
} + + {columns.length > 0 && ( +
+ + + + {columns.map(column => ( + + ))} + + + + {rows.slice(0, PREVIEW_ROWS).map((row, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static preview slice with no stable row id + + {columns.map(column => ( + + ))} + + ))} + +
+ {column} +
+ {String(row[column] ?? "")} +
+ {rows.length > PREVIEW_ROWS && ( +
+ +{rows.length - PREVIEW_ROWS} more +
+ )} +
+ )} +
+ ) +} + +function DatabaseIcon() { + return ( + + ) +} diff --git a/examples/ai-chatbot-ts/app/components/spinner.tsx b/examples/ai-chatbot-ts/app/components/spinner.tsx new file mode 100644 index 000000000..cd8c05e91 --- /dev/null +++ b/examples/ai-chatbot-ts/app/components/spinner.tsx @@ -0,0 +1,18 @@ +"use client" + +import { useEffect, useState } from "react" + +const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + +export function Spinner({ className }: { className?: string }) { + const [frame, setFrame] = useState(0) + useEffect(() => { + const timer = setInterval(() => setFrame(f => (f + 1) % FRAMES.length), 80) + return () => clearInterval(timer) + }, []) + return ( + + ) +} diff --git a/examples/ai-chatbot-ts/app/globals.css b/examples/ai-chatbot-ts/app/globals.css new file mode 100644 index 000000000..dd419a64b --- /dev/null +++ b/examples/ai-chatbot-ts/app/globals.css @@ -0,0 +1,14 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; + +@theme { + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-brand: #5aff75; + --color-brand-200: #7dff93; + --color-brand-foreground: #021a06; + --color-landing-foreground: #d0e4d3; + --color-landing-foreground-200: #7eb78a; + --color-card: color-mix(in oklab, #27272a 55%, #000); + --color-edge: rgb(255 255 255 / 7.5%); +} diff --git a/examples/ai-chatbot-ts/app/layout.tsx b/examples/ai-chatbot-ts/app/layout.tsx new file mode 100644 index 000000000..4050f81de --- /dev/null +++ b/examples/ai-chatbot-ts/app/layout.tsx @@ -0,0 +1,18 @@ +import { GeistMono } from "geist/font/mono" +import { GeistSans } from "geist/font/sans" +import type { Metadata } from "next" +import type { ReactNode } from "react" +import "./globals.css" + +export const metadata: Metadata = { + title: "AI chatbot on Alien", + description: "A streaming chatbot that talks to cloud LLMs through the Alien AI gateway.", +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/examples/ai-chatbot-ts/app/page.tsx b/examples/ai-chatbot-ts/app/page.tsx new file mode 100644 index 000000000..85bc30d17 --- /dev/null +++ b/examples/ai-chatbot-ts/app/page.tsx @@ -0,0 +1,235 @@ +"use client" + +import { useChat } from "@ai-sdk/react" +import { useEffect, useRef, useState } from "react" +import { GrainBackground } from "./components/grain-background" +import { Message } from "./components/message" +import { Spinner } from "./components/spinner" + +// Matched to the /api/seed dataset so first-run questions land. +const SUGGESTIONS = [ + "How many enterprise customers do we have and what's their total MRR?", + "Who are our top 5 customers by MRR?", + "How many orders are pending, and what are they worth?", + "Break down our customers by country.", +] + +export default function Chat() { + const [input, setInput] = useState("") + const [models, setModels] = useState([]) + const [model, setModel] = useState("") + const [seedNote, setSeedNote] = useState("") + const { messages, sendMessage, status, stop, error, regenerate } = useChat() + const bottomRef = useRef(null) + const composerRef = useRef(null) + + const busy = status === "submitted" || status === "streaming" + + useEffect(() => { + fetch("/api/models") + .then(r => r.json()) + .then((d: { models: string[] }) => { + setModels(d.models) + if (d.models[0]) setModel(d.models[0]) + }) + .catch(() => {}) + }, []) + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every stream update + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [messages]) + + function ask(text: string) { + // Before the picker loads, omit the field and let the server pick a default. + sendMessage({ text }, { body: { model: model || undefined } }) + composerRef.current?.focus() + } + + function submit() { + const text = input.trim() + if (!text || busy) return + ask(text) + setInput("") + } + + async function seed() { + try { + const r = await fetch("/api/seed", { method: "POST" }) + const d: { customers?: number } = await r.json() + setSeedNote(r.ok ? `✓ Seeded ${d.customers} customers` : "Seeding failed") + } catch { + setSeedNote("Seeding failed") + } + setTimeout(() => setSeedNote(""), 4000) + } + + return ( +
+ +
+
+ +
+
+ {messages.length === 0 ? ( +
+
+
+ LIVE + AI connected to a private Postgres +
+

+ Ask your data anything. +

+

+ Answers come from live SQL against the stack's private Postgres, with no + credentials in the app. +

+
+
+ {SUGGESTIONS.map(suggestion => ( + + ))} +
+
+ ) : ( +
+ {messages.map(message => ( + + ))} + {status === "submitted" && ( +
+ + Thinking +
+ )} + {error && ( +
+ Something went wrong.{" "} + +
+ )} +
+ )} +
+
+
+ +