Skip to content
bits
Ship an agent to the edge · Lesson 1 of 8 · 25 min

Close the side door

Desk: best at a computer, with a terminal

Verified agents 0.33.0ai 7.0.162wrangler 4.157.0remix 3.1.0

Start from e-09 on GitHub. Starting here, or behind? Get the code at that tag:

Shell
git clone https://github.com/ryanyogan/klippy
cd klippy
git checkout e-09
Tutor:

Klippy has two Workers: the agent and the web app from course D. The web app signs you in with GitHub and talks to the agent over a service binding, a direct call from one Worker to another with no public URL in between.

But the agent still has a public address of its own: klippy.<subdomain>.workers.dev. Anyone who has your KLIPPY_TOKEN can call /agents/klippy/<any-name> there and pick any Klippy by name. That's the side door. In this lesson you close it, so the only way in is the web app, which picks your Klippy from your session.

Tutor:

1. No public address

Two keys in the agent's config:

// No public address (f-01). No klippy.<subdomain>.workers.dev route, and no preview URLs either (turning off workers.dev
// alone leaves those on). The only way in is the web app's service binding, which picks your Klippy from your session.
// `wrangler dev` still answers on localhost, so `pnpm chat` works against your local copy.
"workers_dev": false,
"preview_urls": false,
JSONUTF-8at f-01● lines 4–5: apps/agent/wrangler.jsonc
Tutor:

You need both. workers_dev: false removes the workers.dev route, but Cloudflare's docs say it doesn't turn off the URL each uploaded version gets (the docs now call them version URLs; the setting is still preview_urls). preview_urls: false closes those too. With no routes either, the deployed agent has no address at all. The web app still reaches it over the binding.

Tutor:

2. Name the agent on the server

The web app already names your Klippy from your session, never from the request:

// The agent instance is chosen here, on the server, from the signed-in user. The browser never names it.
export function agentName(user: User) {
return `gh-${user.id}`;
}
TypeScriptUTF-8at f-01● lines 2–4: apps/web/app/auth.ts
Tutor:

The chat used that from course D. But two features still went straight to the agent: sending a file, and searching your sources. Give them routes on the web app:

// Your saved sources, through your Klippy (f-01: the agent has no public address, so the web app is the way in).
sources: {
upload: post('/sources/file'),
search: get('/sources/search'),
},
// For the bits checkpoint grader. Public.
TypeScriptUTF-8at f-01● lines 2–4: apps/web/app/routes.ts
Tutor:

The controller is short. It's signed in only (requireUser), checks the size before and after reading the body, and builds the agent's path from the session:

const MAX_UPLOAD = 1_100_000;
// Files and search reach your Klippy only through here: signed in, and the agent picked from your session.
export default createController(routes.sources, {
middleware: [requireUser],
actions: {
// POST /sources/file (a form with a "file" field): pass the file on to your Klippy, which ingests it.
async upload(context) {
const request = context.request;
const type = request.headers.get('Content-Type') ?? '';
const size = Number(request.headers.get('Content-Length') ?? NaN);
if (!type.startsWith('multipart/form-data')) return new Response('Send a form with a file.', { status: 400 });
if (!(size <= MAX_UPLOAD)) return new Response('Files can be up to 1 MB.', { status: 413 });
const body = await request.arrayBuffer();
if (body.byteLength > MAX_UPLOAD) return new Response('Files can be up to 1 MB.', { status: 413 });
const reply = await agentFetch(`/agents/klippy/${agentName(context.auth.identity)}/file`, {
method: 'POST',
headers: { 'Content-Type': type },
body,
});
if (reply.ok) return redirect(`${routes.notes.index.href()}?sent=1`, 303);
const { error } = await reply.json<{ error?: string }>().catch(() => ({ error: undefined }));
return new Response(error ?? `Klippy didn't take the file (HTTP ${reply.status}).`, { status: reply.status < 500 ? reply.status : 502 });
TypeScriptUTF-8at f-01● lines 16–16: apps/web/app/actions/sources/controller.tsx
Tutor:

The browser never sends a Klippy name, so it can't ask for someone else's. The agent's own checks (file type, 1 MB, 10 a minute) still run, and the web app passes the agent's reason on with its 4xx status. Copy sources/controller.tsx, map it in router.ts, and add the upload form to notes-page.tsx (with sent in its schema.ts). The page now needs sent, so the notes controller passes it on, or typecheck fails:

const { q, deleted, sent } = query.value;
...
return context.render(<NotesPage user={context.auth.identity} notes={rows} q={q} deleted={deleted === '1'} sent={sent === '1'} />);
TypeScriptUTF-8at f-01
Tutor:

Why a service binding, not a Durable Object binding? The web app could also bind the agent's Durable Object class directly and call getAgentByName. That works, but it adds a second way in and Durable Object types shared across two Workers, for no gain at this size. One door is easier to guard.

Tutor:

3. What stays

Nothing gets looser. KLIPPY_TOKEN stays as a second lock: anything that does reach the agent, the web app or wrangler dev, must still prove it's you. Every limit and cap from courses A to E stays.

One thing changes for you: from f-01, pnpm chat is local only. It works against wrangler dev on your laptop. The deployed agent answers only the web app, so use the web app there.

Tutor:

4. Klippy is personal

The web app lets in only the GitHub account ids in ALLOWED_GITHUB_IDS:

// The GitHub account ids allowed in, comma-separated (`gh api user --jq .id`). Klippy is personal: put only yours.
"ALLOWED_GITHUB_IDS": "your-github-id"
JSONUTF-8at f-01● lines 2–2: apps/web/wrangler.jsonc
Tutor:

Put only your own id there. Each person gets their own Klippy, with its own chat, memory and sources. But the notes table from course B has no owner column, so everyone you let in would see and search the same notes. A Klippy for several people needs an owner on notes and a filter on every query. That's out of scope for this course.

Tutor:

5. Try it

Run pnpm test (ours: 21 agent test files, 96 tests passed), then pnpm dev:web. Locally, with a test cookie for GitHub id 1:

recorded output
curl -i -b <cookie> -F 'file=@pip.md;type=text/markdown' localhost:8802/sources/file
HTTP/1.1 303 See Other
Location: /notes?sent=1
Tutor:

Keep ;type=text/markdown: without it curl sends the file as application/octet-stream, and the agent answers 415 ("Send plain text, Markdown, HTML or a PDF."). Signed out, the same request gets a 302 to /login. A 1.2 MB file gets Files can be up to 1 MB. (413). A minute later, once the ingest Workflow has run, search finds the file. Its title is the file's name: yours will say pip.md. Our recording says pip-f01.md, and our notes don't show why, so we've left it as recorded:

recorded output
curl -b <cookie> 'localhost:8802/sources/search?q=what+does+Pip+eat'
{"hits":[{"chunkId":1,"sourceId":1,"title":"pip-f01.md","url":null,"kind":"file","context":"","text":"# Pip\n\nPip is my rabbit. Pip eats carrots and parsley every morning at 7."}]}
Tutor:

What we didn't record: a deploy. After yours, the agent's old klippy.<subdomain>.workers.dev address should stop answering (Cloudflare returns an error page, not your Worker), while the web app keeps working. You'll check that in the checkpoint.

quick check+20 neurons
Tutor:

You set "workers_dev": false on the agent and deploy. What can still reach it from the internet?

    Pick one option

    Pick one, then Check.