$ cat work/askaditya-terminal-assistant.md
AskAditya - a public LLM endpoint that cannot run up a bill
The terminal widget in the corner of this site answers questions about my work over a streaming LLM endpoint. It is open to the internet with no login, so most of the build is the part that stops it becoming someone else's free API.
Next.js · TypeScript · Vercel AI SDK · AI Gateway · Claude Haiku 4.5
$ metrics
The problem
A portfolio makes a visitor read. A terminal lets them ask. The interesting version of 'tell me about your Laravel work' is the one where a hiring manager types it at 11pm and gets an answer grounded in facts I actually wrote down.
The uninteresting version is the one where that endpoint is a public, unauthenticated, pay-per-token API sitting on my credit card. Every design decision here comes from that second sentence.
The constraints
No login, because asking a stranger to sign up before they can ask about my rate defeats the point. No tools and no function calling, so there is no path from a prompt to anything that costs more than one completion. No secrets in the corpus, because the whole system prompt is one adversarial question away from being read aloud.
And a hard money ceiling that is enforced, not monitored. An alert tells me I have been billed. A circuit breaker means I have not.
The architecture
One route, four gates before the model is ever reached, and a corpus built from the same data files the pages render from - so the bot cannot drift from the site.
POST /api/chat
|
+-- content-length > 32KB? -> 413 (before JSON.parse: no free CPU)
+-- message empty or > 300 chars? -> 422
+-- 10 requests this hour by IP? -> 429
+-- month-to-date spend >= $10? -> 503 (circuit breaker, not an alarm)
|
v
streamText(model: anthropic/claude-haiku-4.5)
system: full corpus, cache breakpoint, frozen at module load
maxOutputTokens: 500
|
+-- stream chunks to the client as text/plain
+-- on finish: costUsd(usage) -> recordSpend()
corpus <- content/data/{profile,faq,work}.ts (one source of truth)Decisions that mattered
The body-size gate reads content-length and rejects before JSON.parse. The input cap is 300 characters, so anything past 32KB is not a long question, it is someone probing for a way to make my server work for free.
The spend counter runs on the usage block the provider returns, priced in lib/cost.ts, not on an estimate. Guessing token counts is how a cap silently stops capping.
The system prompt is frozen at module load rather than rebuilt per request. It has to be byte-stable for provider caching to engage at all, and the same property makes the corpus testable with a golden file.
The widget answers ten commands - help, whoami, work, cv, skills, contact - with no model involved. AI is the fallback, not the feature. When the endpoint is unconfigured or the cap has tripped, the terminal is still useful instead of broken.
What broke
The OG image route for case studies returned a rendered card for any slug. Satori renders a PNG per URL, so an unpublished slug returning 200 turned an image endpoint into an unauthenticated CPU amplifier: request 10,000 distinct slugs, get 10,000 renders. It now calls notFound() for anything not in the published set, exactly like the page does.
The corpus builder assumed every case study had a public repo. Client work usually does not, and the template wrote the literal string 'undefined' into the cached system prompt for any study without one. The bot was reciting it. Guarded and pinned with a test.
The third one I found by auditing my own docblock while writing this study. lib/prompt.ts claimed the corpus 'is served from Anthropic's prompt cache after the first request of each TTL window'. It was not. Nothing in the route set a cache breakpoint, so the full corpus was billed at input rate on every single request. The claim had been true as an intention and false as code since the day it shipped. The fix is four lines: the system message moves to its object form and carries an ephemeral cacheControl breakpoint, because the breakpoint has to sit on the message, not on the call.
The outcome
It runs in the corner of this page. Open it and try to break it - the gates are the interesting part, and the source for every number above is one file path away.
The reason this is the first case study on the site is that it is the one where I can show you the guardrails instead of describing them. Most AI integration work is not the prompt. It is the eleven tested failure paths behind it.