building vodou.

Your router matched the right keyword in the wrong sentence

A keyword router that can auto-fire tools needs a hold state for sentences that describe a workflow, and must scan the same words whatever envelope they arrive in.

Chad Priest / / 9 min read

If your agent runs a fast keyword or intent router in front of the model, it probably answers every prompt one of two ways. It fires a tool, or it passes the prompt to the LLM. That covers “what’s on my calendar today.” It does not cover “build me a workflow that checks my calendar and email.” Your router sees my calendar in that sentence, and it is not wrong to. It is only answering a smaller question than the one the user asked.

I found out my router was doing exactly that. The fix took two commits across two days, and the second one was a bug I would not have guessed at.

“build me a workflow that checks my calendar” returned a calendar listing at 75

My planning docs advertise three phrases as the way into the workflow builder. On August 25 I ran all three against the live router instead of trusting the docs:

build me a workflow that checks my calendar and email → auto-route 75
    → google-calendar::list-events, matched on "my calendar"
every morning summarize my calendar and unread mail   → the same
whenever a PR lands, check CI and tell me             → silent, no signal

That is two different failures. The first two gave a confident wrong answer: you ask for an automation and get today’s events. The third gave no answer at all. The phrases the docs call the front door were the phrases the router handled worst.

This was not new. My memory system already held an older note that the router “can auto-fire MCP tools off prose keyword matches with no extracted arguments,” and another about it firing screenshot and disk-info calls on a prompt as thin as “Hi.” The workflow case was the same failure with a better disguise, because the match looked right.

Held is a third answer between fire and ignore

What shipped adds a third outcome. When the sentence contains an explicit schedule or trigger phrase (“every morning”, “whenever ”) or an explicit workflow word (“workflow”, “automation”), the router holds the auto-route. It does not execute list-events. It writes a workflow-offer marker into the turn context. The chat layer sees that marker and sends the sentence to the planner, and the planner endpoint never executes a step. You get back a proposed graph that you can read, edit or ignore.

matchmatch inside workflow sentenceno matchPromptKeyword routerscans the user's sentenceFire toolsingle-step requestHold + offer planschedule, trigger orworkflow wordPlannerproposes a graph, runsnothingModelno confident matchA held match costs an ignored offer. A wrong fire costs an action nobody asked for.
Three outcomes, not two

The cost asymmetry decided the design. If a hold is a false positive, you get an offer you ignore. If an auto-fire is a false positive, something executes, and for a tool that sends or writes, that can’t be taken back.

Outbidding “my calendar” with graph keywords was the first design, and I dropped it

The obvious fix was to register keywords like “build me a workflow” with a higher score than “my calendar,” so the graph route wins. I started writing it and stopped. That is a bidding war inside a table where the calendar match is not wrong, only incomplete. Every keyword added later would have to win that fight again, and eventually one wouldn’t.

So I held the route instead of outbidding it. No threshold moved. The engine already did this for a different case: a keyword-routed mutation whose parameters were guessed gets held and offered, not executed. I reused that move for a new trigger. I also kept the trigger narrow on purpose. A bare and chain does not count. “Check my calendar and email” is a reasonable request for two reads, not a request to build something, and treating every conjunction as a workflow would have turned the planner into a second bad default.

That was D1. Tests passed, and the CLI that prints what the router would do with a prompt said HELD for all three phrases.

The next Telegram message scanned <untrusted_channel_message and nothing else

The next evening I sent one of those sentences from Telegram. I got a model answer and no plan. The model’s answer described a real scheduled skill accurately, which made it worse, because it looked like intended behavior.

I ran the same probe twice. On the bare sentence it said HELD. On the sentence exactly as Telegram delivers it, the router reported this as its scan window:

untrusted_channel_message channel="telegram from="…

The router takes the first non-empty line of the turn as the text to classify. On a channel turn, that line is the opening tag of the untrusted-content envelope that wraps outside messages. Every keyword check and the new workflow gate saw only the envelope. No offer marker was written, so the console’s messageCarriesWorkflowOffer check in llm.ts found no offer and sent the turn to the model.

B18: scan windowfirst non-empty line of the turn= <untrusted_channel_message ...>no keyword, no gate, no offermodel answers insteadFixedpeel the channel envelope firstscan the sentence inside itgate sees 'every morning'offer marker written, planner runs

This was the fourth place the envelope broke in one evening. It had already broken the recipe author and the gate matcher, and in July it broke the gateway’s memory query. The extractor already had a carve-out for the gateway’s own User's new message: wrapper. The fix added the same kind of carve-out one layer down, and it works together with the existing one. The Rust tests now use the literal envelope as input. 993 library tests and 2,055 binary tests passed before the release build was swapped in.

