Search the site
Find pages, services and blog posts
Skip to content
All posts

Building a Svelte UI for an AI Agent Product

  • svelte
  • sveltekit
  • ai
  • agents
  • remote-functions

The short answer: keep the agent and every key it needs on the server, store each agent run as data with a status and a list of events, stream that data to the page, and render it with plain Svelte components. Every action a user (or the agent) takes goes through a server function that checks permissions first. Then test the UI in a real browser against fake event streams, because the interesting bugs live in the states between “started” and “done”.

That’s the shape I keep coming back to. I’ve built it for a client platform that runs AI document extraction, for my own agent tooling in my-pi, and, briefly, for a chat widget on my own CV site. The rest of this post is the detail.

Keep the model on the server

The browser never talks to the model provider. It talks to your SvelteKit server, and the server talks to the model. That keeps API keys out of the client bundle, and it gives you one place to add validation, rate limiting, logging and cost controls.

SvelteKit remote functions make this pleasant. Here’s roughly how the chat widget on my CV site worked. It’s a public repo, so you can go and look at the history:

1// src/lib/chat.remote.ts
2import { command, getRequestEvent } from '$app/server';
3import * as v from 'valibot';
4import { ask_agent } from '$lib/server/agent';
5import { check_rate_limit } from '$lib/server/rate-limit';
6import { verify_turnstile } from '$lib/server/turnstile';
7
8export const ask = command(
9	v.object({
10		question: v.pipe(
11			v.string(),
12			v.trim(),
13			v.minLength(2),
14			v.maxLength(500),
15		),
16		turnstile_token: v.string(),
17	}),
18	async ({ question, turnstile_token }) => {
19		const ip = getRequestEvent().getClientAddress();
20
21		if (!(await verify_turnstile(turnstile_token, ip))) {
22			throw new Error('Failed to verify captcha. Please try again.');
23		}
24		if (!check_rate_limit(ip)) {
25			throw new Error('Too many requests. Please wait a moment.');
26		}
27
28		return { answer: await ask_agent(question) };
29	},
30);

The Anthropic SDK, the key and the read-only SQLite database the agent searched all lived under $lib/server, which SvelteKit refuses to import into client code. Input is validated with Valibot before any tokens are spent. A captcha and a per-IP rate limit sit in front of the model, because a public endpoint that calls a paid API will get found.

I removed that widget a few months later. It worked, but a chat box was the wrong interface for a CV. People scanning a CV want to see evidence quickly, and typing questions into a box slows them down. That’s worth saying early: the first design question for an agent product is whether the user actually wants to chat. Often they want a result, a status and a way to correct it.

Treat each run as data, not a chat log

Once an agent does real work (calls tools, reads documents, writes records) it will take longer than a single request should. So I split it:

  • The web app validates the request, persists it and enqueues a job.
  • A worker process picks up the job and runs the agent.
  • The worker writes the outcome, each model call and each tool call back to the database as it goes.
  • The UI reads that persisted state.

That’s how the client extraction platform is built. The web app never runs extraction inline. It uploads, stores and queues, and a separate worker does the slow part. This means a page refresh, a dropped connection or a second user looking at the same record all see the same truth, because the truth is in Postgres and not in someone’s browser tab.

The model for a run ends up looking something like this:

1type RunStatus =
2	'queued' | 'running' | 'needs_review' | 'done' | 'failed';
3
4type RunEvent =
5	| { type: 'message'; at: string; text: string }
6	| {
7			type: 'tool_call';
8			at: string;
9			id: string;
10			tool: string;
11			summary: string;
12	  }
13	| {
14			type: 'tool_result';
15			at: string;
16			id: string;
17			ok: boolean;
18			summary: string;
19	  }
20	| { type: 'error'; at: string; message: string };
21
22type Run = {
23	id: string;
24	status: RunStatus;
25	events: RunEvent[];
26};

Note the summary fields. Don’t send raw tool arguments and results to the browser by default. They can be huge, and they can contain data the viewer isn’t allowed to see. Store the full payload server side and send a safe summary.

Stream the status to the page

With runs stored as data, streaming becomes a read problem. Remote functions have query.live(), which takes an async generator and keeps the browser connected while a component is using it:

1// src/routes/runs/[id]/run.remote.ts
2import { query } from '$app/server';
3import { error } from '@sveltejs/kit';
4import * as v from 'valibot';
5import { get_run, wait_for_run_change } from '$lib/server/runs';
6import { require_user, can } from '$lib/server/auth';
7
8export const watch_run = query.live(v.string(), async function* (id) {
9	const user = require_user();
10
11	while (true) {
12		const run = await get_run(id);
13		if (!run || !can(user, 'read', run)) error(404, 'Not found');
14
15		yield run;
16		if (run.status === 'done' || run.status === 'failed') return;
17
18		await wait_for_run_change(id);
19	}
20});

wait_for_run_change should be event driven if you can manage it: a Postgres LISTEN/NOTIFY channel, a queue or pub/sub. A timer is fine to start with, but it gets expensive once you have lots of open pages. On the client project the refresh cost was real. One change to a record could trigger several downstream jobs, and each of those wanted to refresh the page. Batching those updates mattered more than any rendering optimisation.

