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.

The kiosk at tablet size. Above, the check-in card with a six-letter code entry and a Check in button. Below, an 'Ask a question' panel showing the question 'What should I bring to my appointment?' and the answer in large type: Bring your appointment code and a photo ID. First-time patients should also bring previous prescriptions and reports. Then 'From: Patient information > Before your appointment', an Ask another button, and a footer saying the answer comes from the clinic's own computer and is not medical advice. Kiosk, a public question
The same kiosk panel with the question 'What is the internal rate for home sample collection?' and the answer in large type: I don't know that; please ask reception. No source line. Kiosk, a staff-only question
Two questions at the kiosk. The first is answered from the public patient information page with its source shown. The second asks for a number that exists only in a staff document; Part 7's staff assistant answered it, and the kiosk cannot, because the query that fetches passages for a public question never sees that document. The refusal is the fixed sentence, with no source line, by rule.

One 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 Pocket app's Ask the clinic screen at phone size: an explanation that answers come from the clinic's own computer and never leave the building, the question 'Where can I park?' in the box, a Send button, a 'Not medical advice' note, and the answer card: Two-wheelers park in the basement, and cars should use the municipal lot opposite the clinic, which is paid. There is no car parking on the clinic premises. From: Patient information > Parking. The tab bar shows Home, My visit, Waiting room, Find us, Settings.
The web host of the Pocket app, at phone size. The same shared screen serves the Android and Windows hosts from season three; the Android build was not exercised in this part and is noted as such.

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.

  1. Add the endpoint to the existing Pocket API group and put a named rate-limit policy on it:
    api.MapPost("/assistant", AskAsync)
       .RequireRateLimiting(AssistantPolicy);
  2. 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();
  3. 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);
    }
  4. 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.
  5. 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
  6. 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.
  7. 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.