cd /news/ai-agents/i-put-an-llm-agent-on-a-static-site-… · home topics ai-agents article
[ARTICLE · art-125939] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

I put an LLM agent on a static site for free. Here is the actual bill

A developer built a free, serverless LLM agent for a static personal site by running a LangGraph-based RAG service as a Docker image on a Hugging Face Space's free CPU tier, with a GitHub Action deploying on every commit to main. Because free Spaces sleep after 48 hours of inactivity and the vector index is rebuilt at startup, the developer uses Uptime Kuma to ping a token-free /health endpoint every five minutes, keeping the container warm at the cost of his own electricity. The chat endpoint is protected by a per-address rate limit and a CORS allowlist, which the developer notes is not an authentication mechanism.

by read6 min views3 publishedSep 10, 2026

The previous post made a claim I still stand behind: this site has no server, no database and nothing to patch. It is a repository that becomes HTML.

Then there is a button in the bottom right corner that answers questions about me, in full sentences, out of a language model. Both things are true at the same time. The second one is only free at the point where you happen to be looking.

Not here. It is a Docker image on a Hugging Face Space, on the free CPU tier, two shared cores. A GitHub Action pushes the repository to the Space on every commit to main, so shipping the agent is still git push, the same gesture that ships the site.

The site's half of the contract is one line:

export const CHAT_ENDPOINT = 'https://martinimarcello00-personal-cv-langgraph.hf.space/chat';

That is the whole dynamic part of a static site. Everything in dist/ is still a file, and the one thing that thinks lives somewhere else behind a URL. The architecture did not get simpler. It moved.

A free Space sleeps after 48 hours without traffic, and only paid hardware can be told not to. The next visitor restarts it, which sounds harmless until you read what the container does when it boots:

CMD ["/bin/bash", "-c", "python build_rag.py && uvicorn api:app --host 0.0.0.0 --port 7860"]

The vector index is not baked into the image. It is rebuilt at startup, every document re-embedded with bge-small-en-v1.5 on those two shared cores before the first request gets an answer. Somebody arriving after a quiet weekend is not waiting for a container to start. They are waiting for an embedding job to finish.

So the Space is never allowed to be idle. The monitor that already watches the containers on my home server watches this one too.

Uptime Kuma — The thing that notices before I do.

Scheduled checks against the handful of endpoints that are supposed to answer, with a notification when one stops. The job I did not expect to give it is keeping something awake rather than watching it: a free Hugging Face Space sleeps after two days of silence, and a periodic request to its health check is what stops the assistant on this site from booting cold in front of a visitor.

The detail that makes it cheap is the target. api.py exposes a health check next to the chat endpoint:

@app.get("/health")
def health_check():
    return {"status": "ok"}

The monitor calls /health every five minutes and never calls /chat. It costs a TCP connection and zero tokens. Pointing a keepalive at the chat endpoint instead would mean paying a language model 288 times a day, forever, to tell a machine that it is still awake.

Five minutes against a threshold of 48 hours is far more often than the problem requires, and I am keeping it, because a check that frequent is also answering the question the monitor was installed for: not only is the agent awake, it is reachable, and I find out before a visitor does.

That is the first tradeoff and it deserves saying plainly: the free tier is free because it expects to be idle, and I am using hardware I own to guarantee that it never is. Somebody else's free hosting, kept alive by my electricity bill.

Sleeping is an inconvenience. The API is the part that can actually cost money, because every visitor is spending mine.

Two of the three guards are the ones everybody writes. A rate limit per address:

@app.post("/chat")
@limiter.limit("5/minute")

And a CORS list with my domain on it, which is worth being honest about. It stops a script on another site from using my endpoint from somebody's browser, and it stops nothing else. The Space has its own public interface one click away, and CORS was never an authentication mechanism.

The third guard is the one I would copy into any project shaped like this one. Before answering anything, the app asks the provider how much it has already spent today:

usage = get_today_model_usage("gpt-5-nano")
if usage.get("total_tokens", 0) >= daily_limit:
    return {"response": "I'm currently overwhelmed with fame (and API token limits)..."}

The budget is not a counter in a variable that resets when the container restarts. It is the organization usage endpoint, for the current UTC day, grouped by model. A crash does not hand out a fresh allowance, and neither does a redeploy.

Notice what the visitor gets when it runs out. A joke, not a 429. When a failure is guaranteed to happen eventually and it is not the visitor's fault, the degraded state stops being an error path and becomes a copy decision.

The choices that make that budget last are all visible in the code, and they are the same choices that decide how the thing feels. The model is gpt-5-nano. Memory is get_safe_history(messages, k=4), the last four messages, walked further back if the cut would land in the middle of a tool call. Retrieval mixes vectors with BM25, and the BM25 half is rebuilt from every document in the store on each query.

From the comment I left in the widget after measuring it: 17 seconds for a cold question, over 25 for a follow-up. Follow-ups are slower than first questions, which is the opposite of what anyone expects, because the thread travels with the request.

There is a stranger consequence hiding in the same file. The checkpointer is MemorySaver, which lives in the process. The browser stores a thread id and keeps the transcript, so a conversation survives navigation, a reload and my next deploy. The agent's copy of it does not survive the restart. The visitor is holding a conversation the server has already forgotten, and it looks fine right up until it does not.

None of that can be fixed for free, so the interface has to absorb it.

const killTimer = window.setTimeout(() => controller.abort('timeout'), 90000);

Ninety seconds before giving up, a counter that appears once the wait passes five, and a line under the typing dots that says what is going on: "Still thinking, 12s. It runs on a free box and takes its time."

Naming the constraint costs nothing and changes the entire reading of the wait. Twenty five silent seconds are indistinguishable from something broken. The same twenty five seconds with a number attached are a slow machine, which is exactly what it is.

The obvious next move is already half built. api.py also exposes /chat/stream, and the site does not call it. The wait would stop being one opaque block and start being text arriving. That is a commit, not a subscription.

The site is still static. Nothing returned that I have to patch, back up or keep running. What returned is a budget, a monitor and a sentence admitting the box is free.

Free hosting for something that thinks is real, and this is the price on the label: it sleeps unless something wakes it, it forgets unless somebody pays for memory, it is slow unless the answer is cheap, and it stops when the day's tokens are gone. All four are acceptable for an assistant on a personal site. None of them are invisible, and pretending otherwise is how you end up with a chat button that has been broken for a week before anyone bothers to tell you.

This one first appeared on my site, where I write about the systems I build and the tools I actually use — the rest of the posts live here.

── more in #ai-agents 4 stories · sorted by recency
── more on @hugging face 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-put-an-llm-agent-o…] indexed:0 read:6min 2026-09-10 ·