The situation
Where you work, and what you charge there, keeps changing: a new neighborhood, a higher fee across the river, a zone you stop serving. Someone then opens the CMS, finds each area and edits zip code lists by hand, and one mistyped zip code quietly puts a town three states away on your map. Now you type the change into one command instead. An agent checks every zip code against the US postal lookup, asks you about anything that looks wrong, and shows you its plan. Nothing is saved until you say yes, and the map changes only when a person publishes the drafts.What you’ll build
An Ace command,node ace service-map:update, in your own AdonisJS app. It asks you what changed, then
hands your note to an agent built with OpenRouter’s Agent SDK (@openrouter/agent). The agent has four
tools:
list_service_areas
check_zip_codes
ask_owner
propose_changes
- A Fetchski Attract site with the Service Area Map section, reading its zones from the CMS: in the CMS, open Connections, then Google, then Google Maps, and set Service area data source to Service Areas managed in this CMS. Areas are defined by US 5-digit zip codes.
- Someone who may manage API keys on that site, to make the key.
- Node.js 26.12.1 or later (
node --version). This page was built with AdonisJS 7.5,@openrouter/agent0.11 and Zod 4.6. - An OpenRouter account with credit and an API key (openrouter.ai/settings/keys).
The agent runs on
~anthropic/claude-opus-latest, OpenRouter’s name for the newest Claude Opus, so it moves to each new Opus by itself. The command prints what each run cost, and every run stops at $0.50. When you make the key, give it a credit limit (for example $10), so nothing can spend more. - About 30 minutes to set it up.
Make the API key
The app gets its own key, for service areas only. It can change drafts but never publish, delete or touch anything else.Open API Keys
https://acme.example/cms-login. Press ⌘K (Ctrl+K on Windows
and Linux), type API Keys and press Enter. Click Create API Key.Name it
Service map notes.Pick the Service area drafts pack
content:service-areas:publish the key can’t change the
live map, and without content:service-areas:delete it can’t remove an area.Check Granted before you create the key: it should list exactly the scope above.
Your site’s packs can be edited, so if it shows any other scope (a publish or delete one, say),
take it out with Edit scopes, or have someone who manages API keys choose Restore default
packs in Scope Packs.No Service area drafts pack in the list? Someone who manages API keys restores it: ⌘K,
Scope Packs, Restore default packs. Or choose Pick scopes one by one and tick
content:service-areas:write.Pick the signing mode
Create it and copy both values
fsk_acme_…) and
the Signing secret (fss_…). They are shown once: keep them for steps 2 and 4 below.Step by step
Create the app
Your AdonisJS project has been created successfully!, then a prompt inside
service-map.Point @fetchski at your site
service-map, with the Secret token you copied above.Your own site serves the @fetchski packages, and any API key from it lets you in. In your
project’s folder (beside its package.json), make a file named .npmrc holding these two lines:@fetchski packages come from your site; the second is the key to sign in with.
Put your site’s address in both lines (set your site in the bar under this page’s title and the
example shows it), and paste your whole key in place of fsk_acme_…. Write the key itself, never a
variable or a value from .env: npm and pnpm don’t read .env, and pnpm refuses a variable here..gitignore (make the file if
there isn’t one):@fetchski/… package, with
your key.You should see: .npmrc beside package.json, and .npmrc as the last line of .gitignore.Install the four packages
.npmrc, run the line for the package manager your
project uses:@fetchski/attract-cms-sdk calls your site and @fetchski/attract-cms-adonis fits it into
AdonisJS; @openrouter/agent runs the agent and its tools, and zod describes each tool’s input.You should see: added … packages. npm may also list @openrouter/sdk under
install-scripts: that script only checks types, and the app doesn’t need it.Point the app at your site
.env, the
rules that check them to start/env.ts, and config/attract.ts, and registers the provider that
puts the site’s client in the container. A malformed key or address writes nothing; whether the
site accepts the key and answers is what attract:check, below, tells you.You should see: DONE: lines for config/attract.ts, .env, start/env.ts and adonisrc.ts,
then [ success ] @fetchski/attract-cms-adonis is configured. Next: node ace attract:check. Run
that: every row reads ok, and it ends [ success ] The site acme is ready.Add the model's environment variables
.env and add these lines at the end, with your OpenRouter key:start/env.ts with this whole file:.env.test with this whole file:configure added, and the two OpenRouter
ones. The app refuses to boot when a value is missing, and the keys stay Secrets, which print as
[redacted] in logs. Tests never call the site or the model, so placeholders do there.You should see: nothing yet: the next steps use them.Gather the model's options
maxSteps and maxCostUsd cap every run, whatever the model does. A note needs three to
eight steps, more when the agent asks you something.Put the model in the container
OpenRouter is a class, so it is its own container key, as the package made SiteClient:
@inject() hands both to the agent, and a test replaces each with a fake.You should see: DONE: create providers/openrouter_provider.ts and DONE: update adonisrc.ts file.Add the zip code lookup
app/services/zip_codes.ts:44101 is around the corner or in another state, so it asks
a postal lookup instead of guessing. Zippopotam.us needs no account and no key. The distance check
is plain math in your code: a zip code more than FAR_MILES (50 miles) from the middle of the
others gets a warning the agent must ask you about. Change 50 to suit how far you travel.Give the agent its tools
app/services/service_map_tools.ts. It is the file pinned beside this page; here it is again
in full:Nothing writes
propose_changes only records the plan, after checking every id was one
list_service_areas answered and isn’t owned by the zip feed import.Questions have a limit
ask_owner answers with an error after three questions, so the agent plans with what it has
and says what is still unclear.Write the agent
app/services/service_map_agent.ts:plan() runs the agent until it calls propose_changes, or a limit stops it (12 steps,
$0.50). After a stop the model gets one last turn without tools, so it always ends with a few
sentences for you. save() is plain code: it saves exactly the plan you approved, merges added and
dropped zip codes into the area’s current list, names a new area’s towns from the lookup, and sends
every draft for review.Add the command
startApp: true boots the app, so the container has both clients; @inject() on run()
hands it the agent. Every question (yours and the agent’s) is an Ace prompt in the terminal, and
nothing reaches the site until you answer yes.You should see: node ace service-map:update --help prints the description.Format, lint and type-check
configure and make:provider write adonisrc.ts in a shape Prettier changes, and
codegen writes the index files tsc reads.You should see: npm run lint and npm run typecheck print only their own command lines
(> eslint ., > tsc --noEmit), no errors.Run it
Run the command
Answer its questions
Read the plan, then say yes or no
+ is a new area, ~ a change to one, - an area taken off the map. Type y and press Enter:Keep East side, only raise its price to $45). The agent plans again, up to three times. Leave the answer empty to stop: nothing is saved.Publish
Submitted Drafts and press
Enter. Open each service area, check its zip codes and price, and click Publish. The map on
your site changes as each one goes live. Not right after all? Dismiss it there: the live map stays
as it was.If something goes wrong
The agent has no plan: nothing was saved
The agent has no plan: nothing was saved
- The note named towns but no zip codes, and you didn’t give any when it asked. Run it again with the zip codes in the note.
- The area comes from the zip feed import, which would overwrite the change. Change it in the feed instead.
- The site refused a call. The agent quotes the site’s message, which names the missing scope or
says the key was refused (check
FETCHSKI_ATTRACT_API_KEYandFETCHSKI_ATTRACT_SIGNING_SECRET).
LOG_LEVEL=debug node ace service-map:update.Give it a sentence or two: which zip codes, which prices
Give it a sentence or two: which zip codes, which prices
The agent asks about a zip code you know is right
The agent asks about a zip code you know is right
FAR_MILES in app/services/zip_codes.ts.The lookup did not answer
The lookup did not answer
User not found.
User not found.
UnauthorizedResponseError: User not found.: OpenRouter refused
OPENROUTER_API_KEY. Copy the key again from
openrouter.ai/settings/keys; it starts with sk-or-v1-.Nothing written: Not an API key
Nothing written: Not an API key
configure refused the key: it isn’t the whole token, which is fsk_<site slug>_<key id>_<secret>,
exactly as Your API key is ready showed it. Copy it again and run configure again; if it’s
lost, rotate the key under API Keys to get a new one. node ace attract:check says which
part of the setup fails: the config, the site, the key or the clock.EnvValidationException: Missing environment variable
EnvValidationException: Missing environment variable
Missing environment variable "OPENROUTER_API_KEY" (or another name): that line isn’t in .env.
Add it as in step 5 of Step by step. In tests, it’s missing from .env.test.Published, but the map didn't change
Published, but the map didn't change
active: false) stays off on
purpose.Test it
The tests fake everything outside your app.fakeAttract() puts a fake site in the container for one
test: it answers every call and records it. A fake zip code lookup answers from a list. For the model, a tiny server on 127.0.0.1
answers OpenRouter’s requests in its own stream format, from a script of turns: so the real agent loop
runs, with the real tools, and nothing is sent or spent. Ace’s prompt.trap() answers the command’s
questions.
The fake model
serverURL points a real OpenRouter client at it, so the test swaps in that client and
nothing else changes.The fake zip codes
swap() replaces it, and the tests never call
Zippopotam.us. The coordinates are the real ones for those zip codes.The agent's tests
The command's tests
prompt.trap() answers a prompt by its text, or by the name the command gave it (each
round’s save-… and change-…). ace.ui.switchMode('raw') keeps the command’s log lines, so the
test can assert them. Commands live outside app/, so the test imports this one by path.Run them
Turn it off
Revoke the site key
API Keys, open Service map notes, and switch Enabled off (or delete
it). The app’s next call is refused.Revoke the OpenRouter key
.env.commands/update_service_map.ts. Drafts it already
saved stay in Submitted Drafts until someone publishes or dismisses them.
Related
Update a service area
updateServiceArea() takesTesting in AdonisJS
fakeAttract() and the assertions