← Back to Blog

Natural Language Map Commands: Workers AI, Whisper and a Ctrl+K Fallback

I wrote this article in November 2025 and a fair chunk of it was fiction. The headline demo was a route between two systems that do not exist in EVE Frontier, borrowed from a different game, and the page has since collected 1,512 impressions in search and not one click. Which is fair enough. If you know the game and the snippet in front of you names a system that isn't in it, you close the tab. So this is a rewrite at the same address, describing the feature that actually ships.

Short version. EF-Map has a panel where you type or speak a request in plain English, a Cloudflare Worker hands it to a small language model, and the model returns a JSON command the map runs. It also has a Ctrl+K palette that does the same job as a keyboard CLI with no model involved. Both dispatch into the same function, which turned out to be the more interesting half of the story.

What is actually wired up

The AI panel is a floating panel with the id ai-commands, mounted at src/app/sections/PanelLayersSecondarySection.tsx:214. There is a button for it in the top bar, but it is hidden by default: hideAiButton ships as true in src/hooks/useUiVisibility.ts:43, and a one-time migration at :72 forced it hidden for everyone who already had it on. So today you either tick it back on in Display Settings, or open the palette and type panel ai. I am not going to pretend that is a prominent feature. It is a toggle away from a toggle.

The panel itself is a textarea, a microphone button, a rotating example in the placeholder, the last 50 commands kept in localStorage, and a badge that reads "Experimental, results may vary" (AICommandPanel.tsx:511). That badge is accurate and I would rather leave it there than take it off.

Hitting send posts to /api/parse-command, routed at worker/index.js:186 and handled in worker/routes/ai.js:227. The model is @cf/ibm-granite/granite-4.0-h-micro (ai.js:255), called with temperature: 0.1 and max_tokens: 256 (ai.js:262-263), and input is capped at 500 characters (ai.js:248). Granite micro is there because it is built for structured output and it is a lot cheaper than the general purpose 8B model I started on, which is the reason recorded in the comment at ai.js:253.

The binding is still the least interesting and most useful part. Three lines in eve-frontier-map/wrangler.json and the Worker can call the model. No API key, no second account, no secret to rotate.

{
  "ai": {
    "binding": "AI"
  }
}

What the model is allowed to say

Everything the parser knows lives in one long template string at the top of worker/routes/ai.js, running from line 7 to line 146. It defines 16 numbered commands with their parameters, keyword mappings so that "fewest jumps" lands on optimizeFor: "jumps", a translation table so "smart turret" becomes smartTurret, a rule about route direction so "from A to B" never comes back reversed, and a security clause telling it that it is a map command parser and nothing else. Output has to be a JSON array, even for one command.

The commands are the ones you would expect from the map's panels. Plot a route, select a system, set the jump range, run a reachability analysis or the scout optimiser, open the SSU finder, filter smart gates and smart assemblies, toggle Cinematic Mode, clear the route, reset everything. Number 16 is unknown, which is what it should return when it cannot work out what you meant.

Here is the bit I did not expect when I went back through the code. The client side handler understands 22 actions, not 16. The union type at src/components/AICommandInput/AICommandInput.tsx:7 lists things like togglePanel, setField and searchSSU that the prompt never mentions and the model will therefore never emit. They are not dead. The command palette and the in-page agent tools dispatch into the same handler, and they can ask for things the natural language path cannot. The AI panel is one of three callers, not the owner.

Compound commands and tidying up after the model

Ask for two things at once and the model returns two objects. The Worker pulls the first JSON array out of the response with a regex, and if there isn't one it falls back to scraping individual objects off separate lines (ai.js:282-294). Then every action name goes through a lookup table that maps find_route and findroute onto findRoute and so on (ai.js:305-325), because a probabilistic model will occasionally give you snake case for no reason. Anything left without an action gets rewritten to unknown with the raw output attached (ai.js:373-377).

The panel then runs whatever came back in order, with a 150 millisecond pause between commands so you can see each one land (AICommandPanel.tsx:402), and shows one toast summarising the lot.

Where it falls over, which is the part I got wrong before

There are three ways this fails and only two of them are visible. If the model returns something that is not JSON, the Worker catches it and returns unknown with the first 200 characters of the raw output (ai.js:295-302), and you get an error toast. If the model correctly decides it cannot parse you, same toast. Fine.