Also check the predicate that decides “did this run change”. On the same project, a live view stopped updating for one category of document because the query that watched for changes didn’t include the table those documents lived in. Nothing errored. The page just went quiet. That kind of bug is easy to miss in review and easy to catch in a browser test.

For my own tools I use plain server-sent events. The observability dashboard in my-pi streams agent events over text/event-stream and holds them in a Svelte class with $state.raw. Either approach works. Use whichever your deployment supports, and check that your proxy or host doesn’t buffer streaming responses.

Render tool calls as components

The page component is then boring, which is what you want:

1<script lang="ts">
2	import { watch_run } from './run.remote';
3	import ToolCall from './tool-call.svelte';
4
5	let { id }: { id: string } = $props();
6
7	const run = watch_run(id);
8</script>
9
10{#await run then current}
11	<p role="status">
12		{current.status}
13		{#if !run.connected}(reconnecting){/if}
14	</p>
15
16	<ol>
17		{#each current.events as event, i (i)}
18			<li>
19				{#if event.type === 'message'}
20					<p>{event.text}</p>
21				{:else if event.type === 'tool_call'}
22					<ToolCall {event} />
23				{:else if event.type === 'error'}
24					<p class="error">{event.message}</p>
25				{/if}
26			</li>
27		{/each}
28	</ol>
29{/await}

A few things I’ve learned rendering agent output:

  • Give each event type its own component. Tool calls, results, errors and messages have different jobs. A tool call wants a name, a short summary and a pending indicator that resolves when its result arrives.
  • Use $state.raw for large event arrays. You replace the array when new events arrive rather than mutating it, and you skip deep proxying of hundreds of objects.
  • Announce status, not every token. A role="status" region for “running”, “needs review” or “failed” helps screen reader users. A live region that reads out every streamed word does the opposite.
  • Show the connection state. If the stream drops, say so. A silent spinner that never finishes is the most common complaint about agent UIs.
  • Render model text as untrusted. If you turn markdown into HTML, sanitise it before it goes near {@html}. The model can be persuaded to write whatever a document told it to.

Check permissions on every action

Agent products tend to have more actions than they first appear to: approve, reject, rerun, edit a field the model extracted, attach a document. Each one is a command() or form() on the server, and each one checks that this user can do this thing to this record before doing anything else.

1export const approve_run = command(v.string(), async (id) => {
2	const user = require_user();
3	const run = await get_run(id);
4
5	// same 404 for "missing" and "not yours", so ids can't be probed
6	if (!run || !can(user, 'approve', run)) error(404, 'Not found');
7	if (run.status !== 'needs_review')
8		error(409, 'Run is not in review');
9
10	await mark_approved(run, user);
11	await notify_run_changed(id);
12});

Hiding a button in the UI is a convenience. The check in the server function is the control.

The same rule applies to the agent itself. On the client platform the permission rules live in one module, and both the UI actions and the agent’s tools go through it. Where an agent can query the database, it connects as a restricted Postgres role with row-level security, and schema changes get an audit of those policies. The agent should never be able to see more than the person it’s acting for.

Design for human review

Most useful agent products end with a person checking the work. The UI for that deserves as much care as the agent:

  • Show where each value came from, so a reviewer can check it against the source document without hunting.
  • Let the reviewer correct a value in place, and store the correction alongside what the model said.
  • Collect structured feedback. A thumbs down tells you very little. On the client project the feedback form asks for a category (wrong extraction, missing document, confusing UI, technical issue and so on) plus a comment. Categories you can count turn into evals. Free text mostly turns into a backlog.

Test the UI in a real browser

Agent UIs are mostly state transitions: queued, running, a tool call pending, a stream dropping and reconnecting, a failure halfway through. I test those with Vitest browser mode and vitest-browser-svelte, feeding components fixed event lists so the tests are deterministic and don’t call a model.

1import { expect, test } from 'vitest';
2import { render } from 'vitest-browser-svelte';
3import RunEvents from './run-events.svelte';
4
5test('shows a pending tool call until its result arrives', async () => {
6	const screen = render(RunEvents, {
7		events: [
8			{
9				type: 'tool_call',
10				at: '2026-07-20T10:00:00Z',
11				id: 't1',
12				tool: 'search_documents',
13				summary: 'Searching 3 documents',
14			},
15		],
16	});
17
18	const call = screen.getByRole('listitem');
19	await expect
20		.element(call)
21		.toHaveTextContent('Searching 3 documents');
22	await expect.element(call).toHaveTextContent('Pending');
23});

Split the rendering component (takes events as a prop) from the component that owns the live query, and most of your tests never need a server. For the live connection itself, one or two end-to-end tests against a fake worker are enough.

The checklist

When I start on a Svelte front end for an agent product, this is what I set up first:

  1. Model calls, keys and tool execution in $lib/server only.
  2. A run table with status, events and a stored full payload.
  3. A worker for anything slower than a normal request.
  4. A live query or SSE endpoint that streams persisted state.
  5. One permission module used by UI actions and agent tools.
  6. Components per event type, with connection state visible.
  7. A review screen with source links, corrections and structured feedback.
  8. Browser tests driven by fixed event lists.

None of it is specific to one model provider. That’s deliberate: providers change often, and the UI shouldn’t have to change with them.

Building an agent product?

I build SvelteKit applications for AI agent products and help teams get agent runs, review flows and permissions right before they ship. If that’s where you are, let’s talk.