I used Claude to help draft and structure this post. The architecture, security decisions, frontend context plumbing, and eval loop are from work shipping a real assistant on a pay platform — described here at the pattern level only. No internal documents, catalogs, or wire contracts.
Putting an LLM in a payroll product is easy to demo and hard to trust. The model will invent IDs, echo emails from page context, and — given a GraphQL client — invent queries you never meant to run. We needed a command-palette assistant that could navigate, look things up, open forms, generate reports, and drive short multi-step workflows without becoming a second API that bypasses auth, ACL, and compliance.
The answer: a NestJS BFF plus a React client inside the pay-platform SPA. The drawer and palette collect page context, stream a chat turn, and apply structured actions locally. The browser streams; the model proposes; the BFF decides; the SPA executes only validated UI side effects.
What "Agentic" Means Here
Ours is a bounded loop that calls tools, observes results, and either continues or hands a structured action to the frontend — not an open-ended planner with a shell. Max depth is a handful of iterations; most turns end in one or two tool hops.
| Outcome | What the user experiences |
|---|---|
| Text | An answer from tool results, or a clarification |
| Navigate | The SPA routes (optionally with filters applied) |
| UI / click | A dialog opens, a tab switches, or a labeled control is activated |
| Form collect | An in-chat form; submit is an explicit second step |
| Download | A report file — the URL never lands in the chat transcript |
Reads can round-trip through the model in one turn. Writes cannot fire off a bare prompt; they go through a form the user submits. That product rule drove more architecture than any model choice.
A Mental Model of One Turn
User opens Ask AI (palette / hotkey / drawer)
│
▼
SPA collects page context (registry snapshot)
│ global surface + per-page hooks + form/table snapshots
│ FE PII deny-list + size caps
▼
Browser POST ──SSE──▶ BFF (messages + context[])
│
├─ Auth · rate limit · idempotency
├─ Sanitize again (card data hard-fail; PII → tokens)
├─ Classify intent → advertise a small tool slice
├─ Build system prompt (safety + tools + playbook + memory + page context)
│
▼
Bounded agentic loop
│
│ stream tokens from LLM
│ │
│ ├─ no tool call ──────────────▶ text + done
│ │
│ ├─ client tool (navigate / UI / click / form)
│ │ validate → emit action → done
│ │ (or reject + continue so the model can retry)
│ │
│ └─ server tool (lookup / analytics / confirmed mutation)
│ validate → ACL → execute
│ append summary to prompt → loop
│
▼
SPA applies action chunks locally (router, click, form, download)
│
▼
Audit (encrypted) · session tool-memory · metricsStack: NestJS on Fastify (BFF); React drawer, palette, context registry, and action handlers; Redis for ephemeral session/tool memory and rate limits; Postgres for encrypted audit; Azure-hosted chat models behind a thin adapter. Without cloud credentials, an in-process mock keeps the streaming path testable.
The chat endpoint writes SSE to the Fastify response — heartbeats so proxies don't kill the stream, abort on socket close so a closed tab cancels the model and in-flight tools. Chunks are typed: text deltas, tool-call/result visibility, structured actions, errors without stack traces, and a terminal done.
How the SPA Talks to the Assistant
Auth is the same session cookie — no separate AI login. The FE does not own the tool catalog (it may send an empty tools list; the BFF decides). What the FE owns is page context and local action execution.
Page context is a registry, not a DOM dump
Mounted screens register a short-lived context producer: id, human-readable label (shown as a chip), and build() returning a plain object. Unmount removes it. Collect never re-renders the tree; the drawer reads producers on demand at send time.
On send: collect() → merge/cap (hard ceiling per turn) → ship context: PageContext[] with the chat POST. The BFF scrubs again and injects into the system prompt. The model sees what the SPA chose to share for this turn — not a live tab scrape.
Each entry is { id, label, payload } after sanitization. One-shot ids (notably form submission) append for a single turn and never stay in the registry.
Three layers of what gets shared
1. Global page surface (every route) — pathname, segments, query/route params (sensitive keys dropped); capped plain-text summary of main content (assistant chrome, scripts, SVGs stripped); capped click targets (buttons, in-app links, menus, tabs) with stable id, label, type, and region. Targets are stamped on the live DOM at collect so a later click resolves back. Controls can opt out (data-ai-no-click); password/card inputs are excluded; duplicate labels are deduped per region/type.
2. Per-page feature registrations — list/detail screens expose tight domain snapshots (e.g. vendor count + visible row ids/names/statuses). A FE PII deny-list runs over every payload before it leaves (SSN, passport, TIN, CVV, account numbers, DOB-shaped keys, …) with depth/string/array caps. The BFF sanitizer is the second line of defence.
3. Form and table helpers — form-builder hosts auto-register sanitized RHF values (empties dropped, key count capped) plus optional stepper chrome. Data tables register a small row snapshot for "open the second row" follow-ups without shipping the grid.
Chips reflect active context labels. Telemetry records that context was shared (ids only), not payload contents.
Attachments are parallel: FE uploads to the BFF (and may park in product storage when the page form declares file fields), then sends attachment refs. Vision/extraction stays on the server; the SPA only orchestrates upload UI.
Actions come back as instructions, not scripts
Text deltas append to the bubble. An action chunk switches on kind:
| Kind | SPA behaviour |
|---|---|
navigate | React Router navigate(to) |
ui | Navigate if needed, then flush a registered UI action after the page is ready |
click | Same route-then-act; resolve targetId/label against the last click-target map |
form + collect | Render an in-chat schema form; wait for the user |
form + apply / navigate | Prefill a registered form, or navigate with values in location state |
download | Transient-anchor download; never log, render, or persist the URL past the turn |
Pending UI/click actions survive a route change with a short flush — "go to X and open create" is one logical step. Invalid/stale clicks fail soft into progress/error copy. Popup-blocked downloads fall back to a bubble button — still without echoing the raw URL.
Form submit is another context-bearing turn
The FE does not call the mutating API. It recollects page context, appends a one-shot submission whose id matches submitContextId, puts { mutationName, values } in that payload, and sends a new turn ("Submitting the … form."). The BFF consent gate permits the mutating server tool. Dismissing the form is a local transcript note — no mutation turn. "The model wanted a write" and "the human confirmed" stay separate in the audit trail.
Command palette as the front door
Ask AI is a first-class palette section: open the drawer, start fresh, or seed the composer from the palette search box. Same provider, same collect on send — an entry point, not a second agent. Feature-gated so rows vanish when the assistant is off.
Why the split matters: declared snapshots only; clicking/navigating are FE capabilities gated by collect + BFF validation — not eval of model JS; writes round-trip through chat + consent; new screens opt in via a small hook, not by forking the chat client.
The Core Tenet: the LLM Is Untrusted
| Rule | Practice |
|---|---|
| The model never owns identity | User, tenant, and persona come from the authenticated session, composed server-side |
| The model never invents queries | Server tools run documents from a compiled allowlist; hashes rechecked at execute |
| The model never mutates alone | Mutating tools require a prior user form submission naming the same operation |
| The model never sees raw PII | Messages and page context are scrubbed before prompt or audit store |
| The model never sets ACLs | Tools advertise/execute only if the role snapshot allows; empty snapshot denies |
If a design made the model more "agentic" at the cost of one of these, we threw it out.
Two Kinds of Tools
Server tools run in the BFF. Args are schema-validated twice (registry + executor), ACL-checked, timed out, and normalized to { status, summary, data } before re-entering the prompt. Results are truncated; the model gets enough to answer or choose the next hop. Summaries land in short-lived session memory for follow-ups ("list them", "open the first one") without re-fetching everything.
Client tools are descriptor-only. The BFF validates args and emits a structured action — navigate, click, open UI, present a form, offer a download. The browser does the work. No parallel "let the model drive the SPA with arbitrary JS."
Capability 1 — Navigation as a First-Class Action
"Open reports" / "show approved expenses" should feel like a smarter goto.
- Intent leans navigation for imperative open/go/show-page phrasing.
- The turn advertises a small client-tool set (navigate, UI, clicks, form present) — not the full data catalog.
- The model emits a navigate/UI call with a path the BFF validates as in-app.
- The orchestrator emits an action chunk; the SPA routes; the loop ends.
Filtered navigation. Playbook teaches when to attach query params (status, geography, date) vs open unfiltered and ask. Ambiguous filters fail soft — closest page or ask; don't invent a deep link that 404s.
Page context and click targets. The model can choose a page click or UI action instead of a full route change. BFF checks args; SPA resolves against the last collect. Hallucinated button ids get rejected (BFF retry and/or FE soft failure).
Search-then-navigate. "Find Jane Doe": server lookup → summarize matches → client navigate from a real id in the tool result. The model never fabricates a detail path from a name. List-page context can power "open the first vendor" without another search. Lookup, then act.
We deliberately skip regex fast-paths for known phrases. Route tables misfired on soft phrasing, prior-turn context, and "open X filtered to Y." Intent gating stayed; deterministic short-circuits did not.
Capability 2 — Lookups and Multi-Step Answers
Questions like "how many pending approvals?" lean data lookup. The BFF advertises a ranked slice of read tools scored by playbook against the message — not the entire catalog.
LLM → tool-call (read)
BFF → validate + ACL + execute allowlisted read
BFF → append "tool ok — <summary>" + truncated data to the running prompt
LLM → either answer in text, or call another tool / navigateSecond hops that feel agentic: count → prose; list → filtered navigate; search → detail navigate; domain lookup insufficient → analytics fallback (below).
Session tool memory stores short summaries keyed by session — a hint in the system prompt, not a second source of truth that bypasses ACL.
Read tools are documentation-shaped catalog entries compiled at boot: allowlisted document, args schema, server-side auth bindings, result masking, playbook fragment (when to use / when not to / examples / suggested navigation). Adding a lookup is mostly a catalog change. Tool-picking failures in evals were rarely "the model is dumb" — usually two neighbouring tools sounding the same. Tightening whenNotToUse fixed more misfires than swapping models.
Capability 3 — Schema-Driven Forms (Writes With Consent)
"Create an expense for me" is not consent. The agent may start a write; a human finishes it.
Dual-path UX
Ambiguous create intent often gets a short reply offering:
- Full page — existing create screen (richer, attachments, edge cases).
- Quick in-chat form — schema-driven form for the common case.
Jumping straight into either frustrates someone who wanted the other. The agent asks (or the user already said "in chat" / "open the form").
The form round-trip
Turn N
User: "create … in chat with the quick form"
LLM → presentForm({ which mutating tool, title, … })
BFF → validate mutationName is a registered mutating tool
→ emit action(kind: form, mode: collect, schema copied from the tool)
FE → renders auto-form from schema; user fills fields and submits
Turn N+1
FE → new chat turn + form-submission context { mutationName, values }
BFF → prompt includes form-submission guidance
LLM → tool-call the mutating server tool with the submitted values
BFF → consent gate: submission must target this same mutation
→ compose auth-derived fields server-side (model never sets them)
→ execute
LLM → often a follow-on navigate to the created record
BFF → emit navigate action + doneThree layers of the same idea: presentForm only accepts a registered mutating tool; the consent gate refuses mutating calls without a matching user submission; catalog registration fails boot if operation kind disagrees with the mutating flag.
Dynamic form fields
Persona-scoped options (categories, contracts, leave policies) resolve server-side from allowlisted read tools when the form is enriched — not invented by the model. presentForm args stay tiny (mutation, title, description); which dropdown resolver runs is config at compile time. Enrichment failure fails closed — text fallback over an unrenderable form.
The model never sets identity fields, never executes a mutation off prompt text alone, never round-trips free-form HTML into stored rich-text without a server-side escape. Writes are where "agentic" is most tempting and least appropriate.
Capability 4 — Reports and Analytics Fallback
Dedicated catalog tools cover common questions. Ad-hoc asks (compare spend, CSV of approvals, cross-module questions) hit a small analytics fallback — still a server tool, ACL-gated (reports read), kill-switchable, backed by a separate analytics service:
| Shape | User experience | Constraint |
|---|---|---|
| Answer in chat | Rows scrubbed; model composes a short reply | "Who is asking" composed server-side, never from model args |
| Downloadable report | Frontend gets a download action | Pre-signed URL is not echoed into the transcript |
Self-service personas can only ask about themselves; another user's id hard-fails. The upstream question is self-contained — no full chat history to analytics. Optional "asked for" entity hints are validated; they don't spoof identity.
In the loop this is often a fallback hop: domain tool unfit → analytics → text or download. Playbooks must say when not to use it, or every fuzzy question becomes a slow analytics trip. Eval failures like "time off summary this year" landing on analytics instead of a domain tool → fix the playbook, don't ban the fallback.
Capability 5 — Attachments and Vision (Side Path)
Receipts/PDFs short-circuit into a vision-capable model for extraction, then land back in the same action vocabulary (prefill, navigate, clarify). Same sanitizer, same audit, same rule that extracted card data is not cheerfully parroted back. In a pay platform the scary part is what the pixels contain, not whether the model can OCR.
Page Context, Memory, and Continuity
Four inputs for multi-turn coherence without a blank check:
- FE page-context registry — global + per-page/form/table producers, deny-listed and capped, sent explicitly each turn.
- BFF sanitizer — second pass before prompt build; PAN hard-fail; stable redaction tokens for audit.
- Tool memory — short Redis summaries of prior server-tool returns (enough for "open the first match," not enough to reconstruct PII).
- Message history — capped in count/size; scrubbed; never a channel for identity overrides.
Idempotency keys prevent double-running a turn. Rate limits are per user. Disconnect cancels work in flight.
Keep the Prompt Small on Purpose
Early versions advertised too many tools every turn — worse answers, higher latency and cost. We classify intent lightly and advertise a slice:
| Intent skew | What gets advertised |
|---|---|
| Navigation | Client tools for route / UI / click / form present |
| Data lookup | Top-N playbook-ranked read tools + relevant module tools |
| Filtered navigation | Core nav only — open closest page or ask which filter |
| General | Core nav + module tools if a module match exists |
The FE can hint; the registry remains authoritative. Empty FE lists still get a curated subset — never the entire catalog.
PII Before the Prompt
Every user message, page-context blob, and assistant reply headed for the model or audit DB goes through:
- Synchronous deny-list / regex — cards, government IDs, known sensitive keys. A PAN-shaped value hard-fails before any prompt is built.
- Async redaction — emails, phones, IPs → stable placeholders so the same value maps to the same token across prompt and audit.
We disabled name NER on palette traffic — "Open reports page" is not a person; false positives hurt more than they helped. Structured name fields still hit the deny-list.
Logs get the same sync pass plus a path deny-list. Stack traces never leave in API responses. Audit rows encrypt message bodies and tool args at rest; dashboards query masked/derived fields.
Eval Like You Mean It
Unit tests miss "wrong page for 'show approved expenses'" or "create in chat" skipping the form. We evaluate against the real orchestrator:
- Tool correctness — expected tool/action shape (navigate path, form present, read-tool family, text-only disambiguation, …)
- Answer quality — with backends stubbed: safe, useful, on-policy?
Stub the data plane — you want routing, consent, and refusal, not production data. Treat playbook drift as a bug — overlapping tools → fix catalog copy, don't loosen assertions forever. High-nineties pass rates look great; the useful artifact is the remaining ambiguities. Those drive the next catalog edit — how navigation filters, form dual-paths, and analytics fallback rules actually improve.
Worked Scenarios (Composite)
Illustrative shapes, not product transcripts.
"Open the reports page."
Navigation slice → client navigate → SPA routes → done. One model call, no server tools.
"How many pending expense approvals?"
Data-lookup slice → server count → summary → text answer. Follow-up "list them" reuses tool memory or lists + navigates.
"Find the employee named …"
Search → masked matches → navigate using an id from the tool result. No id, no detail URL.
"I want to create an expense."
Text disambiguation (full page vs in-chat). In-chat → presentForm → submit → consent-gated mutation → navigate to the new record.
"Export approved expenses for last quarter as CSV."
Analytics download → download action → URL stays out of the transcript. ACL must allow reports read.
"Open the create dialog on this page."
Global surface stamped click targets → UI/click action → SPA resolves stamped id (navigating first if needed) → real DOM click. Stale target → soft failure or BFF rejection → fallback navigate or ask.
User on a list: "open the second one."
Per-page list registration shared visible row ids → navigate from that snapshot (or re-query if thin) → done.
What I'd Repeat
- Trust boundary in a BFF — neither browser nor model holds a privileged client.
- SPA shares declared page context via a registry, not a raw DOM dump.
- Sanitize leaving the page and entering the prompt.
- Navigation, clicks, forms, downloads, lookups as typed outcomes the FE applies.
- Allowlisted, config-authored read tools over generated queries.
- Explicit user submission for writes; dual-path UX when the full page is richer.
- Advertise fewer tools per turn than you think you need.
- Loop for lookup-then-navigate and form-then-confirm — hard-cap the depth.
- Eval routing continuously; prompt and playbook changes are product changes.
What I'd Still Tighten
Graceful degradation (mock LLM, no-op audit, optional analytics) made local work pleasant, but production wants hard fail-fast on missing security config — empty CORS allow-lists and missing session stores should refuse to boot. Circuit breakers and retries around the model provider are table stakes. Dynamic form options should stay config-declared. Every new mutating workflow should stay schema-driven: if a write path needs a one-off TypeScript escape hatch, the catalog DSL isn't finished.
An in-app assistant feels agentic when SPA and BFF share a vocabulary: the page says what it can see, the model proposes, the BFF validates, and the client applies navigation, clicks, forms, and downloads. It stays trustworthy when every path is short, allowlisted, and — for anything that changes state — waiting on a human.