6 · Guardrails
This is the sixth part from the anatomy — the limits — and it’s the one that turns a demo into something you can leave running.
The framing that makes this obvious
Section titled “The framing that makes this obvious”An agent is an unpredictable user holding your credentials.
Not malicious. Unpredictable. It will do reasonable-looking things you did not anticipate, at three in the morning, in a loop, using the permissions you gave it.
Every guardrail below is just that sentence taken literally. And each one protects a specific part of the agent we’ve been building:
| Part | What can go wrong | The guardrail |
|---|---|---|
| 🔧 Tools | it picks arguments you never imagined | narrow tools |
| ⚙️ The loop | it never decides it’s finished | a hard step ceiling |
| 🗒️ Memory / database | a leaked key reads everything | permissions, not trust |
| 📋 The request | 50,000 characters of input | validation, before it costs anything |
1. Narrow tools
Section titled “1. Narrow tools”Give it a door, not a bulldozer.
| Instead of | Give it |
|---|---|
execute_sql(query) |
save_note(text) |
write_file(path, content) |
save_draft(content) |
http_get(url) |
wiki_read(title) |
The left column lets the model do anything you can do. The right column lets it do one thing.
This is the highest-leverage idea on the page: you cannot prompt your way to the safety that a narrow function signature gives you for free. Instructions are a request. A function signature is a wall.
Our tools are read-only and hit one public API. The worst a confused agent can do is read the wrong Wikipedia page.
2. A hard step ceiling
Section titled “2. A hard step ceiling”The loop stops when the model says it’s done. So what happens when it never says so?
Without a ceiling, a confused agent loops forever and burns your entire daily quota before lunch. On a free tier, that’s your whole day gone — and if it happens during a live demo, it happens in front of everyone.
async def log(self, label: str, detail: str | None = None) -> None: self.seq += 1 if self.seq > config.MAX_AGENT_STEPS: raise RuntimeError(f"Agent exceeded {config.MAX_AGENT_STEPS} steps") await db.add_step(self.run_id, self.seq, label, detail)Twelve steps by default. It is four lines, it sits outside the model’s judgement entirely, and it is the highest-value guardrail in the project.
There’s a second ceiling next to it, on a different axis. The step ceiling stops
one run looping; MAX_THREAD_TURNS stops one conversation growing forever.
Every turn re-sends the whole history, so a long thread costs more per question
than a short one and eventually stops fitting in the context window at all:
if body.thread_id: turns = await db.count_thread_runs(body.thread_id) if turns >= config.MAX_THREAD_TURNS: raise HTTPException(status_code=409, detail="...start a new conversation.")Note where it is: in the request handler, before the work is queued. A limit the user can act on belongs in front of the job, not three minutes into a run that was doomed when it started.
3. Database permissions, not trust
Section titled “3. Database permissions, not trust”Supabase gives you two kinds of key, and the difference between them matters
more than anything else in your .env:
| Key | Where it belongs | What it can do |
|---|---|---|
sb_publishable_… |
safe in a browser | only what RLS policies allow |
sb_secret_… |
server only | bypasses all Row Level Security |
These replaced the older anon and service_role JWT keys — same two roles,
clearer names. Pre-rename projects still carry the legacy pair, and it keeps
working until Supabase retires it at the end of 2026.
Our backend uses the secret key, so it can do everything. We turn RLS on anyway, with no policies — default-deny — so that if the publishable key ever leaks, it grants nothing at all.
alter table runs enable row level security;alter table steps enable row level security;4. Input validation, before it costs anything
Section titled “4. Input validation, before it costs anything”class RunRequest(BaseModel): query: str = Field(min_length=3, max_length=500)A two-character query gets a 422 without ever reaching the model. A 50,000
character query can’t be used to drain your token budget.
It doesn’t feel like a guardrail, but it’s the only one on this page that stops a problem before you’ve paid for it.
What we deliberately did not build
Section titled “What we deliberately did not build”Being honest about scope, because a workshop demo is not a production system:
- Rate limiting per IP. You want this the moment the URL is public. Cap runs per IP and per user before someone burns your daily quota for fun.
- Output filtering. We don’t check what the model wrote before showing it.
- Cost caps. No spend ceiling, because the free tier is our ceiling.
- Auth. Anyone with the URL can run the agent.
If you’re deploying something people will actually use, the first two are the ones to add first.
How we use this in the demo
Section titled “How we use this in the demo”All four guardrails are live in the code, and they cost about fifteen lines between them.
That’s the point worth making: the cheap guardrails are the ones that matter. Skipping them isn’t a time saving, it’s a deferred incident.
app/agent/steps.py — the step ceiling.
app/main.py — request validation, and the conversation cap.
database/schema.sql — RLS, default-deny.
app/tools/wikipedia.py — narrow, read-only tools that fail politely.
Where this leaves us
Section titled “Where this leaves us”The agent is now complete and reasonably safe. It thinks, acts, remembers, and stops. On your laptop, it works perfectly.
Every problem on this page came from the agent doing something wrong. The last page is about the problem that shows up when everything goes right — the model behaves, the tools work, the answer is good — and your users still get an error.
It’s the reason this workshop exists.
Next: Why deployment is hard → — the one that matters most.