building vodou.

Your tool router can't tell a request from a sentence about one

Keyword routing to MCP tools fires on words buried in prose. What broke while building plain-language routing, the invariant that fixed it, and a 5-minute check.

Chad Priest / / 9 min read

If your agent has more than a handful of MCP tools, you have already built a router, even if you never called it that. Somewhere a user’s sentence turns into a tool name. Most stacks do this in one of two ways. Either the model reads every tool description and picks one, or a lookup table maps phrases like “send email” to a tool and skips the model. Both work in the demo. Both break the same way in production. They can’t tell a person asking for work apart from a person talking about it.

I wrote the prompt that commissioned this post as a long paragraph of instructions. My own router matched ten registered keywords in it: “performance”, “research”, “recall”, “react”, “docs”, “type”, “click”, “debug”, “seo” and “send email”. I asked for none of those things. Every one of those words sat inside a sentence describing something else. None of them ran. Getting to that point took longer than building the router did.

Ten keywords matched this post’s own brief and zero tools fired

I built this into Vodou, a local-first AI system with persistent memory and MCP tool orchestration. For a person using it, the capability is simple. You write a sentence in chat or at the command line. It maps to the right skill or MCP tool, independent calls run in parallel, and it holds back when the sentence only describes work.

It has four stages. First, a skill wins if one matches, because a skill is a reviewed, ordered procedure and a raw tool call is not. Next comes a stored mapping table (keyword, server, tool) as the fast path. When the table has nothing, a semantic match runs over every server’s tool descriptions. After a candidate is chosen, two gates decide whether it actually runs. The first asks whether the keyword is the request itself or a word inside prose. The second asks whether the tool sends, writes or spends anything.

no skillno mappingis the requestinside proseread-onlyside-effectingUser sentenceSkill matchskills win outrightMapping tablefast pathSemantic matchover tool descriptionsProse gaterequest or description?Side-effect gatesends, writes, spends?Run, in parallelHold as a hint witha reasonA match is not the same as permission to run.
From a sentence to a tool call

Infographic: same brain, it can also act. Skills, MCP tools, automations and workflows with approvals.

A held match isn’t thrown away. It gets passed to the model with a reason attached, “matched inside prose”, “side-effecting” or “skill route”, so the model can make the call with the full sentence in view. The router’s job ends at proposing a candidate. It doesn’t get to decide the request on its own.

“screenshot” in “tell me what to do for each screenshot” was not a request

The first version was a plain substring match against the mapping table. It was fast and felt precise. Then someone pasted a set of images with the note “tell me what to do for each screenshot”, and the router took a screenshot. The keyword was real, the tool was real and the mapping was right. The sentence just wasn’t a command.

That one is harmless. Swap in “email” and it isn’t. A mapping that fires on a keyword in a paragraph like “I was going to send email to the team about this, but first…” sends the email. So the side-effect gate went in as its own gate, not as a stricter version of the prose gate. The hook refuses to auto-fire anything that sends, writes or spends. Even a clean match only gets surfaced.

The second thing I got wrong was scale. My assumption was that better general routing would make the product safe to sell. Across every connected server, the router was choosing one tool out of 942. No match heuristic is good at that, and I kept tuning it. What changed my mind was noticing that the dangerous calls were a tiny set. When I bounded the writes, the decision that mattered shrank to one of six.

general routing942 toolsbounded writes6 tools
Candidates the router must choose between

General routing can stay imperfect now. A wrong read-only call costs a few seconds. A wrong write costs trust, and those calls now come from a list short enough to reason about.

The third failure was the embarrassing one. The rules file that tells the coding agent how to read these hints quoted two strings the hook had never emitted. The docs said to look for one parenthetical. The code wrote a different one. So the agent was told to skip matches that were labelled in a way it would never see. Nothing crashed. The hints just quietly meant nothing. The fix was to match on a shared substring, hook: not auto-run, that all three hint types really contain, and to write down that the hint speaks only for the hook. The semantic router is a separate lane and may still fire on its own.

