# ANALYTICS_QUERY Isn't a State: Plugging an LLM Into a WhatsApp FSM

> Source: <https://dev.to/paulmurithi/analyticsquery-isnt-a-state-plugging-an-llm-into-a-whatsapp-fsm-2al8>
> Published: 2026-10-18 16:13:30+00:00

*SokoFlow AI Build Log, Week 3 of 6: intent vs state, a routing policy I didn't plan for, and the latency number I owed you from Week 2*

The spec said: *add the `ANALYTICS_QUERY` intent to the existing FSM.* One line.

I spent days on it. The code wasn't hard. I just couldn't tell whether `ANALYTICS_QUERY` was an intent, a state, or something else, and every wrong answer would have broken a different part of a system that already worked.

The answer turned out to be "mostly neither", and finding it is what made the rest of the week simple.

**TL;DR**

- Week 3 connected last week's loop (
`LLMClient`, `ToolRegistry`, `ToolExecutor`, plus the Orchestrator) to the existing webhook and FSM, and wired the remaining four tools.
`ANALYTICS_QUERY` is handled in IDLE's intent dispatch, not registered as a `SessionState`. Nothing ever *waits* in an "analytics" position. The one exception is product disambiguation, which is a real parked state.- I kept
`Intent.UNKNOWN` and added a routing policy above the resolver. It leans toward sending messages to the LLM and lets the model say "no tool".- Follow-ups like "and yesterday?" are
*context*, not *state*: I store the last validated tool call and let the model propose a new one.- Closing Week 2's gap: end-to-end p50 of 1.20 s and p95 of 2.27 s on 7 requests. That's a diagnostic baseline, not a production number.

SokoFlow is a headless WhatsApp ERP for Kenyan SMEs. I've been building it for about five months. There's no app and no UI to learn; for a shopkeeper, the conversation is the interface. Phase 3 adds one AI feature: natural-language shop analytics. A shopkeeper asks "which products are running low?" or "how does this week compare to last week?", and an LLM, using function calling against SokoFlow's existing services, figures out which query to run.

