Adding Claude Haiku to my CV builder: sign-in first, a spend cap, and a fallback
BuildCV, the CV builder on buildcv.zakaria.lu, was built on one promise: no account, no upload, nothing leaves the browser. I also had about $100 of Anthropic API credit, and a CV builder is exactly where a small, fast model earns its keep. This is how I added Claude Haiku 5.5 to it without breaking that promise for anyone who doesn't opt in, without handing the internet a free endpoint, and without the builder falling over the day the credit runs out.
Three requirements set the shape. It runs on my own Claude key, separate from the DeepSeek key behind the site's concierge. Only signed-in people can spend it. And when the budget is gone, BuildCV keeps working with the coaching it already had.
Four actions, all of them suggestions
Assist adds four buttons: Polish on any bullet (three rewrites), Draft with AI for the summary (two options), AI analysis against a pasted job ad (a fit verdict, missing skills, bullets worth rewording) and AI review (up to five things a recruiter would notice). Nothing changes until you press "Use this" or "Apply", and those go through the same reducer as typing, so Undo takes them back.

Polish on a real bullet in production, with real Haiku output. Option 2's "50% reduction" is arithmetic on the 12 and 6 days already in the bullet, not a new claim. The CV is a fictional persona.
Ask first, send the minimum
The first AI click opens a consent dialog that says exactly what happens. The payload builder sends role titles, employers, bullets, skills and the job ad if you pasted one. It never sends a name, email, phone, address, links or the photo, and it sends nothing at all for the sample persona. Entries travel as refs like E1 and bullets as E1.2, never as document ids, and the server rejects any answer that cites a ref it didn't send.

The consent dialog, captured on a local test run of the same build that is in production.
On the server, none of the CV is kept. The only new table, public.buildcv_assist_request (Flyway V14), is a metadata ledger: task, model, status, token counts, cost and duration, purged after 90 days. The startup schema check pins its exact columns, so a content column can't quietly appear. I also never log an SDK or JSON exception message, because those can echo the submitted text.
Sign-in without losing the CV
The gate is the same Keycloak-brokered LinkedIn sign-in the rest of the site uses. The work was in coming back. The button links to the sign-in route with a returnTo built from origin and path only. That matters because BuildCV's share links carry a whole CV in a #/open?d=… fragment, and that must never end up in a server URL. The server accepts returnTo only for an exact configured origin, rebuilds it, and reads and clears it from the session before the callback runs, so a stale target can't follow a later sign-in. BuildCV sign-ins record a visit like any other but skip the instant email I normally get. After the round trip, the action you clicked is focused, not run. Spending is always a click.
Nine gates, cheapest first

Every refusal happens before the provider call; only gate 8 spends money.
The order is the design. An anonymous POST gets a 401 from the security filter before Play parses the body, so nobody can make me read 64 KiB without a session. Requests the browser marks as cross-site are refused. Then the free checks: is the feature paused or out of budget, is this IP bursting, has this person used their 30 actions in the last 24 hours? Quota and budget are reserved before the call, so two tabs can't both take the last slot.
The model call is a pure text generator. A pasted job ad is a prompt-injection vector from anyone with a LinkedIn account, so there are no tools, no tool choice, nothing the model can trigger. It answers JSON against a schema, the server checks counts, lengths and refs, and the browser renders the result as plain text. In Scala, with the Anthropic Java SDK (abridged):
private lazy val client: AnthropicClientAsync =
AnthropicOkHttpClientAsync.builder()
// Never fromEnv(): a shell's ANTHROPIC_API_KEY must not replace this key,
// nor ANTHROPIC_LOG start logging request bodies.
.apiKey(settings.apiKey)
.maxRetries(0)
.logLevel(LogLevel.OFF)
.timeout(Timeout.builder().connect(Duration.ofSeconds(5))
.request(Duration.ofMillis(settings.timeout.toMillis)).build())
.build()
private def params(request: AssistRequest): MessageCreateParams = {
val system = TextBlockParam.builder().text(request.system)
.cacheControl(CacheControlEphemeral.builder().build()).build()
MessageCreateParams.builder()
.model(settings.model) // claude-haiku-5-5
.maxTokens(request.maxTokens.toLong)
.systemOfTextBlockParams(java.util.List.of(system))
.addUserMessage(request.user)
.outputConfig(OutputConfig.builder()
.effort(OutputConfig.Effort.LOW)
.format(format(request.schemaJson))
.build())
.build() // no tools, no tool_choice, no temperature, no prefill
}maxRetries(0) and a 40-second timeout keep one call inside Play's idle timeout. An explicit key instead of fromEnv() means a stray ANTHROPIC_API_KEY or ANTHROPIC_LOG in a shell can neither swap the key nor start logging request bodies.
Three limits, and a fallback that is a feature

Each limit is a backstop for the one before it; the UI only ever degrades.
The app budget defaults to $10 a month, summed from the ledger plus the worst case of anything in flight. Cost rounds up, and a timeout after sending is booked at the worst case, so my accounting errs high. The third layer lives outside my code: a dedicated Anthropic Console workspace for this key, with its own spend limit, in case the first two are ever wrong.
If Anthropic answers with a billing error (402, billing_error, the credit-balance 400) or a key error (401/403), a breaker opens for an hour, then lets a single probe through. While it's open, every request answers local for free, and the builder says AI is paused and keeps its built-in coaching in front: example phrases, summary templates, the keyword tailor and the rule-based review checks. I chose that over a second AI vendor.

The "AI paused" state, captured locally against a mock Messages API forced to answer 402 billing_error.
What four real calls cost
Before any real money moved, I ran the whole flow end to end against a small mock of the Messages API, including the 402 path. The one thing a mock can't prove is whether the model accepts the request, so the first real calls were the real test: Haiku 5.5 took effort: low plus a JSON schema without complaint.
What the first real run got wrong
Real output found three problems the mock never could, and all three are mine rather than the model's.

AI analysis in production. The first suggestion is identical to the original bullet, so the UI shows it as already applied.
A rewrite identical to the original. The analysis "suggested" a bullet unchanged, and the UI showed it as Applied because the text already matched. The server should drop no-op rewrites before they reach the browser.
"Experience entries have no dates." They do. Following my own contract, the review payload sends education dates but not experience dates, and the model reported what it was shown. The review prompt asks about gaps, so the dates need to go along.
Internal refs in prose. "Add a bullet under E1 or E2" leaks wire refs into text a person reads. The prompt should name the role and employer instead.

AI review in production. Issue 2 is a genuine catch; issue 5 is my payload's fault; "E1" and "E1.3" should never reach a reader.
The same review also made exactly the catch I'd hoped for: the summary said the ERP migration was still in progress while a bullet said it was delivered on time. A recruiter would spot that, and no keyword check would.
A Guice lesson on the way
In production, Play runs Guice in Stage.PRODUCTION, which builds every singleton at startup. That shaped two things. The SDK client starts a timer thread when it's built, so it's a lazy val forced only by the startup sequence, and if building it fails the feature reports disabled instead of failing the boot. And the automated review on the pull request caught that a malformed setting such as daily-limit = "30/day" threw in the settings constructor, which would have taken down all four of my sites even with Assist switched off. Settings now report problems from validate(), and only when the feature is on.
Where this leaves BuildCV
The builder still works with no account and no network. The AI is one opt-in click away and costs at most about a tenth of a cent per click. When the money runs out, it says so and gets out of the way. Next up are the three fixes above.