The real agentic UI
Most apps that call themselves agentic just put an LLM in front of data you could already click to. I wanted a dashboard the agent actually composes, so I built one.
Most apps claim to be agentic nowadays, but I don't really see the agentic value in them. Mostly it's an LLM fetching data that I could just go and find in the UI myself. When I hear agentic, I want a truly dynamic experience.
In the past couple of years I had the chance to work on some amazing projects that were doing something genuinely useful with AI. Yesterday a few dots connected in my mind, and I thought: why not build a truly agentic dashboard? You get a grid, the agent composes the components to your preference, and you end up with the dynamic, custom experience you actually wanted.
Pages are just grids
Most dashboard and CRM pages are a grid with a bunch of components in it. We can lean on that. Give each component an amount of slots on the page (a width out of 12 columns and a height in rows), and you're flexible with how many components there are and how you want to structure them.
Then comes the components part, which is relatively easy to think about if you're familiar with React. Let the agent query the data, and give it the ability to write code on the backend to transform that data.
Don't inject the data into the context directly. You'll blow the context up, since there can be hundreds of records in a single dashboard aggregation. The agent writes the recipe, the server runs it, and the results become the props of your components.
query every order line from the last 12 months → 9,786 rows
Rows pasted into the prompt
819,929 tokens4.1× the window
A summary of the rows
1,066 tokens0.5% of the window
What the agent writes
So your JSON ends up looking something like this. It's trimmed from a real page the agent built when I asked it to compare my two best-selling products:
{
"title": "Keyboard vs Chino",
"datasets": {
"monthly": {
"sql": "select m.month, coalesce(sum(oi.line_total) filter (where v.product_id = 14), 0) as keyboard_revenue, coalesce(sum(oi.line_total) filter (where v.product_id = 4), 0) as chino_revenue from generate_series(date_trunc('month', now()) - interval '11 months', date_trunc('month', now()), interval '1 month') as m(month) left join orders o on date_trunc('month', o.placed_at) = m.month and o.status not in ('cancelled','refunded') left join order_items oi on oi.order_id = o.id left join product_variants v on v.id = oi.variant_id and v.product_id in (14, 4) group by m.month order by m.month"
},
"kpis_today": { "sql": "…" }
},
"widgets": [
{
"id": "revenue",
"type": "stat",
"w": 3,
"h": 2,
"title": "Mechanical Keyboard revenue",
"dataset": "kpis_today",
"value": "kb_revenue",
"compare": "kb_revenue_prev",
"compareLabel": "vs last week",
"format": "currency"
},
{
"id": "monthly_revenue",
"type": "line",
"w": 6,
"h": 4,
"title": "Monthly revenue",
"description": "Last 12 months",
"dataset": "monthly",
"x": "month",
"xFormat": "month",
"series": [
{ "key": "keyboard_revenue", "label": "Mechanical Keyboard", "color": "var(--chart-1)" },
{ "key": "chino_revenue", "label": "Stretch Chino", "color": "var(--chart-2)" }
],
"yFormat": "compactCurrency"
}
]
}Widgets never hold data. They point at a dataset by name and map its columns onto their props. Datasets are recipes, a SQL query plus an optional transform, and they run again on every render. A saved page stays live, and the spec stays small no matter how much data it shows.
SQL result 24 rows
… 18 more rows
Widget props 12 rows
… 9 more rows
line widget x: "month" · series: keyboard, chino
— Mechanical Keyboard— Stretch Chino2025-11 → 2026-10
And boom, your dashboard at least looks agentic. You can take this much further and build some crazy ideas on top of it. What I'm showing here is just a proof of concept that I built in an hour or so.
See it run
If you want to see more, here's the whole thing in action. Every frame is the real app with live agent calls, and the slow parts are sped up.
Bonus: how the tools work
The agent gets five tools, and none of them returns a full table unless it explicitly asks for rows:
| Tool | What it does |
|---|---|
query | Runs read-only SQL plus an optional transform, and returns a summary |
get_page | Returns the current page's spec, with a summary of each dataset |
search_pages | Greps saved pages: titles, widget titles, dataset SQL |
read_dataset | Pages through a dataset's actual rows, 200 at most per call |
update_page | Applies edits to the spec, then validates and saves it |
The agent writes SQL, so the database has to say no
Letting a model write SQL against your database sounds scary. It's fine if the database is the
one enforcing the rules, not the prompt. Every query runs as a Postgres role that can only
SELECT, inside a read-only transaction with a timeout, and is wrapped in a subquery:
export async function runSql(sql: string): Promise<DatasetResult> {
const body = sql.trim().replace(/;+\s*$/, "");
const client = await readerPool.connect(); // the read-only `dashboard_reader` role
try {
await client.query("BEGIN READ ONLY");
await client.query("SET LOCAL statement_timeout = '8s'");
await client.query("select set_config('TimeZone', $1, true)", [TIME_ZONE]);
const res = await client.query({
text: `select * from (\n${body}\n) as q limit ${MAX_ROWS + 1}`,
values: [],
});
// ...
} finally {
await client.query("ROLLBACK").catch(() => {});
client.release();
}
}The subquery does two jobs. It caps the rows, and because the query goes through the extended
protocol (that's what the empty values is for), Postgres rejects a second statement smuggled in
after a semicolon. Setting the time zone matters more than it looks. Without it, "group by day"
buckets on UTC, and every chart is off by a few hours.
Transforms run in a sandbox
Some reshaping is painful in SQL and easy in JavaScript: pivoting, ranking, filling gaps. The agent can attach a function body to any dataset. It runs in a QuickJS sandbox with a time and memory limit, with no network or filesystem, and only the rows passed in:
const runner = createRunner({ limits: { timeoutMs: 5_000, memoryLimitBytes: 64 * 1024 * 1024 } });
export async function runTransform(code: string, rows: Row[]): Promise<DatasetResult> {
const result = await runner.run({
source: `const rows = await input.rows();\n${code}`,
hostFunctions: { input: { rows: () => rows } },
});
// must complete and return an array of plain objects, or the agent gets an error back
}Summaries instead of rows
This is the part that keeps the context small. query and get_page never hand back the data
itself. They return its shape: the row count, each column with its type and range, and a few
sample rows. That's what the agent saw for the monthly dataset above (trimmed to two columns):
{
"ok": true,
"rowCount": 12,
"truncated": false,
"columns": [
{ "name": "month", "type": "date", "min": "2025-10-31T23:00:00.000Z", "max": "2026-09-30T23:00:00.000Z" },
{ "name": "keyboard_revenue", "type": "number", "min": 6065.56, "max": 39875.08, "sum": 282105.8 }
],
"sample": [{ "month": "2025-10-31T23:00:00.000Z", "keyboard_revenue": 32111.44 }]
}That's enough to choose a chart, a format and an axis. When the agent really needs to read numbers,
for example to write the analysis you see at the end of the video, it pages through
read_dataset.
Edits are operations, checked before they're saved
The agent doesn't rewrite the whole page to change one chart. update_page takes a list of small
operations, applied in order:
const PageOp = z.discriminatedUnion("op", [
z.object({ op: z.literal("set_meta"), title: z.string().optional(), description: z.string().optional() }),
z.object({ op: z.literal("set_dataset"), id: z.string(), dataset: Dataset }),
z.object({ op: z.literal("remove_dataset"), id: z.string() }),
z.object({ op: z.literal("set_widget"), widget: Widget, index: z.number().int().optional() }),
z.object({ op: z.literal("remove_widget"), id: z.string() }),
z.object({ op: z.literal("move_widget"), id: z.string(), index: z.number().int() }),
z.object({ op: z.literal("replace"), spec: PageSpec }),
]);The result has to pass validation before it's saved. The spec is parsed with Zod, every dataset actually runs, and every column a widget binds to has to exist in that dataset's output. If anything fails, nothing is saved, and the errors go back to the agent as the tool result:
const missing = widgetColumns(w).filter((c) => !r.columns.includes(c));
if (missing.length) {
errors.push(
`widget "${w.id}": dataset "${dsId}" has no column(s) ${missing.join(", ")} (available: ${r.columns.join(", ")})`,
);
}That error message is written for the model. It names the widget, the dataset, the missing column and the columns it could have used, so the next attempt is almost always the right one. The user never sees a broken page, because a broken page never gets saved.