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.
Where the agent actually runs
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.
Free means it falls asleep
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
self-hostedScheduled 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.
The bill is the real limit
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.
What the budget buys, and what it costs
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.
The front end pays whatever is left
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.
What actually came back
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.