In-app AI assistant — Victor Agnew

Victor Agnew

Staff AI Platform Engineer · Vancouver, BC

Boring deploys. work/ Safe AI. ai-lab/ Happy teams. virtual-office.md

PIPELINE
>20 min → <5 min
DEPLOYS
weekly → hundreds/week
FAILED PIPELINES
<1%
CREDENTIAL ROTATION
6 months → 24 h
REVIEW ENV
~$9/day
GCP SPEND
−40% YoY
MCP TOOLS
160+ → 23
VIRTUAL OFFICE
~1 h 40 min
virtual-office.md Virtual office three.js · Cloudflare Workers · LiveKit Built in a day; 26 days later, 1,600+ commits and 12 coworkers committing.
Close case-studies/ai-assistant.md Back

case-studies/ai-assistant.md

In-app AI assistant

An assistant inside the ArborXR portal that answers questions about a customer's device fleet, built on the company's first production MCP server.

ArborXR · Staff Platform Engineer

MCP tools
160+ → 23
Chat panel bundle (gzip)
154 → 50 kB
Tools run as
the signed-in user
  • Laravel
  • Laravel MCP
  • Claude
  • Anthropic PHP SDK
  • FrankenPHP
  • AG-UI
  • TanStack AI
  • Preact
  • TypeSafe Jev

The problem

Customers manage their device fleets in the ArborXR portal. AI agents can answer questions about that data through an MCP server. The starting point was a 1:1 mapping of the OpenAPI spec: more than 160 endpoints, each its own tool.

MCP only helps people who can wire it up. The assistant was aimed at non-technical users, and at regulated, education and enterprise IT customers who can't sign up for third-party LLMs or wire up MCP themselves. They needed answers inside the portal, with no setup.

What I built

A curated MCP server

I built the company's first production MCP server, on Laravel MCP. I replaced the 1:1 mapping with 23 hand-curated tools, so agents reason better. Every tool is read-only. Every call runs as one user in one organization, resolved from that user's access token.

[ FIG. 1 ] From one tool per endpoint to 23 hand-curated tools
From one tool per endpoint to 23 hand-curated tools Two panels side by side. Left panel, titled "1:1 OpenAPI mapping": a dense grid of 160 small identical cells (16 columns × 10 rows) followed by a "+", labelled "160+ endpoints, one tool each". A wide arrow from left to right labelled "hand-curated". Right panel, titled "MCP server (Laravel MCP)": 23 larger cells in seven labelled clusters: Devices (5), Apps (3), Content (3), Groups (3), Users (3), Tags (2), Analytics (4). Under the right panel, in mono: "read-only · one user, one organization". 1:1 OpenAPI mapping + 160+ endpoints, one tool each hand-curated MCP server (Laravel MCP) Devices Apps Content Groups Users Tags Analytics read-only · one user, one organization
Tools in-process, as the user

Every vendor reference architecture I checked runs an assistant's tools in-process, under the caller's own identity, with no separate identity service. So the assistant lives inside the portal and calls the MCP server's tool classes directly, with no HTTP hop. Its tools run with the signed-in user's own permissions. Each request builds its own tool list from those permissions and never shares it with another request.

[ FIG. 2 ] One set of tool classes, two ways in, always as the signed-in user
One set of tool classes, two ways in, always as the signed-in user A large box labelled "Portal". Inside it on the right, a box "MCP tool classes (read-only)". Inside it on the left, two boxes stacked: "MCP endpoint" (top) and "Assistant" (bottom), each with an arrow to the tool classes; both arrows cross a thin vertical bar labelled "the user's permissions". Outside the portal box on the left: "AI agent", with an arrow into "MCP endpoint" labelled "access token → user + organization", and "Chat panel", with an arrow into "Assistant" labelled "session → user + organization". Outside the portal box on the right: "Claude", with a two-way arrow to "Assistant" labelled "stream a turn · tool calls". A small note on the Assistant → tool classes arrow: "in-process, no HTTP hop". AI agent Chat panel Portal MCP endpoint Assistant the user's permissions MCP tool classes (read-only) access token → user + organization session → user + organization in-process, no HTTP hop Claude stream a turn · tool calls
Streaming past PHP's time limit