The third one is the problem. If the model returns a perfectly good findRoute but names a system the map cannot resolve, src/app/aiCommandHandler.ts:147-152 logs a warning to the console and returns. Nothing happens on screen. Same story if you say "route to X" with nothing selected and no origin given, at :137-141. There are two TODO comments sitting right there saying a toast should be shown, and they have been there since the handler was pulled out of App.tsx. Silence is the worst possible response to a command, because you cannot tell whether you were misunderstood or ignored.

The name matcher deserves a warning too. findSystem at aiCommandHandler.ts:86 tries five things in order: exact match, re-inserting hyphens where letters meet digits, stripping separators, a prefix match, then a plain substring match over every system name with the first hit winning. That last stage is generous. Give it something vague and it will confidently select a system you did not ask for, and it will not tell you that it guessed.

The examples in the shipped code name systems from the wrong game

While checking the above I found the actual reason this page never earned a click. The rotating examples in the panel at AICommandPanel.tsx:39-50, the help popup underneath them, and several worked examples inside the system prompt itself all name systems that belong to a different game. Another points at a system that is not in the current map database at all, which I assume was true in an earlier cycle and quietly stopped being true. Nobody reported it, which tells you roughly how much use this panel gets.

For what it is worth, real names here look like A4T-SL7, O8B-FS7 or A.062.E25. Of the 24,026 systems in the map database, exactly 100 carry a pronounceable proper name like Zhurb or Airgetlam. Everything else is a code. Fixing those example strings is on my list, and it is a better use of an afternoon than anything else in this article.

Voice input, which does work

The microphone records through MediaRecorder, caps itself at 60 seconds (AICommandPanel.tsx:250), and posts the blob to /api/transcribe, which runs @cf/openai/whisper-large-v3-turbo (ai.js:200) with a 10MB ceiling on the upload. Whisper takes an initial_prompt, so it gets a hint about the domain vocabulary, including an explicit note that the word is route as in navigation and not root (ai.js:207). That one line fixed the most common transcription error I was seeing.

The transcription lands in the textarea rather than firing straight off, and the auto-submit in the older input component is still commented out on purpose. Speech recognition plus a probabilistic parser plus an instant map action is two guesses too many to chain without a look first.

The keyboard palette that skips the model entirely

Ctrl+K opens a command line over the map, registered in src/components/CommandPalette/CommandPaletteHost.tsx. It has 27 commands (src/app/commandPalette/paletteCommands.ts:55), a proper tokenizer with quoting and repeatable --flags, Tab completion that suggests system names with their region as a hint, and the same forgiving name matching, so typing a4tsl7 finds A4T-SL7. When a command runs it calls ctx.dispatch and the palette closes.

That dispatch is the same handleAICommand the AI panel gets, wired from the same prop at PanelLayersSecondarySection.tsx:214 and :218. So the two doors open into the same room. One costs a round trip to a model and can misread you, the other either understands you or prints a usage line. The WebMCP agent tools go through the same door as well.

route ACS-D4J IQ1-3LG --range 80

Those two are real systems, sitting 441.5 light years apart in a straight line. The committed routing fixture for that pair says the fuel-optimal answer at an 80 light year jump range is 7 hops covering 507.7 light years travelled. Ask the AI panel for the same thing in a sentence and, when it parses correctly, you get an identical call into the router. The difference is entirely in how you got there.

What gets logged

Every parse is written to KV with a 90 day expiry (ai.js:398-400), under one of two prefixes depending on whether anything came back as unknown. The entry holds the input text, the parsed commands, the model name, a timestamp and the token count, and no user identifier of any kind. The read-back endpoint is locked to preview hosts or an admin token (ai.js:432-434). The point of keeping the text is to find out what people ask for that the prompt does not cover, and the failed bucket has been more useful than the successful one.

Where I have landed on it

I think the natural language panel is a good demo and a mediocre tool, and the hidden-by-default button is me having already admitted that. It is genuinely nice for voice, and for the compound case where you want three things done in a row. It is worse than the palette at everything else, because the palette cannot misunderstand you and it tells you what it did not like. If I do more work here it will be on the silent failures first, then the example strings, because a command interface that ignores you is worse than one that says no. Anyway, both are live, Ctrl+K needs nothing switched on, and if you find a phrasing that ought to work and doesn't, that is what the failed log bucket is for.

Related Posts

The Ctrl+K command palette is the deterministic half of this story, covered properly.

WebMCP browser agent tools is the third caller into the same command handler, for browser agents rather than people.

System Finder's plain English search is the other place on the map where you describe what you want instead of clicking filters.

The transparency report covers the aggregate-only logging approach these command logs follow.

workers ai natural language cloudflare ai llm integration voice commands whisper granite model command parsing command palette eve frontier