Two smaller lessons came out of the same period. First, ground-truth facts have to be routed before the model reasons, not after. If the model answers first and a tool checks later, you have already shipped the wrong state to the user. Second, a candidates-and-scores log line for every routing decision caught more bugs than any test I wrote. You can’t debug a router whose losing candidates you never see.

Invariant: a consumer’s match string must appear in its producer’s output

Here are two properties you can check, stated as true or false.

One. A tool with side effects is never auto-executed from a keyword match. Every auto-execute path has a gate that reads the tool’s side-effect class, and every held match comes with a reason. Grep your router. Either a code path from “keyword matched” to “tool invoked” skips a side-effect check or it doesn’t.

Two. Any string that one component matches on (a prompt rule, a log parser, a hint reader) appears as a literal in the component that produces it. If your system prompt says “ignore hints marked X” and nothing in your codebase writes X, the rule is dead. Nothing will tell you.

Beforekeyword anywhere in the text fires thetool'screenshot' in a question takes ascreenshotdocs quote labels the hook never writesAfterkeyword inside prose becomes a hintsends, writes, spends are never auto-runevery hint carries a label the code emits

Replay last week’s prompts through your router in dry-run

You can test this on your own stack in five minutes. Pull a sample of real user messages and run your router over them without executing anything. Then look at which matches landed in long messages.

import sqlite3, re

db = sqlite3.connect("your_app.db")
prompts = [r[0] for r in db.execute(
    "SELECT content FROM messages WHERE role='user' ORDER BY created_at DESC LIMIT 500")]
mappings = db.execute("SELECT keyword, tool_name, has_side_effects FROM tool_mappings").fetchall()

for p in prompts:
    words = len(p.split())
    for kw, tool, side in mappings:
        if re.search(rf"(?<!\w){re.escape(kw)}(?!\w)", p, re.I):
            flag = "SIDE-EFFECT" if side else ""
            if words > 12:
                print(f"{flag:11} {tool:30} <- '{kw}' in {words}-word msg: {p[:90]!r}")

Passing output is a short list where every long-message match is read-only, and reading the context shows that you’d be fine with it running. Failing output is any SIDE-EFFECT line on a message that was plainly describing work, or a single common word (“type”, “click”, “docs”, “react”) showing up dozens of times. If your routing is purely model-driven, run the same replay with your tool-selection prompt at temperature 0 and count the tool calls on messages that end in a question mark. Then grep your system prompt for every quoted label and check that each one exists in your code:

grep -oE '`[^`]{6,}`' system_prompt.md | tr -d '`' | sort -u | while read s; do
  grep -rqF -- "$s" src/ || echo "DEAD RULE: $s"
done

Silence means pass. Any DEAD RULE line is a rule your model is following for a label your code never writes.

Routing papers pick which model runs, not whether anything should

Most routing research answers a different question. AgentRouter for multi-step workflows routes each step of a trajectory to the cheapest model that can handle it, and says frontier-only routing wastes 60 to 80 percent of the inference budget. RouterHGC and the knowledge-graph AgentRouter choose which agents and collaboration modes should handle a query. All three assume the query should be acted on. None of them treats “this sentence is not a request” as a routing outcome.

Architecture guides such as MLflow’s spend their time on how the agent reasons once it has decided to act. The MCP operations advice gets closer. AWS’s MCP guidance treats tool design and governance as pillars, and the Cloud Security Alliance calls for behavioral monitoring as one layer of defense in depth. Neither one names the specific failure where a correct mapping fires on a word inside a paragraph, and that’s the failure users actually hit.

Still open: the hook and the semantic router are two lanes

The prose gate is a heuristic, and it lives in the prompt hook. The semantic router runs separately and can still pick the same tool from the same sentence. That’s why every hint ends by saying so. I have one identity for “this tool call” across both lanes but not one arbiter yet. There’s also a coverage gap in the other direction. A custom Gmail server I added wasn’t discovered by the model-driven path at all until it was registered in the mapping table. The fast path and the fallback still disagree about what exists.

If you want plain-language routing that separates asking from describing, holds anything that sends or writes until you’ve seen it, and shows why every candidate did or didn’t run, that’s what vodou.ai does across your skills and MCP tools.