Skip to main content

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

One call of the Fetchski Attract SDK: every service area on your site, with its zip codes and price.

check_zip_codes

Looks each zip code up on Zippopotam.us (free, no account): whether it exists, its town, how far it is from the others, and which area already has it.

ask_owner

Asks you a question right in the terminal, and waits for your answer. At most three per run.

propose_changes

Hands back the plan. It saves nothing: your code shows you the plan and asks you to say yes.
When you say yes, your code (not the agent) saves each change as a draft with the SDK and sends it to the CMS’s review queue. Say no, tell it what to change, and it plans again.
Before you start, you need:
  • 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/agent 0.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

Sign in to your site’s CMS at 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

Label: Service map notes.

Pick the Service area drafts pack

Under Scopes, keep Start from a pack and pick the Service area drafts pack. It ticks one scope:Add nothing else. Without 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

Request signing: Token + signature on writes. The SDK signs each write for you with the key’s signing secret.

Create it and copy both values

Click Create key. Under Your API key is ready, copy the Secret token (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

Terminal
Why: the API starter kit is the smallest AdonisJS 7 app with Ace, Japa tests, ESLint and Prettier set up. It starts on SQLite, which this Recipe doesn’t use: there is no database to set up.You should see: Your AdonisJS project has been created successfully!, then a prompt inside service-map.

Point @fetchski at your site

Do this in 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:
.npmrc
The first line says @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.
Any API key from your site works, the same one your app uses included: its scopes and signing mode don’t matter for installing, only that it’s switched on and not past its expiry date. No key yet? Make one in the CMS under API Keys (Authentication).
Then keep the file out of git. Add this line to the project’s .gitignore (make the file if there isn’t one):
.gitignore
.npmrc now holds a key to your site: never commit it. If git already tracks a .npmrc, stop tracking it with git rm --cached .npmrc. Keep these lines in this project’s .npmrc only, never in the .npmrc in your home folder. In CI, write the same file from a secret before installing.
Why: your site serves the Fetchski SDK, so npm asks it for every @fetchski/… package, with your key.You should see: .npmrc beside package.json, and .npmrc as the last line of .gitignore.

Install the four packages

In a terminal, in the same folder as the .npmrc, run the line for the package manager your project uses:
401 Unauthorized? Your site refused the key in .npmrc: it’s mistyped or cut short, switched off, or past its expiry date. Check too that .npmrc sits in the folder you ran the command in.
Why: @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

Terminal
It asks for your site’s address, then the Secret token and the Signing secret you copied (both hidden as you type).Why: it reads your site’s slug from the key, writes the address and both values to .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

Open .env and add these lines at the end, with your OpenRouter key:
.env
Replace start/env.ts with this whole file:
start/env.ts
And replace .env.test with this whole file:
.env.test
Why: the whole file is the starter kit’s, the rules 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

Create the file:
config/openrouter.ts
Why: 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

Terminal
Then replace the file it made:
providers/openrouter_provider.ts
Why: 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

Create app/services/zip_codes.ts:
app/services/zip_codes.ts
Why: the agent can’t know whether 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

Create app/services/service_map_tools.ts. It is the file pinned beside this page; here it is again in full:
app/services/service_map_tools.ts
Why: each tool has a plain description and a Zod input schema, which refuses a zip code that isn’t five digits before anything runs.

Nothing writes

No tool saves anything. 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

Create app/services/service_map_agent.ts:
app/services/service_map_agent.ts
Why: 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

Terminal
Replace the file it made:
commands/update_service_map.ts
Why: 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

Terminal
Why: 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

Terminal
You should see the first question. Type your note on one line, the way you would tell a colleague, and press Enter:
Terminal

Answer its questions

When a zip code looks wrong, the agent asks before it plans. Type your answer and press Enter:
Terminal
The wording is the agent’s, so yours will read differently.

Read the plan, then say yes or no

Terminal
+ is a new area, ~ a change to one, - an area taken off the map. Type y and press Enter:
Terminal
Not right? Press Enter for no, and type what should change (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

Sign in to the CMS, press ⌘K (Ctrl+K on Windows and Linux), type 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 lines after it say why, in the agent’s words. The usual reasons:
  • 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_KEY and FETCHSKI_ATTRACT_SIGNING_SECRET).
To see each call the app made to the site, run LOG_LEVEL=debug node ace service-map:update.
The note was shorter than 20 characters. Type a fuller one at the same prompt.
It is more than 50 miles from the others you named. Answer that it’s right, and it carries on. If your areas are often that far apart, raise FAR_MILES in app/services/zip_codes.ts.
Zippopotam.us didn’t answer within 10 seconds, so the agent asks you about each zip code instead of checking it. Answer, or run the command again later.
The command stops with UnauthorizedResponseError: User not found.: OpenRouter refused OPENROUTER_API_KEY. Copy the key again from openrouter.ai/settings/keys; it starts with sk-or-v1-.
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.
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.
Check Connections, then Google, then Google Maps: Service area data source must be Service Areas managed in this CMS. An area taken off the map (active: false) stays off on purpose.
Your site refused the key in .npmrc: it’s mistyped or cut short, switched off, or past its Expires date. Paste the whole key again (step 2), and run the install from service-map, where .npmrc is.

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

tests/helpers/fake_model.ts
Why: serverURL points a real OpenRouter client at it, so the test swaps in that client and nothing else changes.

The fake zip codes

tests/helpers/fake_zip_codes.ts
Why: the lookup is a class, so a test’s swap() replaces it, and the tests never call Zippopotam.us. The coordinates are the real ones for those zip codes.

The agent's tests

tests/unit/service_map_agent.spec.ts

The command's tests

tests/unit/update_service_map.spec.ts
Why: 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

Terminal
You should see:
Terminal

Turn it off

Revoke the site key

In the CMS, ⌘K → API Keys, open Service map notes, and switch Enabled off (or delete it). The app’s next call is refused.

Revoke the OpenRouter key

Delete it at openrouter.ai/settings/keys, then remove the lines you added to .env.
To keep the app but retire the command, delete commands/update_service_map.ts. Drafts it already saved stay in Submitted Drafts until someone publishes or dismisses them.

Update a service area

Every field updateServiceArea() takes

Testing in AdonisJS

fakeAttract() and the assertions

Required scopes

The scope each operation needs

Turn a voice memo into a blog post draft

The same kind of agent, on your blog