Something funny happened while I was drafting this. The prompt hook in my coding session matched “my calendar” inside the writing brief, labeled it “matched inside prose,” and did not run it. The same failure class shows up in every layer that reads text.

Two properties a router either has or doesn’t

The first property: a router that can execute has an outcome that is neither fire nor pass. Check your code for a return value, enum or branch that means “I matched, and I am declining to act on the match.” If the only outcomes are “call the tool” and “hand to the LLM,” then every correct but incomplete match gets executed.

The second property: the router’s decision is invariant under every transport envelope. For every wrapper w your system puts around user text (channel tags, quoted replies, attachment preambles, your own gateway’s framing), route(w(s)) == route(s). This is either true of your router or false, and it is cheap to test.

A 40-line replay that finds both failures in your router

Take your router’s classify function, wherever it lives. Copy the actual envelopes from your own logs. Don’t write envelopes you think look plausible, because the bug hides in the real ones.

from yourapp.routing import route  # -> dict(action="fire"|"hold"|"pass", tool=..., scanned=...)

WORKFLOW = [
    "build me a workflow that checks my calendar and email",
    "every morning summarize my calendar and unread mail",
    "whenever a PR lands, check CI and tell me",
]
SINGLE = ["what's on my calendar today", "check my calendar and email"]

# paste REAL wrappers from your logs, one per ingress path
ENVELOPES = {
    "bare":     lambda s: s,
    "channel":  lambda s: f'<untrusted_channel_message channel="chat" from="u1">\n{s}\n</untrusted_channel_message>',
    "reply":    lambda s: f"> previous message\n\n{s}",
    "attached": lambda s: f"[attachment: notes.pdf]\n{s}",
}

fail = 0
for s in WORKFLOW + SINGLE:
    base = route(s)
    if s in WORKFLOW and base["action"] == "fire":
        print(f"FIRED ON WORKFLOW  {s!r} -> {base['tool']}"); fail += 1
    for name, wrap in ENVELOPES.items():
        r = route(wrap(s))
        if (r["action"], r.get("tool")) != (base["action"], base.get("tool")):
            print(f"ENVELOPE DRIFT [{name}] {s!r}: {base['action']} -> {r['action']}, scanned={r.get('scanned','?')[:50]!r}")
            fail += 1
print("PASS" if not fail else f"{fail} failures")

A passing run prints PASS. A failing run looks like mine did:

FIRED ON WORKFLOW  'every morning summarize my calendar and unread mail' -> calendar.list_events
ENVELOPE DRIFT [channel] 'every morning summarize ...': hold -> pass, scanned='<untrusted_channel_message channel="chat" from="u'

If your router doesn’t expose what it scanned, add that before anything else. And if you already log routing decisions, this query takes thirty seconds:

SELECT substr(scanned_text, 1, 30) AS head, count(*) AS n
FROM routing_decisions
GROUP BY head ORDER BY n DESC LIMIT 10;

If any of the top rows begins with <, > or your own framing text, your router is classifying envelopes.

Routing guides assume the classifier sees the user’s words

Most writing on agent routing describes the router as a classifier that picks a destination. Taskade says it “classifies intent first, then sends the work where it belongs.” Zencoder describes weighing intent, complexity, cost and risk. Reliant Labs’ router node has an LLM pick “the best match” from a list of candidates. All of that is sound. None of it treats “I matched and should not act” as its own outcome, and a design that always picks the best candidate has nowhere to put a correct but incomplete match.

The LLM-as-Scheduler paper comes closest. Its cheap first-stage gate can early-exit, verify, repair or reroute, so declining is a real option there. AWS’s post on controlled tool orchestration is about enforcing tool order once you know a sequence is intended. That’s the step after a hold.

I found nothing that covers the envelope. Every source assumes the classifier’s input is the user’s request. In a system with channels, attachments and untrusted-content tags, the input is whatever some other layer wrapped around the request, and nobody re-checks that.

A bare “and” chain still gets the calendar

Two gaps are still open. “Check my calendar and email” still routes to the calendar tool alone, because I chose not to read conjunctions as workflows. It’s a narrow rule, and it gives a worse answer for that sentence than a planner would.

The envelope fix is still a list of carve-outs, one per wrapper the extractor knows about. When a new channel arrives with a new envelope format, it will break the same way until someone adds its carve-out. The replay above is how I plan to catch that before a user does. The real fix is for every ingress path to pass the user’s own words alongside the wrapped turn, so no router has to dig them out.