| → ← space | navigate |
| A | jump to the agenda |
| N | speaker notes |
| O | slide overview |
| T | start / stop session timer |
| F | fullscreen |
| P | print → save as PDF |
| Home End | first / last slide |
| Esc | close |
Mouse: click to advance, move to wake the controls. Touch: swipe left / right.
Ship your agent on a 100% free stack.
I'm an AI Engineer passionate about building intelligent solutions that make a difference.
dileepa.devNo AI, cloud, or DevOps experience assumed. If you can write a little Python, you can do all of this.
Never run the agent
inside the HTTP request.
A research agent. You ask a question. It plans, searches, reads sources, writes an answer, and saves it. Then you ask a follow-up and it remembers.
It takes 30–60 seconds. That's deliberate — it's slow enough that the naive version visibly breaks.
Gemini · FastAPI on Render · Postgres on Supabase · React client on GitHub Pages. Total cost: $0.
Why your agent dies the moment you deploy it.
8 API calls · ~45 seconds · any step can fail
Seconds → minutes. Web servers are built for requests that finish fast.
"Bad answer" → half-finished job. It failed on step 4 of 7. Now what?
Staring at a blank screen for 45 seconds, wondering if it's broken.
Three different problems. All three are solved by the same architectural decision.
Your agent runs beautifully on localhost, because
nothing between you and the server is impatient.
Deployed, there's a proxy, a load balancer, a gateway, a CDN — and every one of them has an opinion about how long a request may take.
One of them gives up before your agent does.
You can, a bit. It doesn't fix anything.
Raising the timeout doesn't remove the wall. It moves it further away.
Five boxes. No credit card at any point. This is a real submission-grade stack, not a toy.
Free tiers limit you two different ways, and picking the wrong one for an agent hurts.
| Limits per minute | Best for | |
|---|---|---|
| Groq | generous requests, tight tokens | short, very fast turns |
| Gemini | tighter requests, generous tokens | long agent loops |
An agent re-sends its whole context — system prompt, tool definitions, full history — on every single turn. Five turns can burn a token-per-minute budget that a chatbot would never touch.
Exact numbers move every few months — check both providers on the
session morning. docs/free-tier-notes.md lists where to
look; the shape of the trade-off above outlasts the digits.
Free tier isn't a smaller paid tier.
It's a different set of
trade-offs.
You're trading money for latency and reliability. That's a fine trade for a competition demo — just know you're making it.
One decision that dissolves all three problems.
202 {"run_id": "abc"} "got it, I'll
start"
~200 ms
{status, steps[]} client asks "done
yet?"
every 1.5 s
The HTTP request is now 200 milliseconds long. Nothing in the chain has time to get impatient — no matter how long the agent takes.
@app.post("/runs", status_code=202) async def create_run(body: RunRequest, background: BackgroundTasks): run_id = await db.create_run(body.query) # fast: one insert background.add_task(agent.run_agent, run_id, body.query) return {"run_id": run_id, "status": "queued"} @app.get("/runs/{run_id}") async def read_run(run_id: str): return {"run": await db.get_run(run_id), "steps": await db.get_steps(run_id)}
Two highlighted lines are the entire pattern.
add_task queues the work to run after the
response has already been sent.
// 1. hand over the job — comes back immediately const { run_id, thread_id } = await createRun(baseUrl, query, threadId, signal); rememberThread(thread_id); // ← the entire memory feature // 2. ask "done yet?" until it settles for (;;) { await sleep(1500, signal); const { run, steps } = await readRun(baseUrl, run_id, signal); setSteps(steps); // live progress UI if (run.status !== "running") return finish(run); }
Ten lines. Identical in Vue or Flutter — it's just
fetch in a loop.
await sleep and not
setInterval.
A timer fires whether or not the last request came back.
Cold-start your backend, first poll takes 40s, and you now have 25
requests in flight for one run. Awaiting means the next poll
starts when the last one finished.
signal. That's what stops the
loop when the user leaves the screen. Forget it and the browser
polls your free-tier server until the tab closes — a great way to
discover rate limits.
thread_id, send it with the next question. The
history never comes near the browser — which is why reloading the
page doesn't lose the conversation.
BackgroundTasks lives inside
your server process.
Your host restarts the container — deploy, crash, idle spin-down, routine maintenance — and every in-flight run vanishes.
The row sits at running forever. The client polls a
job that no longer exists.
A competition demo. A hackathon. Your first version.
Be honest about it rather than surprised by it. Free hosts restart services readily.
running for >5 min as failed, so the UI
can say something useful.
BackgroundTasks lives in your processrunning
A deploy, a crash, or a free-tier recycle takes the job with it. The
row sits at running forever.
Cheap fix: mark runs stale after 10 minutes, so the
UI stops spinning and says something honest.
Real fix: a task queue with a separate worker process
— the jobs stop living inside the web service.
Either way the architecture is unchanged: accept, return an ID, poll. You're only swapping how the background work is scheduled.
| Polling | SSE / WebSockets | |
|---|---|---|
| UX | 1.5s lag | instant |
| Through proxies & firewalls | always works | sometimes buffered or blocked |
| Wi-Fi → mobile data | survives | connection drops |
| In Flutter / React Native | trivial | extra library |
| Debugging | it's just a GET | needs real tooling |
SSE is better when it works. Polling always works.
Ship polling. Upgrade when you've outgrown it.
# main.py API_KEY = "AIzaSy…" ← in the code
Once it's in a commit it's in git history forever. Deleting the line does not remove it.
# .env (gitignored) LLM_API_KEY=AIzaSy… # .env.example (committed) LLM_API_KEY=
In production the same values go in your host's environment panel.
.env goes in .gitignore
before your first commit. Bots scan public GitHub
for leaked keys within minutes.
The API key never
touches the client.
Not in React state. Not in a Flutter constant. Not in
NEXT_PUBLIC_* or VITE_*.
Making the agent's thinking visible — and giving it a memory.
This run's scratchpad. What did I search? What did I find? What step am I on?
Dies with the run. Belongs to the job.
→ our runs.messages column and steps
table
Survives the run. What does this user like? What have I learned before?
Belongs to the user, not the job.
→ our memories table, searched by meaning
Most student projects mash these together and then can't work out why the agent "remembers" the wrong things.
thread_id reloads the conversation and
replays it to the model. Then reload the page: still there,
because it was never in the browser.
create table runs ( id uuid primary key default gen_random_uuid(), thread_id uuid not null default gen_random_uuid(), -- the conversation query text not null, status text not null default 'queued', -- queued|running|done|error result text, messages jsonb, -- memory, for the model created_at timestamptz default now() ); create table steps ( -- the agent's thought process id bigserial primary key, run_id uuid references runs(id) on delete cascade, seq int not null, -- display order label text not null, -- "Agent is searching..." detail text );
on delete cascade: delete a run, its steps go too.
One less thing to clean up.
thread_id.
messages is that conversation in the shape the model
wants it back — load it, pass it to .run(), save it
again. That's the whole of "the agent remembers", and it's a
column, not a feature.
# load the conversation history = await db.get_thread_messages(thread_id) result = await agent.run(query, message_history=history) # save it back, for next time await db.update_run(run_id, messages=to_jsonable_python(result.all_messages()))
Load, run, save. Memory is a column, not a feature.
Ask a question. Then just type:
“why is that?”
Three words that mean nothing on their own. They work because the server reloads the conversation and replays it to a model that remembers nothing.
One question is one run. A conversation is several
runs sharing a thread_id.
Every turn re-sends the whole history — so threads get more expensive as they grow, and have to be capped.
messages column in
Postgres, which is also why you could carry the thread to your
phone.
await steps.log( "Searching the web", f'Looking for: "{query}"')
One insert before every phase — before, not after. The user wants to know what's happening now.
This is also your production debugger. You can't attach a breakpoint to a deployed agent, but you can read exactly how far it got.
-- 1. turn it on (already available on Supabase, free) create extension if not exists vector; -- 2. a table with an embedding column create table memories ( id bigserial primary key, content text, embedding vector(768) ); -- 3. find things that MEAN something similar (<=> is cosine distance) select content from memories order by embedding <=> $1 limit 5;
Keyword search finds the word "car". Vector search finds "automobile", "vehicle", and "my old Toyota".
vector(768) must match your embedding model's output
size exactly.
The single most common reason a student's pgvector project stops working.
Turn RLS on and write no policies — default deny. Then open only what you need.
Supabase gives you two keys. The
sb_publishable_… key is safe in a browser and
restricted by RLS. The sb_secret_… key
bypasses RLS entirely — server-side only,
forever.
The agent's database role gets INSERT on
steps — and nothing else it doesn't need.
It doesn't need DELETE on runs. So don't
give it DELETE on runs.
.env that gets committed is a full database takeover,
not just a leaked API quota.
anon /
service_role instead, they're on the legacy keys —
same two roles, older names, valid until end of 2026. Don't let
this derail the room.
execute_sql(query: str)
One confused generation away from DROP TABLE. And you
cannot prompt your way out of this — the model doesn't have to be
malicious, just wrong.
save_note(text: str) search_notes(query: str)
Narrow functions you wrote, that do exactly one thing, on exactly the rows they're allowed to touch.
A tool is just a function the agent is allowed to call. You decide how big that function is.
A tool is a function. MCP is that function, described so a client that has never seen your code can call it.
app.mount("/mcp", mcp_app)
One line, mounted inside the API that was already deploying. Point
Claude Desktop at
your-app.onrender.com/mcp and your Wikipedia tools
show up there. No second service.
MCPToolset("https://mcp.tavily.com/mcp") .filtered(lambda ctx, t: t.name == "tavily_search")
Web search we didn't build, from a server we don't run. Wikipedia is written after the fact — this is how the agent answers “what happened this week?”
If one program calls one function, MCP buys you nothing. It earns its place at a boundary — and deployment creates one.
.filtered(). Tavily's server offers
five tools — search, extract, crawl, map, research. We take one.
Handing a model all five is four more ways to spend credits.
"Narrow tools" from the previous slide, applied to tools you
didn't write.
TAVILY_API_KEY, no search
tool, and the agent says it can't check current information
instead of inventing it. Check web_search on
/health before demoing this.
Clone it while I talk:
github.com/dileepadev/deploying-agentic-ai-apps-workshop
app/ — a FastAPI process that keeps runningclient/ — a folder of static filesAll four are free, no credit card. Only the left-hand column is hard — a static host cannot get the agent wrong, because it never runs your code.
schema.sql in the Supabase SQL
editor → you now have runs and steps
.env is
not in the repo
app, build
pip install uv && uv sync --frozen, start
uv run fastapi run main.py --port $PORT
/health. Green? You're live.
Then paste your Render URL into the web client and ask it something.
0.0.0.0: binding to localhost
inside a container means nothing outside can reach you. Classic
first-deploy failure.
deploy/render.yaml does all of step 3 for you via
New + → Blueprint. Mention it, don't demo it.
app→
live URL
Built by the FastAPI team.
No config file, no build command, no start command —
it reads pyproject.toml, uv.lock and
.python-version and works the rest out.
Environment variables go in the dashboard with a
Secret toggle, then Save and Redeploy.
Prefer the terminal? uv run fastapi deploy from
app/.
Public beta, Hobby is 0.1 vCPU / 512 MB with
scale-to-zero on by default. Deploy to both hosts — nothing in
app/ knows which one it's on, and you get a fallback
URL for demo day.
client — the Vite
project, not the repo root
VITE_API_URL = your backend URL →
Deploy
client/vercel.json is already written, so there is
nothing else to configure. GitHub Pages needs even less — it's already
in the Actions workflow, published at /demo/.
Now wire the two together, or nothing works:
VITE_API_URL → the backend URL ·
ALLOWED_ORIGINS → the frontend origin.
Both need a redeploy.
VITE_* is baked into the bundle at build time
— changing it needs a redeploy, and it is never a place for
a key.
ALLOWED_ORIGINS live and watch "Failed to
fetch" turn into a working page. Best CORS demo there is.
| Symptom | Cause | Fix |
|---|---|---|
| "Failed to fetch" | CORS | add your frontend origin to ALLOWED_ORIGINS |
| First request hangs ~1 min | cold start | hit /health 10 min before any demo |
| Worked yesterday, dead today | DB paused when idle | resume it — and ping it weekly |
429 |
rate limit | retry with backoff; your own key |
404 from the model |
model name changed | check what's current, update the env var |
allow_origins=["*"] together with
allow_credentials=True is
silently rejected by browsers. Hours have died
here.
Never run the agent
inside the HTTP request.
Accept the job. Return an ID. Work in the background. Let the client poll.
.env in .gitignore before your first
commit
Once a key is pushed it's in history forever — rotate it, don't
just delete it
status = 'error'
A silently stuck run looks exactly like a slow one
docs/competition-checklist.md in
the repo.
Everything — slides, code, README —
github.com/dileepadev/deploying-agentic-ai-apps-workshop
docs/run-of-show.md.