Skip to content
All posts
  • 4 min read
On this page

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.

The page from the JSON above, on its 12-column grid. Every widget is a slot of w columns by h rows.

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

server keeps the 9,786 rows
{ "ok": true, "rowCount": 9786, "truncated": false, "columns": [ { "name": "placed_at", "type": "date", "min": "2025-10-09…", "max": "2026-10-08…" }, { "name": "product", "type": "string", "distinct": 25 }, { "name": "country", "type": "string", "distinct": 9 }, { "name": "line_total", "type": "number", "min": 16, "max": 9451.2, "sum": 5591415.68 }, … 8 more columns ], "sample": [ … 10 rows ] }

1,066 tokens0.5% of the window

The same query, handed to the model two ways. Counted with the o200k tokenizer on the real result, against a 200k-token context 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:

keyboard-vs-chino.json
{
  "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

monthproductrevenue
2025-11Keyboard$32,111
2025-11Chino$23,608
2025-12Keyboard$39,875
2025-12Chino$33,595
2026-01Keyboard$6,066
2026-01Chino$5,109

… 18 more rows

Widget props 12 rows

monthkeyboardchino
2025-11$32,111$23,608
2025-12$39,875$33,595
2026-01$6,066$5,109

… 9 more rows

const out = {};for (const r of rows) { const m = (out[r.month] ??= { month: r.month, keyboard: 0, chino: 0 }); m[r.product === "Mechanical Keyboard" ? "keyboard" : "chino"] = r.revenue;}return Object.values(out);

line widget x: "month" · series: keyboard, chino

— Mechanical Keyboard— Stretch Chino2025-11 → 2026-10

A real result from the dashboard database, run through the transform in the same sandbox the agent uses. The widget only names columns.

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:

ToolWhat it does
queryRuns read-only SQL plus an optional transform, and returns a summary
get_pageReturns the current page's spec, with a summary of each dataset
search_pagesGreps saved pages: titles, widget titles, dataset SQL
read_datasetPages through a dataset's actual rows, 200 at most per call
update_pageApplies 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:

src/lib/spec/datasets.ts
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:

src/lib/spec/datasets.ts
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:

src/lib/agent/tools.ts
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:

src/lib/pages.ts
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.