[Last week](https://dev.to/paulmurithi/the-gateway-the-registry-and-the-executor-keeping-groq-at-the-edge-of-my-backend-18jp) I built the core function-calling loop:

`LLMClient`:` ToolRegistry`:` ToolExecutor`:
This week the job was to connect all of it to the real conversation, so that a shopkeeper's message can enter through the webhook and flow through the existing finite state machine (FSM).

First wall: where do I plug this in without rebuilding everything? The spec's Week 3 target read:

Add the `ANALYTICS_QUERY` intent into the existing FSM/webhook. Handle session context so a follow-up question ("and yesterday?") can reuse prior context. Test via your existing GUI/CLI simulators.

It wasn't as simple as adding an intent, so I first had to retrace how the current system handles a message. Every incoming message enters through the webhook and is enqueued to the `conversation_task` queue, where a worker picks it up and runs this sequence:

`fsm_engine.process_message()`.
`SessionState` → a handler that produces an `FSMResult`.` render_message(...)`, using the shop's locale and the message key.` ANALYTICS_QUERY`: intent or state?
The spec calls it an intent, and that confused me while I was looking for where to integrate it. Making it a `SessionState` would put it on the same level as our existing FSM states, like `ADD_PRODUCT_PRICE` or `CHECK_STOCK`, and wire it into the handler map:

```
self._handler_map: dict[
    SessionState, Callable[[UserSession, str], Awaitable[FSMResult]]
] = {
    SessionState.ADD_PRODUCT_NAME: self.add_product_flow.handle_name,
    SessionState.ADD_PRODUCT_PRICE: self.add_product_flow.handle_price,
}

# Get the correct handler based on state. Default to IDLE.
handler = self._handler_map.get(session.state, self._handle_idle)
```

But that approach didn't fit, and it forced me back to the difference between the two ideas:

An entry in that handler map means "the session is waiting here, and this handler receives the next message." Analytics never waits. A question arrives in IDLE, gets fully processed, and the session is IDLE again. An `ANALYTICS_QUERY` entry in the map would be misleading, because nothing ever parks there.

So analytics belongs in the intent-dispatch step that IDLE already does. That first clarification made everything after it simpler.

`UNKNOWN` vs SokoFlow has one special intent. When the resolver can't confidently classify a message, it produces `Intent.UNKNOWN`, and the system re-prompts the user with the available options.

That's exactly what happens today if a shopkeeper types something like "How much did I sell last week". The regex-based resolver can't match a message that free-form to an intent.

Now that analytics routes unclassified messages to the LLM, why keep `UNKNOWN` at all? Why not delete it, or keep it and send everything in it to the model? I decided to keep it. `UNKNOWN` means "no regex matched", which is a fact about the resolver. "Therefore send it to the LLM" is a routing *policy*, and policy belongs above the resolver. My reasons for keeping them separate:

Notice that I just introduced a routing policy. That's a component I'll come back to.

I was settled on analytics being purely one-shot. Then ambiguity contradicted that.

SokoFlow already has one disambiguation branch. Users write in natural language, so we have to map their words to something the backend understands. A shopkeeper checking stock simply says "Milk". But a shop can realistically have Milk 250ml and Milk 500ml, which are different products with different prices. So the system runs a database-backed fuzzy match and asks the user which one they meant.

That's a genuine parked state: the flow pauses, goes back to the user, and continues when they reply. So that case belongs in the handler map, as a state for picking from candidates.

The overall idea:

With that settled, I knew where the new flow plugs in. The updated flow:

`ResponseFormatter` produces `FSMResult(message_key, params)`, and the handler also records the new last-call context.` render_message` renders the text in the shop's locale.
Not a lot changed, and that was the beauty of it. But it did involve a few new components.

In step 2 I mentioned an "analytics handler", and for a while that name confused me, because my mental picture of a handler was the handler map keyed by `SessionState`.

Analytics is different. The IDLE handler calls the analytics handler directly, gets an `FSMResult` back, and the session stays IDLE. Nothing needs the map, because no future message needs special interpretation. So the analytics handler is simply a *collaborator* of the IDLE handler, the way a service is, much like the existing `stock_lookup_flow`. It has two kinds of entry point:

So "handler" in the handler map and "handler" in the analytics design were two different roles sharing a name.

This one felt odd at first. SokoFlow already has `render_message` for i18n, so I assumed that was enough. Why add a component?

`message_key` (summary, top-N list, trend, or empty result) and builds the params: currency and percentage formatting, date display, truncating a long list to fit a WhatsApp message, and so on.
This is also where I locked in a decision I flagged back in Week 1. In most tool-calling tutorials, once the business logic runs, the result goes back to the LLM to write a friendly reply. I'm deliberately not doing that. The LLM stays out of the path that produces numbers. That means no hallucinated figures, no wording that drifts between locales, and no second model call adding latency and cost.

The boundary: **the LLM routes and extracts arguments; application code owns everything the shopkeeper reads.**

By now there were many components, and it helped to see how they relate and how control flows. I designed them as nested layers, where each layer only knows the one directly below it:

```
Worker
 └─ FSM engine
     └─ Analytics handler        (knows: session, FSMResult, Orchestrator, Formatter)
         └─ Orchestrator         (knows: LLMClient, ToolExecutor)
             ├─ LLMClient        (knows: the model provider)
             └─ ToolExecutor     (knows: the tool registry, domain services)
```

Control flows down and results flow back up. No component calls upward, and none skips a layer. That separation paid off during implementation, especially in unit tests, because every component was isolated.

I introduced the Orchestrator [last week](https://dev.to/paulmurithi/the-gateway-the-registry-and-the-executor-keeping-groq-at-the-edge-of-my-backend-18jp), and it was the missing piece that stopped me from measuring a full end-to-end loop. This week it comes to life.

This is the part my earlier diagram hid. The Orchestrator runs a small sequence and owns every decision in it:

I didn't foresee this component, and it turned out to be one of the most important parts of the integration.

The distinction is about *which layer decided*. The resolver says `UNKNOWN`. The policy then decides whether to escalate that message to analytics. So where does the policy live?

**Option A: route inside `_handle_idle()`.** When the resolver returns `UNKNOWN`, the FSM invokes the analytics handler instead of immediately raising `InvalidInputError`.

**Option B: route after `process_message()` returns.** The worker sees `MessageKey.UNKNOWN_INTENT` and decides whether to invoke analytics.

I chose Option A, because routing is a decision about what a message *means*, and that already belongs to intent dispatch in IDLE. Option B was worse than it looks. By the time `process_message()` returns `UNKNOWN_INTENT`, the FSM has already treated the message as an error:

For the worker to then invoke analytics, it would have to undo or override all of that. It would also need a *second* session update (the last-call context) after the first, which means either two CAS saves or a worker editing another component's result. Both break the uniform pipeline (retrieve, process, save, render, send), and the worker would start carrying domain knowledge about when "unknown" really means "analytics".

One thing worried me. The policy looked like it needed to act as a classifier, to make sure routing decisions were accurate. But deciding whether a message *is* an analytics question is an intent-classification problem, which is the exact hard thing I was trying to hand to the LLM.

It doesn't have to be. The policy only decides whether a message is *allowed and worth sending* to the LLM, which is a much easier question.

I also resisted the tempting keyword prefilter: "message contains sales, stock, today…". Shopkeepers write in English, and often mix in Swahili and Sheng. A keyword list would silently reject legitimate questions, in ways that are hard to notice.

The two possible mistakes have very different costs:

| Mistake | Consequence | Severity | 
|---|---|---|
| Escalate a non-analytics message | One model call. The LLM returns no tool call, the user gets the normal unknown reply, and the counter increments. | Low, and the outcome is the same one they'd have had anyway | 
| Fail to escalate a real analytics question | The user gets "I didn't understand" for a question we could have answered | Higher, and it erodes trust in the feature | 

So the policy should **lean toward escalating and let the LLM reject.** The cost argument for gating is also weak here: at roughly $0.00005 per call with the model I picked in Week 1, a wasted call is almost free. The real reasons for having a policy are rate limiting, availability, and keeping the feature switchable.

At this point there's a solid entry point and a routing policy. Next came multi-turn follow-ups. Minutes after asking "How much did I sell today?", the shopkeeper types: "And yesterday?"

Without context, that's impossible to answer. I took inspiration from the MVP's ambiguous-product logic, which stores candidates in the session context and uses them when the user replies with their choice. This problem is similar, and the framing that came out of it is: **follow-up memory is context, not state.** "And yesterday?" arrives with the session in IDLE, gets escalated by the routing policy because fresh context exists, and the model resolves it against the previous request.

The decisions:

**Disambiguation is a real parked state.** When the outcome is `NeedsSelection`, the session parks in an analytics product-selection state, registered in the handler map with the same shape as the existing stock-lookup selection state:

Last week I wired the first three tools (`get_sales_summary`, `get_stock_level`, `get_top_products`) through the executor but not into the WhatsApp flow. This week I wired the remaining four: `get_low_stock_items`, `get_product_price`, `get_sales_trend` and `get_slow_moving_items`.

I also came back to Week 2's latency target, which I'd postponed until the WhatsApp integration existed. Instead of benchmarking a pile of temporary workarounds, I built a repeatable benchmark around the integrated request path.

It uses the real Groq API (`openai/gpt-oss-20b`), the application's actual tool executor and services, and a controlled test environment. It measures the time from entering the conversation handler to producing a formatted reply at the outgoing-message boundary, excluding actual WhatsApp delivery.

In my first run, all seven analytics queries succeeded, with no timeouts and no incorrect tool selections. The numbers:

That closes the Week 2 requirement to measure and record p95. But seven requests are enough for an initial diagnostic baseline, not a statistically reliable estimate of production performance. The numbers also reflect my current test conditions, including a fairly limited internet connection. I'm treating this as a baseline to guide further testing, not a production latency figure.

We're past the halfway point. Week 4 is robustness and guardrails: fallbacks for unresolved queries, per-shop rate limiting, and cost-cap enforcement. Then the interaction logger, so failures are diagnosable and the system is more observable.

Read more: [the 4-month MVP build log](https://dev.to/paulmurithi/series/41476)

Check out the repo: [SokoFlow on GitHub](https://github.com/paul-murithi/SokoFlow)
