Docs

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:

DeploymentModel service used by the application
AWSAmazon Bedrock
GCPVertex AI
AzureAzure 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 Postgres

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

  • app is the Next.js Container users open in their browser.
  • db is the private Postgres database.
  • llm gives the application access to models available in the deployment.
alien.ts
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:

alien.ts
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.

app/api/models/route.ts
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:

app/api/chat/route.ts
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:

app/api/chat/route.ts
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.

app/queries.ts
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:

app/queries.ts
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:

app/db.ts
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.

app/api/chat/route.ts
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:

app/api/chat/route.ts
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 dev

Open 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 aws

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

1 · Release
alien release

Publishes a version. Nothing is deployed for a customer yet.

2 · Invite
alien onboard acme-corp

Creates a deployment link for that customer.

3 · Deploy

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.

On this page