The staff have their assistant. Now the patient at the kiosk and the patient on the phone get theirs, and the difference between the two is the whole design: the patient assistant sees public documents only, calls no tools, and is asked its questions through a rate-limited endpoint that never accepts an audience from the client. This part builds it on the kiosk and in the season-three app, and the screenshot that matters is the one where it refuses.
Kiosk, a public question
Kiosk, a staff-only questionOne endpoint, with the audience decided by the server
The Pocket API from season three gains one route, a POST that takes a question and streams the answer back as plain text. The endpoint writes the audience itself, "public", and the request contract has no field for it; a client cannot ask to be staff. No tool is ever attached. Questions must be between 3 and 300 characters, and the built-in rate limiter allows ten per minute per address, which is generous for a person and enough to stop a script from turning the clinic's GPU into a public utility. The tenth request in a minute passed and the eleventh returned 429 in the live check.
The kiosk: giant type, one question, then reset
The kiosk runs inside the main application, so it calls the assistant service directly, with the public audience, below the check-in card. The panel follows the kiosk rules from season two: type large enough to read standing up, one action per screen, an "Ask another" button, and a reset after sixty seconds of nothing so the next patient does not read the last one's question. The footer says, in words a patient can act on, that the answer comes from the clinic's own computer and is not medical advice.
The app: a screen and a longer timeout
The shared Razor library gets an "Ask the clinic" screen reached from Home; the tab bar turned out to be five fixed links rather than a data-driven list, so a button on Home was the honest change. The typed API client reads the streamed text as it arrives. One detail that would have bitten a real patient: the app's HTTP client has a ten-second timeout for its other calls, and a model answer can take longer, so the assistant call uses its own two-minute client. If the request fails, the screen says the assistant needs a connection.
The rule, and the test that proves it
docs/private.md says the patient assistant never sees a staff chunk, enforced in SQL and tested in xUnit. The test uses the fake embedding generator so the exact text of a staff chunk is the question; with the public audience the query returns nothing, and the service returns the refusal without calling a model. That is the test. The kiosk screenshot is the same fact at human scale. Seventy-three tests pass, thirteen of them new.
What went wrong
The first live refusal on the kiosk came with two source lines under it. The model had written the refusal sentence and then, obediently, a Sources line, and the client's "is this the refusal" check read the raw text rather than the text the patient sees. The rule says citation or refusal, never both. Both clients now strip the Sources line before deciding, and a regression test pins the shape. A rule in a document is a wish until a test reads it the way a user would.
Do it yourself
The patient side: one endpoint, one panel, one screen. Tag private-09.
- Add the endpoint to the existing Pocket API group and put a named rate-limit policy on it:
api.MapPost("/assistant", AskAsync) .RequireRateLimiting(AssistantPolicy); - Register the limiter: a fixed window of ten per minute per client address, no queue, a JSON 429:
public static IServiceCollection AddAssistantRateLimiter(this IServiceCollection services) => services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy(AssistantPolicy, http => RateLimitPartition.GetFixedWindowLimiter(ClientKey(http), _ => AssistantLimiterOptions())); options.OnRejected = async (context, ct) => { context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.HttpContext.Response.ContentType = "application/json; charset=utf-8"; await context.HttpContext.Response.WriteAsJsonAsync(new { error = AssistantContract.TooManyError }, ct); }; }); // Program.cs: builder.Services.AddAssistantRateLimiter(); ... app.UseRateLimiter(); - Write the handler so the audience is a constant the client cannot change, and stream the answer as plain text. The guard marker becomes the refusal on the wire, since a stream cannot be unsent:
var question = request.Question?.Trim() ?? string.Empty; if (!AssistantContract.IsAskable(question)) // 3 to 300 characters return Results.BadRequest(new { error = AssistantContract.LengthError }); var reply = await assistant.AskAsync(question, KnowledgeAudience.Public, ct); http.Response.ContentType = "text/plain; charset=utf-8"; await foreach (var delta in reply.Deltas.WithCancellation(ct)) { if (delta == AssistantService.GuardMarker) { await http.Response.WriteAsync(AssistantService.RefusalText, ct); break; } if (delta == AssistantService.ResetMarker) continue; // no tools on this path, ever await http.Response.WriteAsync(delta, ct); await http.Response.Body.FlushAsync(ct); } - On the kiosk, call the service in-process with
KnowledgeAudience.Public, below the check-in card, in the kiosk's type sizes, and reset the panel after sixty seconds of silence the way the success screen already does. - In the shared app project, add a screen that posts the question and reads the stream. Give it its own client with a long timeout; the app's ten-second one would cut a model off mid-answer:
using var request = new HttpRequestMessage(HttpMethod.Post, "api/pocket/assistant") { Content = JsonContent.Create(new AssistantQuestion(question.Trim())), }; using var response = await _thinking.Value.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); if (response.StatusCode == HttpStatusCode.TooManyRequests) return new AssistantAnswer(AssistantContract.TooManyError, []); if (response.StatusCode == HttpStatusCode.BadRequest) return new AssistantAnswer(AssistantContract.LengthError, []); response.EnsureSuccessStatusCode(); // then read the body as it streams; if it ends as the refusal, show the refusal alone - Decide "is this the refusal" on the text the patient sees, after stripping any Sources line, and pin that with a test; the first live run showed a refusal with two citations under it.
- Prove the visibility rule twice: a unit test where a staff chunk is the exact question and the public audience gets nothing, and the kiosk asked for a staff-only number.
What you should see: a cited public answer on the kiosk and in the app, the fixed sentence for a staff-only question, a 400 for a two-character question and a 429 on the eleventh in a minute.
The brief that built it, trimmed to the lines that decided the result. Season one put every prompt in a commit message; this season's briefs were written to a coding assistant before each part, and this is what they said.
POST /api/pocket/assistant: { question } in, text/plain streamed out from AssistantService with audience
"public" fixed in the endpoint; 3 to 300 characters else 400; a fixed-window limit of 10 per minute per IP
named "assistant", 429 as short JSON; never tools; never an audience from the client. Kiosk: an "Ask a
question" panel under check-in, in-process, giant type, Ask another, 60 s reset. Pocket: an /ask screen and
PocketApi.AskAsync reading the stream; offline message from the connectivity capability. Tests: staff chunk
invisible to public (the visibility proof), length validation, the policy numbers. Harness flags --kiosk-ask
and --pocket-ask.Perishable facts, as of September 2026: ASP.NET Core's built-in rate limiter with a fixed window; the Pocket API remains unauthenticated by design, with the confirmation code as the credential for visit data and no credential needed for public questions. The sixty-second kiosk reset mirrors the success screen's proven pattern and was not itself photographed.
Model pick: the visibility rule, the server-decided audience and the rate limit were in the brief at high effort; the cheaper model built three surfaces in twenty minutes, and found the double-citation bug by looking at its own screenshot.
What the AI got wrong: the local model appended a source line to a refusal; the coding assistant checked the wrong text. Neither reached a patient.
The meter: a patient question costs the same as a staff one, one embedding and one generation, and the rate limit caps the worst case at ten a minute per address. The build meter accumulates for the retro.
Checkpoint: tag private-09 in
the repo. Open the kiosk,
ask what to bring, then ask for the internal home-collection rate; the second answer
must be the fixed sentence. Next: does it actually work, in numbers.