Answers stream in as they're generated. The portal runs on FrankenPHP, where Octane sets a 30-second execution limit and FrankenPHP re-arms it for every request as wall-clock time. An idle stream and a busy loop both died at 30 seconds, and a turn with tool calls can run longer. A bare set_time_limit(0) leaks: every later request on that worker thread runs unlimited until the worker recycles. So each turn lifts the limit for itself and restores the previous value in a finally block. Lifted streams stayed open for three minutes through the network path, so the cut was PHP, not the ingress.

The text still arrived in bursts, because Guzzle read Claude's stream in blocks. In a probe, 67 text deltas reached PHP at two distinct times. After I swapped in Symfony's HTTP client, 72 deltas arrived at 29 distinct times, and the first came at 1.5 seconds instead of 4.0.

AG-UI events into a Preact island

The portal's frontend is Blade, Livewire and Alpine, with no React or Vue. The chat panel is a Preact island inside a Livewire component, with TanStack AI managing the chat. The first version used React. After review I moved it to Preact and TanStack AI, which cut the bundle from 154 kB to 50 kB gzipped. Mingle hosts the island, but its JavaScript only supports React and Vue, so I wrote a typed Preact bridge.

Each turn is one POST, and its response is the stream: AG-UI events over server-sent events. A run opens with RUN_STARTED, streams text and tool calls, and ends with RUN_FINISHED or RUN_ERROR. Laravel's eventStream() ends with a line that isn't JSON, and the client throws on it, so the endpoint writes raw frames. The client also never reads the body of an error response, so a turn's errors arrive inside the stream as RUN_ERROR events with a code.

[ FIG. 3 ] One turn is a single POST, and its response is the stream
One turn is a single POST, and its response is the stream Sequence diagram with three lifelines: "Chat panel (Preact + TanStack AI)", "Portal", "Claude". 1 Chat panel → Portal: "POST turn (AG-UI RunAgentInput)". 2 Portal → Chat panel: "RUN_STARTED". 3 A box on the Portal lifeline: "input guard (pluggable · Jev screening)". 4 Portal → Claude: "stream the turn". 5 Claude → Portal: "text deltas"; Portal → Chat panel: "TEXT_MESSAGE_START · TEXT_MESSAGE_CONTENT …". 6 Claude → Portal: "tool call"; Portal → Chat panel: "TOOL_CALL_START · TOOL_CALL_ARGS · TOOL_CALL_END"; a self-loop on Portal: "run the tool as the user"; Portal → Chat panel: "TOOL_CALL_RESULT"; Portal → Claude: "tool result". 7 Claude → Portal: "answer text"; Portal → Chat panel: "TEXT_MESSAGE_START · TEXT_MESSAGE_CONTENT … · TEXT_MESSAGE_END". 8 Portal → Chat panel: "RUN_FINISHED", with "or RUN_ERROR {code}" in smaller text beneath it. A bracket along the Portal lifeline from step 2 to step 8: "time limit lifted for this turn, restored in finally". Chat panel (Preact + TanStack AI) Portal Claude time limit lifted for this turn, restored in finally 1 2 3 4 5 6 7 8 POST turn (AG-UI RunAgentInput) RUN_STARTED input guard (pluggable · Jev screening) stream the turn text deltas TEXT_MESSAGE_START · TEXT_MESSAGE_CONTENT … tool call TOOL_CALL_START · TOOL_CALL_ARGS · TOOL_CALL_END run the tool as the user TOOL_CALL_RESULT tool result answer text TEXT_MESSAGE_START · TEXT_MESSAGE_CONTENT … · TEXT_MESSAGE_END RUN_FINISHED or RUN_ERROR {code}
Guardrails

Pluggable guardrails screen input for jailbreaks and abuse. The guard is a seam with interchangeable drivers. The screening driver sends each message to TypeSafe's Jev model before the turn runs, and asks three questions: is this a jailbreak attempt, a harmful request, or abuse? Switching drivers is configuration; the rest of the flow doesn't change.

Refusals

Claude can also refuse. A refusal comes back as a normal response, and Claude's documentation says the conversation keeps refusing unless the refused turn is removed. At first the refused turn was stored and replayed, which poisoned the conversation. Now the whole turn is deleted, and the user is told the assistant can't help with that request.

Results

  • In October 2026 I shipped an assistant that answers questions about a customer's device fleet, inside the ArborXR portal.
  • The MCP server replaced a 1:1 mapping of 160+ endpoints with 23 hand-curated tools.
  • The assistant's tools run with the signed-in user's own permissions.
  • Answers stream in as they're generated.
  • Pluggable guardrails screen input for jailbreaks and abuse.
next case study Zero-credential agents