Chat with private Postgres
In this example, we are going to build a streaming chat application over private data in Postgres. The Next.js application, database, and model access run together in the customer's cloud, so database rows do not need to pass through your hosted backend.
When someone asks, “Which enterprise customers have the most MRR?”, the model calls a queryDatabase tool. It chooses one of seven questions and supplies a few bounded filters. The application—not the model—owns the SQL that runs.
A browser sends a question to the Next.js application in the customer environment. The application calls an available model, runs an application-owned query against private Postgres, and streams the answer back.
Postgres has no public endpoint. The application image contains neither a database password nor a model-provider key. Alien links both resources to the Container and resolves their bindings while the application is running.
The model request also stays with the deployment's cloud account. On AWS the application calls Amazon Bedrock. On GCP it calls Vertex AI. On Azure it calls Azure AI Foundry. The Container's cloud identity authorizes the request, and the cloud provider applies that account's enabled models, quotas, logging, and billing.
We will build the stack, list the models available in its cloud, give the model a safe database tool, and stream the final answer.
What alien.AI means in this application
alien.AI("llm") does not create one Alien-hosted model shared by every deployment. It connects the application to the model service available in the environment where that application is running:
| Deployment | Model service used by the application |
|---|---|
| AWS | Amazon Bedrock |
| GCP | Vertex AI |
| Azure | Azure AI Foundry |
This is important for a private-data application. The database query runs inside the deployment, and the resulting rows are sent to a model through that deployment's cloud AI service. Your hosted control plane does not need the Postgres password, a route to the database, or a provider API key.
Customer's AWS account
browser ──HTTPS──▶ Next.js Container ─────▶ Amazon Bedrock
│ workload identity
│
└───────────────▶ private PostgresThe same application code uses Vertex AI on GCP and Azure AI Foundry on Azure. Since each cloud exposes a different model catalog, the UI discovers models at runtime instead of assuming every deployment has the same ones.
Local development is deliberately different. Your laptop has no AWS, GCP, or Azure workload identity, so alien dev uses OPENAI_API_KEY or an OpenAI-compatible endpoint you configure. That local binding lets you develop the application; a deployed cloud binding uses the model service in the deployment's account.
Describe the application
alien.ts declares three resources:
appis the Next.js Container users open in their browser.dbis the private Postgres database.llmgives the application access to models available in the deployment.
const llm = new alien.AI("llm").build()
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")
.environment({ PORT: "3000", HOSTNAME: "0.0.0.0" })
.link(llm)
.link(db)
.permissions("app")
.build()The Container is public because it serves the chat UI. Postgres is not. .link(db) gives only this Container the information it needs to connect to db.
The stack grants the application model invocation and database access:
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()All three resources are live, so Alien can create and reconcile them during a rollout. Runtime access is separate: the app permission profile controls what the Container can do once it is running.
List the models this deployment can use
Model availability differs by provider, account, and region. The application asks its linked AI resource instead of hard-coding a model list.
import { ai } from "@alienplatform/sdk"
export async function GET() {
const models = await ai("llm").getAvailableModels()
return Response.json({ models: models.map(model => model.id) })
}The model picker can now show only models available to this deployment.
Resolve the model connection at request time
The AI binding exists in the running workload, not while Next.js builds the image. Resolve it inside the request handler:
const modelId = model || (await ai("llm").getAvailableModels())[0]?.id
if (!modelId) {
return Response.json({ error: "the AI binding exposes no models" }, { status: 503 })
}
const connection = await getAiConnection("llm")In a cloud deployment, Alien's AI gateway uses the workload's cloud identity when it calls the provider. Locally, alien dev can create the same binding from your provider key.
The gateway preserves the provider's wire format. Claude models therefore use the Anthropic client; other models in this example use an OpenAI-compatible client:
function modelFor(modelId: string, connection: AiConnection) {
if (modelId.startsWith("claude")) {
const anthropic = createAnthropic({
baseURL: connection.baseURL,
apiKey: connection.apiKey ?? "",
})
return anthropic(modelId)
}
return createOpenAICompatible({
name: "alien",
...connection,
})(modelId)
}Give the model questions, not SQL
The tool input is a closed schema. The model can select a question, an optional plan or order status, and a result limit of at most 50 rows.
export const QUESTIONS = [
"customer_count_by_plan",
"customer_count_by_country",
"total_mrr_by_plan",
"top_customers_by_mrr",
"orders_by_status",
"recent_orders",
"revenue_by_customer",
] as const
export const askSchema = z.object({
question: z.enum(QUESTIONS),
plan: z.enum(["enterprise", "pro", "starter"]).optional(),
status: z.enum(["paid", "pending", "refunded"]).optional(),
limit: z.number().int().min(1).max(50).default(10),
})plan() turns that structured request into application-owned SQL. Values from the model are passed as query parameters:
case "top_customers_by_mrr":
return {
text: `select name, plan, country, mrr_usd from customers
where ($1::text is null or plan = $1)
order by mrr_usd desc limit $2`,
values: [planFilter ?? null, limit],
}The model cannot send a table name, SQL expression, or arbitrary statement. Adding a new kind of question is an ordinary code change: add it to the schema and write the query it owns.
Open a bounded Postgres connection
The database binding resolves the connection inside the Container:
const conn = await postgres("db").connection()
return new Pool({
host: conn.host,
port: conn.port,
database: conn.database,
user: conn.username,
password: conn.password,
ssl: conn.ssl,
options: "-c default_transaction_read_only=on -c statement_timeout=10000",
})The pool is read-only and gives every statement a ten-second timeout. The password is resolved at runtime; it is not checked into the project or baked into the image.
The demo seeds its sample tables through a separate write connection. It uses a transaction and an advisory lock, so concurrent Containers cannot partially seed the database. A production application would normally use its existing migration and ingestion path instead.
Implement the tool
The tool rejects filters that do not apply to the chosen question, ensures the demo data exists, plans the query, and returns the rows to the model.
const queryDatabase = tool({
description: "Answer a question about the company's Postgres data.",
inputSchema: askSchema,
execute: async ask => {
const ignored = unsupportedFilters(ask)
if (ignored.length > 0) {
return { error: `${ask.question} does not take ${ignored.join(" or ")}` }
}
await ensureSeeded()
const { text, values } = plan(ask)
const { rows } = await query(text, values)
return { question: ask.question, rows, rowCount: rows.length }
},
})Returning the rows as a tool result gives the model the evidence it needs to write the answer. The example UI also lets the user inspect the underlying tables and compare the answer with the source data.
Stream the answer
Finally, pass the selected model, conversation, and tool to the Vercel AI SDK:
const result = streamText({
model: modelFor(modelId, connection),
system:
"Answer questions about the company's data. Use queryDatabase when data is needed. " +
"If the tool cannot answer the question, explain what the data can answer.",
messages: await convertToModelMessages(messages),
tools: { queryDatabase },
stopWhen: stepCountIs(6),
})
return result.toUIMessageStreamResponse()The stop condition leaves room for the model to call the tool and then produce the user-facing response without allowing an unbounded tool loop.
Run it locally
Locally there is no workload cloud identity, so provide a model-provider key to alien dev:
cd examples/ai-chatbot-ts
OPENAI_API_KEY=sk-... alien devOpen the printed URL and ask:
How many enterprise customers do we have, and what is their total MRR?
The first question creates the demo tables. Watch the model call queryDatabase, inspect the returned rows in the UI, and then try a question outside the seven supported operations. The model should explain that the available data cannot answer it instead of inventing a query.
Deploy it
alien deploy production --platform awsYou can also deploy the same stack to GCP or Azure. Alien builds the Next.js image and creates the Container, Postgres database, AI resource, and public endpoint in that environment.
The example leaves the chat endpoint open so it is immediately usable. Before using this design in a product, add authentication and per-user rate limits; anyone who can reach an unprotected endpoint can consume the deployment's model quota.
For a customer deployment, publish the application and create a setup link:
alien releasePublishes a version. Nothing is deployed for a customer yet.
alien onboard acme-corpCreates a deployment link for that customer.
The customer opens the link and deploys into their environment.
The chat application, private Postgres, and model access are provisioned together in the customer's environment. The browser reaches the application over HTTPS; only the linked Container can reach Postgres.
What you built
You built a complete AI application around the alien.AI resource:
- The application discovers models available in its deployment.
- The model connection is resolved at runtime without a provider key in the image.
- Postgres remains private and its password is resolved only inside the linked Container.
- The model selects from application-owned, parameterized queries instead of writing SQL.
- The response streams back with the rows that support it.
Complete source: examples/ai-chatbot-ts.
Next: AI, Postgres, and Permissions.