The original talk-to-data demo lands on a real punchline: the LLM emits SQL, the browser runs it through DuckDB-WASM, the model never sees a row of data. That story holds because DuckDB-WASM is a sandbox and the dataset is synthetic. The moment you copy the pattern onto a real database, “the LLM writes SELECT” stops being safe. SELECT is plenty destructive on real PII, and a regex blocking DROP is theatre.
This sibling demo replaces freeform SQL with a typed query catalog. Same A2UI v0.9 surface, same KPI tiles and charts, completely different agent contract. The model picks a query name and fills in typed args. The server runs the query under its own credentials. There is no escape hatch.
The catalog is the API
Each query is a defineQuery block: a name, a description, a Zod schema for args, a drizzle function that returns rows.
defineQuery({
name: 'revenueByMonth',
description: 'Monthly revenue total for a year, ordered ascending.',
args: z.object({ year: YEAR, status: STATUS_ENUM.default('paid') }),
run: async (db, { year, status }) =>
db.select({
month: sql<string>`strftime('%Y-%m', ${orders.createdAt})`.as('month'),
revenue: sql<number>`SUM(${orders.amountUsd})`.as('revenue'),
})
.from(orders)
.where(and(eq(orders.status, status),
sql`strftime('%Y', ${orders.createdAt}) = ${String(year)}`))
.groupBy(sql`1`).orderBy(sql`1`),
})
Zod validates args at the boundary. Drizzle parameterises every value below it, so string concatenation is impossible. year is a bounded integer (2024 to 2026), limit is clamped at 50, every enum is closed. Adding a query is a code change with a typecheck, not a prompt edit.
DataLoader is server-only
The agent still emits a DataLoader envelope, but the prop shape changed:
{ "component": "DataLoader", "query": "revenueByMonth",
"args": { "year": 2025 }, "target": "/data/revByMonth" }
The flow’s stream loop intercepts those before they reach the client. It runs the named query, then emits a synthetic updateDataModel with the result rows at target. Charts bound to that path render. The browser never registers a DataLoader component, never sees a query string, never deals with SQL.
The trade-off is real: the agent’s expressiveness is the catalog’s coverage, no more. A question outside the eleven queries lands on the closest match or the model gives up cleanly. For a production system the next layer is per-user auth and JWT-scoped parameters, so the function runs queries under the user’s identity rather than its own. That is the work this demo deliberately stops short of, but it is the obvious next step.