Projects / Personal / 2026
Answering Recruiters' Questions in Seconds for a Fraction of a Cent Each Using Claude on Cloudflare Workers
Benefits
- 1–3 secondsfor a typical full reply
- $0.06model cost for roughly 100 test messages
- Under $5projected monthly running cost
- 0 serversto patch, scale or keep warm
- 12 of 12test replies ending with follow-up questions
Overview
Moiz Ahmed is a full-stack engineer with eight years at Google, Apple and Amazon. When he built his personal site, he wanted visitors to be able to ask about his work in their own words and get an accurate answer straight away.
He built MoBot, an AI assistant that lives in a sidebar on every page. MoBot answers only from Moiz's résumé and the site's own content, argues his case in hiring conversations without stretching the facts, and can pass a message to him. It runs on a single serverless endpoint with Claude Haiku, replies in one to three seconds, and is designed to cost less than $5 a month with a hard ceiling that abuse cannot exceed.
Opportunity | Turning a One-Way Résumé into a Conversation
A résumé is one-way, and recruiters have questions it doesn't answer: what a project involved, whether a skill is recent, whether a role would be a fit. Usually those questions go unasked, or wait days for an email reply.
Moiz wanted visitors to ask in their own words, get a straight answer, and reach him from the same conversation. The risk was accuracy. Recruiters may act on what an assistant tells them, so an invented detail about his career would cost more than no answer at all.
"It should never invent facts about me. It has to be honest."
He also saw a chance to practise what he does at work in a system small enough to reason about end to end: an LLM application with real users, real cost and real abuse risk.
Solution | Building an Honest, Low-Cost Assistant on Serverless Infrastructure
Moiz made the product decisions first, because they set the constraints for the architecture.
- An assistant, not an impersonation. MoBot speaks about Moiz in the third person and never pretends to be him.
- Honest by design. It answers only from his résumé and the site's content. If the answer isn't there, it says so and offers to pass the question on.
- On his side, within the facts. Asked "should I hire him?", MoBot asks about the role, picks the evidence that fits and gives a verdict. It also says plainly when a requirement isn't in his background.
- Keep it funny. A bot that visitors can ask anything about his work should be good company, so MoBot has a voice and a new excuse for Moiz's absence each day of the week.
- Nobody should have to think of a question. Starter questions open the chat, and every reply ends with follow-up buttons.
- The conversation follows the visitor. The chat is a sidebar that stays open, mid-reply included, across page changes.
- No accounts. Asking a question shouldn't require signing up for anything.
The rest of this section covers the system design in detail.
Requirements and constraints
| Correctness | Never state something about Moiz that he hasn't written down. Prefer "I don't know". |
|---|---|
| Cost | Under $10 a month in total, with a hard ceiling that abuse cannot push past. |
| Latency | First words on screen within about a second; a full reply in a few seconds. |
| Operations | No servers to patch, no database to run, nothing that pages anyone. |
| Privacy | No accounts and no stored transcripts. |
| Scale | A personal site: tens of conversations a day at most, with occasional spikes if a post is shared. |
Those constraints decide most of what follows. A low, spiky load with a hard cost cap points to serverless and a small model; "no database" and "no transcripts" push state to the browser and a key-value store.
Architecture
The site itself is static HTML served from Cloudflare's edge. Only three routes run code: the chat, the contact form and the game's leaderboard. They are stateless functions, so there is nothing to keep warm and nothing to scale.
What happens on one message
- Validate. The Worker checks the shape and length of the conversation sent by the browser and trims it to a recent window. Oversized or malformed input is rejected before anything costs money.
- Prove it's a person. If the visitor has no valid session cookie, the Worker requires a bot-check token, verifies it with Cloudflare, then stores a session and sets an
HttpOnly,Secure,SameSite=Strictcookie. The check runs once a day per visitor, not once per message. - Check the budgets. Two counters are read from the key-value store: this month's spend for the whole site, and this visitor's messages today. Either being exhausted ends the request with a friendly "MoBot is napping" message.
- Assemble the prompt. A stable block (instructions and facts) and a small per-request block (the visitor's date, and the greeting they were shown).
- Call the model with streaming. Text is forwarded to the browser as it arrives.
- Run tools if asked. The model may search the web, ask to email Moiz, or propose follow-up questions. The loop is bounded to a few rounds.
- Account for it. The token usage from every model call in the request is priced and added to the monthly counter, in a
finallyblock so a failure still gets counted.
Knowledge: why there is no vector database
The usual design for "chat with my documents" is retrieval: chunk the documents, embed them, store vectors, and fetch the closest chunks for each question. Moiz chose not to build that.
- The corpus is tiny. The résumé, eight project write-ups and a few pages come to roughly 4,000 tokens with the instructions. Sending all of it costs a fraction of a cent.
- Retrieval can miss. When the right chunk isn't fetched, the model answers from a partial picture, and that is exactly how a confident wrong answer about someone's career would happen. With everything in context there is no retrieval step to fail.
- One source of truth. The same Markdown files render the pages and feed the prompt. Editing a file and pushing updates both. There is no index to rebuild and no way for the site and the bot to drift apart.
The point at which this stops working is easy to state: when the corpus outgrows a few tens of thousands of tokens, or when the per-message cost of resending it matters. Neither is close. If the blog grows large, posts would move behind a retrieval tool while the core facts stay inline.
The stable block is marked for prompt caching and placed first, with the date and greeting after it. Caching matches on an exact prefix, so anything that varies must come later. At this traffic the cache is often cold, which is fine: it's an optimization for busy moments, not something the budget depends on.
Why Claude Haiku
Moiz chose the smallest current Claude model, and the reasoning is about fit, not only price.
| Model | Input / output per million tokens | Estimated monthly cost at 300 conversations |
|---|---|---|
| Claude Haiku 5.5 | $0.10 / $0.50 | about $4 |
| Claude Sonnet 5.5 | $2 / $10 | about $36 |
| Claude Opus 5.5 | $4 / $20 | about $70 |
- The task is narrow. Answering from one page of facts in a set voice is reading comprehension and tone, not deep reasoning. A larger model's extra capability would go unused.
- The budget is fixed. At the planned traffic, only Haiku fits under $10 a month with room to spare.
- Abuse stays cheap. If someone gets past the limits, the damage is bounded by price per message. At a fraction of a cent, a worst-case day is still pocket change.
- Speed is part of the product. Replies start in about a second, which is what makes follow-up buttons feel like a conversation.
- It's a one-line change. The model name is a constant. If quality fell short, moving up a tier would take a minute and a redeploy.
The trade-off: a small model is more likely to skip an instruction. It occasionally forgot the follow-up buttons until that instruction was rewritten to explain why they matter, after which twelve out of twelve test replies included them. Writing instructions as reasons, not commands, mattered more than model size.
Tools
| Tool | Runs | Purpose and guard |
|---|---|---|
| Web search | On Anthropic's servers | General questions unrelated to Moiz. Capped per message. The prompt forbids searching for him, because other people share his name and their results would be reported as his. |
| Send message to Moiz | In the Worker | Emails Moiz a visitor's name, address and message. The model must read the message back and get a clear yes first. Input is validated in code and rate-limited separately from chat. |
| Suggest follow-ups | In the Worker | Returns up to three short questions, which become buttons. The Worker validates and trims them, then ends the turn without another model call. |
Follow-up questions are a tool call, not text the model appends to its reply. That keeps them out of the streamed answer, gives them a schema that can be validated, and means a malformed suggestion is dropped instead of shown.
The tool loop is bounded, a tool call that was cut off by the output limit is never executed, and a paused server-side search is resumed by resending the turn. The email tool has the smallest possible blast radius: it can only write to one fixed inbox, and that address is a secret the model never sees.
Streaming protocol
The Worker returns newline-delimited JSON over a single HTTP response: text deltas, astatus such as "searching", sources for cited pages, suggestions, anderror or done. This was chosen over Server-Sent Events because the request is a POST with a body, which EventSource can't send, and over WebSockets because the exchange is one request and one response. A plain fetch with a stream reader is all the client needs.
The Anthropic API key stays in the Worker. The browser's stream contains only the application's own event types, so nothing about the upstream API leaks into the client contract and the provider can change without touching the UI.
Where state lives
| State | Lives in | Why |
|---|---|---|
| Conversation | The visitor's browser, for that tab's session | The API is stateless; the browser resends history. No transcripts are stored. |
| "Passed the bot check" | Key-value store, keyed by a random id in a cookie, expiring daily | Avoids re-challenging on every message. |
| Per-visitor daily counts | Key-value store, expiring daily | Rate limiting without a database. |
| Monthly spend | Key-value store | The budget brake. |
| Secrets | Worker secrets | Never in the repository or the page. |
| Knowledge | Compiled into the Worker at build time | No runtime fetch, no cold-start lookup. |
Because the browser holds the conversation, a visitor could edit the history they send. That is acceptable here: they can only change their own conversation, the prompt and facts are added server-side on every request, and nothing in the history grants access to anything.
The key-value store is eventually consistent and has no atomic increment. Two simultaneous requests can both read the same counter and one increment is lost. That is accepted: the counters are approximate brakes, and the real ceiling is outside the application (see below). A strict counter would need a Durable Object, which would be added if the limits ever needed to be exact.
Abuse, cost and security
A public endpoint that spends money per request is the main risk in this design. The controls are layered so that no single one has to be perfect.
| Threat | Control |
|---|---|
| Scripts hammering the endpoint | A bot check before the first message, then a session cookie. Requests without either are rejected before the model is called. |
| One person sending thousands of messages | A per-visitor daily message limit, plus caps on message length, history length and reply length. |
| Many people at once, or a bypass of the above | A site-wide monthly spend counter that puts MoBot to sleep before the cap. |
| A bug in the limits themselves | Prepaid API credit with auto-reload off. When the balance is gone, calls fail. This is the only control that doesn't depend on the application's code being right. |
| Prompt injection ("ignore your instructions") | The model is told that visitor text and search results are data, not instructions. More importantly, it holds no secrets and has no powerful tools, so a successful injection yields an off-topic reply and nothing else. |
| Using MoBot to spam Moiz | The email tool is rate-limited per visitor, validated in code and can only reach his inbox. |
| Harvesting his email address | It isn't in the page, the repository or the prompt. It's a Worker secret used only as the recipient. |
| Leaked API key | The key exists only as a Worker secret, has an expiry date, and can spend at most the prepaid balance. |
The principle behind the injection row applies to any LLM feature: assume the model can be talked into anything, and design so that it doesn't matter. Safety comes from what the model is able to reach, not from asking it nicely.
Rate limits are keyed on IP address, which is imperfect: people behind one office network share a limit, and a determined attacker can rotate addresses. For a personal site the trade is right, since the alternatives are accounts or fingerprinting, and the spend ceilings catch what IP limits miss.
Failure modes
| If this fails | The visitor sees |
|---|---|
| Model API error, expired key or empty balance | "MoBot is napping", with a pointer to the contact form. |
| Model rate limit | "Give me a moment and try again." |
| Bot check fails or can't load | A message to reload, and the contact form as the fallback. |
| Email service rejects a message | MoBot is told the send failed and says so honestly, instead of claiming success. |
| The model declines a request | A short line steering back to questions about Moiz. |
| JavaScript disabled, or the chat script fails | The whole site still works. The chat is an enhancement on static pages. |
| The Worker itself is down | Static pages are served from the edge independently; only chat, contact and the leaderboard are affected. |
Every failure degrades to the contact form, which shares no dependency with the model. There is always a way to reach him.
The persistent sidebar
The site is built with Astro and ships plain HTML for content. The chat is the one place that needs long-lived client state, so it is a single React component. Astro's client-side router swaps the page content on navigation and keeps that component mounted, which is why a reply continues streaming while you move from Projects to About.
The cost of that choice is honest to state: React's runtime is the largest script on the site, about 66 KB compressed, and it loads on every page for a feature not everyone opens. The component itself is 3.4 KB. Loading the runtime only when the chat is first opened is the obvious next optimization.
Testing
- Behaviour script. A fixed set of questions run against the real model after every prompt change: facts it should know, things it must not guess (why he left a job, salary, employment terms), an injection attempt, a request to look him up online, an off-topic essay request, and a full message hand-off.
- Regression by counting. For behaviours that are probabilistic, such as offering follow-ups, the rate was measured across a dozen varied openers instead of trusting one good run.
- End to end in a browser. Opening the chat, navigating mid-reply, reloading, and the phone layout.
- Limits. The daily limit was confirmed the direct way: testing ran into it.
What's missing is automation. These checks are scripts run by hand, not a suite that gates a deploy. That is the first thing to add.
Alternatives considered
| Chosen | Alternative | Why not |
|---|---|---|
| Facts in the prompt | Retrieval with a vector store | More moving parts and a new way to be wrong, for a corpus that fits in one request. |
| A Worker | A container or VM | Idle cost and patching for a workload that is idle most of the day. |
| Key-value counters | A relational database or Durable Objects | Exact counts weren't worth the extra component, given the prepaid ceiling. |
| Calling the API directly | An agent framework | The loop is thirty lines. A framework would hide the parts that most need control: cost, limits and what gets streamed. |
| History in the browser | Server-side sessions | Would mean storing visitors' conversations. |
| A static site with one island | A single-page app | Five of six pages are text. They should be HTML. |
Known limitations and next steps
- No transcripts means no visibility. There is no record of what people ask or where MoBot falls short. The next step is anonymous, aggregate logging: question categories and "didn't know" counts, without storing text.
- No automated evaluation. The behaviour script should run in CI against a pinned model, with a pass threshold.
- Approximate counters. Fine at this scale; a Durable Object would make them exact.
- Follow-ups can overreach. Because the model writes them, it occasionally suggests a question it can only partly answer.
- English only, and the voice was tuned by hand rather than measured.
Outcome | Fast, Accurate Answers at a Fraction of a Cent Each
MoBot went live on meetmoiz.com in October 2026. In testing it passed every item in its behaviour script: known facts, unknowns, salary, employment terms, an injection attempt, a request to search for Moiz online, an off-topic request and the full message hand-off.
| Measured | Result |
|---|---|
| Typical full reply | 1 to 3 seconds; up to about 6 seconds with a web search |
| Model cost in testing | $0.06 for roughly 100 messages, web searches included |
| Replies ending with follow-up questions | 12 of 12 varied openers |
| Chat component size | 3.4 KB compressed |
| Code | About 900 lines of TypeScript across the endpoint, guards and sidebar |
| Infrastructure cost outside the model | $0; hosting, storage, bot check and email are on free tiers |
A reply also kept streaming across a page change with no reload, which was the hardest requirement of the sidebar design.
First-month projections
These figures are projections, not results. They will be replaced with measured data once the site has run for a month.
| Measure | Projection | Basis |
|---|---|---|
| Conversations | around 300 | The traffic the budget was planned for. |
| Messages per conversation | 6 to 8 | Follow-up buttons should lift this above a typical two or three. |
| Model spend | under $5 | About 2,400 messages at the measured cost per message, plus occasional searches. |
| Messages passed to Moiz | 10 to 20 | A few percent of conversations ending in a hand-off. |
| "I don't know" answers | under 10% of replies | Above that, the facts need expanding. |
Next, Moiz plans to add anonymous, aggregate usage counts, so that the questions MoBot can't answer show him what to write about, and to run the behaviour script automatically before each deploy.
About Moiz Ahmed
Moiz Ahmed is a full-stack software engineer based in Seattle with eight years at Google, Apple and Amazon. He builds distributed services, AI-powered tools and the automation that helps engineering teams ship faster.
Technologies used
Claude Haiku 5.5
The model behind every reply, chosen for speed and cost on a narrow task. Called with streaming and tool use.
Cloudflare Workers
Runs the chat endpoint at the edge. Stateless, billed per request and idle at no cost.
Workers KV
Holds sessions, per-visitor limits and the monthly spend counter, each with an expiry.
Cloudflare Turnstile
Confirms a visitor is a person once a day, before any request reaches the model.
Astro and React
Static pages for content, with one persistent React component for the chat sidebar.
Resend
Delivers messages from visitors to Moiz's inbox when MoBot's email tool is used.
Built with Claude Code as a pair programmer: the product and design decisions are Moiz's, and much of the code was written in that collaboration.