The problem
I'd already built a workflow that emails a weekly report on support ticket data — volumes, categories, resolution times, themes. It works, and it runs without anyone asking.
But it answers the same fixed set of questions every time. The moment someone wants to know something it wasn't designed for — how many refund tickets are still open, what's taking longest this month — you're back in the workflow editor changing nodes.
A report is a push. Some questions need a pull. So I built the other half.
What I built
A chat interface over the same 100-ticket dataset. You ask in plain English and get an answer in a few seconds:
"How many settlement issues are still pending?"
"What's the average resolution time for refund tickets?"
"How many refund tickets are there?" → "How many of those are closed?"
Every count comes back with the supporting ticket IDs, so any answer can be verified in about ten seconds rather than being trusted.
How it works
Chat trigger
└─ AI Agent
├─ Gemini 3.5 Flash the model
├─ Simple Memory conversational context
└─ Stats sub-workflow the only tool
├─ Google Sheets all 100 rows
└─ JavaScript node every figure, computedThe design decision that matters: the model never does arithmetic. A JavaScript node computes counts by status and category, the cross-tab of the two, averages, medians, and the supporting ticket IDs for every combination. The agent receives finished numbers and puts them into a sentence.
I verified that node's output against the raw spreadsheet before connecting it to the agent — total tickets, status breakdown, average resolution time. All matched. So the numbers were known-correct before the model ever touched them.
The prediction I got wrong
My spec predicted the model would miscount. That wasn't a guess — I'd seen it in the earlier report workflow, where the same ten rows produced "10 tickets, average 3.2 days" on one run and "9 tickets, average 2.5" on the next. So I wrote the risk into the PRD and planned a fallback.
Then I tested it properly, against figures I'd calculated independently. It was right. Every count, every average, stable across sessions and across a page reload.
At one point I was convinced it had invented a figure — 4.96 days — and I had what looked like a proof it was impossible. The proof was wrong. I'd taken a maximum value from the spec that applied only to resolved tickets and treated it as a global ceiling. The model's number was correct; my analysis wasn't.
I only found that out because I'd computed the ground truth in code. Verify the accusation as rigorously as the claim.
The failure that was real
The actual problem was speed. Twenty seconds a question, because all 100 rows — including full descriptions and comments — went into the prompt on every turn.
Obvious fix: split into two tools. One for statistics, one for raw lookups. Route between them.
The agent called both. Every question, every time.
AttemptFixResult1Routing rules in the system promptboth tools called — 10.5s2Explicit "call exactly one tool" instructionboth tools called — 8.4s3Hard prohibition in the tool's own descriptionboth tools called — 22.6s
Attempt 3 is the interesting one. n8n's guidance is explicit that tool-specific instructions belong in that tool's description, not the system message. I put it exactly where the documentation says. It made no difference.
Three prompt-level fixes, three failures, each one slower than the last — all to solve a correctness problem that, as it turned out, didn't exist.
So I stopped prompting and changed the shape of the system: fold the row data into the stats tool, delete the second tool entirely. With one tool there is no routing decision to get wrong.
You can't prompt your way out of an architecture problem. Give a model two plausible options and it will take both. Removing the choice worked; arguing with it three times didn't.
Results
DesignResponse timeTokensSingle Sheets tool8.2s10,969Two tools, prompt-routed22.6s13,310One tool, stats + trimmed rows4.0s9,361
All nine acceptance tests written before the build now pass, including the two hardest:
Context retention — "how many of those are closed" correctly resolved to refund tickets.
Honest refusal — asked which agent handled a ticket, it named the missing field rather than inventing a person. It did the same when asked about a field I'd deliberately removed.
When the stats tool was briefly unavailable, the agent said it couldn't answer rather than falling back on guesswork.
Limitations
Named rather than hidden:
Every row enters the prompt on each call. This doesn't scale past a few hundred tickets without giving the tool filter parameters.
Runs locally with no authentication.
The dataset is synthetic — generated for this build, modelled on payment support categories.
Free-tier rate limits (20 requests/minute) became the practical constraint during testing, not model quality.
What I'd do next
An evaluation set. 15-20 questions with known answers, run automatically, pass/fail reported. Right now correctness is verified by hand, which doesn't survive a change to the prompt or the model.
Filter parameters on the tool, so it retrieves what a question needs instead of everything.
A hosted frontend with auth — currently a webhook swap plus real security work, which is a project of its own.
Artifacts
PRD — written before the build: problem, non-goals, functional requirements, risk table, acceptance criteria
Build log — every decision, the corrections to my own analysis, and the performance comparison
The stats Code node
The two workflows are designed as one system: the same deterministic statistics, served two ways. A report nobody has to ask for, and a chat for the questions the report didn't anticipate.
