> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fetchski-attract.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Update your service map from a plain-English note

> Tell an Ace command what changed, the way you'd tell a colleague: an AI agent checks every zip code, asks you about anything odd, shows its plan, and saves it as drafts for review once you say yes.

```ts app/services/service_map_tools.ts wrap theme={"dark"}
import { tool } from '@openrouter/agent'
import { AposMode, type SiteClient } from '@fetchski/attract-cms-sdk'
import { z } from 'zod'
import {
  FAR_MILES,
  milesFromTheOthers,
  type ZipCodeLookup,
  type ZipCodePlace,
} from '#services/zip_codes'

/** A service area the agent listed, as the code around it needs it. */
export interface ListedArea {
  title: string
  zipCodes: string[]
  source: string | null
}

const zipCode = z.string().regex(/^\d{5}$/, 'A zip code is 5 digits')

const areaFields = {
  price: z.number().min(0).optional().describe('The amount shown for the area; 0 shows "included"'),
  priceLabel: z
    .string()
    .max(40)
    .optional()
    .describe('Shown instead of the price, e.g. "From $40" or "Call for pricing"'),
  description: z
    .string()
    .max(300)
    .optional()
    .describe('A sentence a visitor sees when they click the area'),
}

/** One change in the plan the person approves. Nothing is saved until they do. */
export const plannedChange = z.discriminatedUnion('action', [
  z.object({
    action: z.literal('create'),
    title: z.string().min(2).max(60).describe('Its name, e.g. "North side"'),
    zipCodes: z.array(zipCode).min(1).describe('Every zip code the new area covers'),
    ...areaFields,
  }),
  z.object({
    action: z.literal('update'),
    id: z.string().describe('The id list_service_areas gave'),
    title: z.string().min(2).max(60).optional(),
    addZipCodes: z.array(zipCode).optional().describe('Zip codes to add to the area'),
    removeZipCodes: z.array(zipCode).optional().describe('Zip codes to take out of the area'),
    ...areaFields,
  }),
  z.object({
    action: z.literal('turn_off'),
    id: z.string().describe('The id list_service_areas gave'),
  }),
])

export type PlannedChange = z.infer<typeof plannedChange>

/** What one planning run found, for the code around the agent. */
export interface ServiceMapRun {
  listed: Map<string, ListedArea>
  /** Every zip code check_zip_codes found, for the towns a new area shows. */
  places: Map<string, ZipCodePlace>
  questionsAsked: number
  plan: PlannedChange[] | null
}

/** Asks the person at the terminal a question and waits for their answer. */
export type AskOwner = (question: string) => Promise<string>

/** The most questions the agent may ask in one run. */
const MAX_QUESTIONS = 3

/**
 * The service map, as the agent sees it. Two tools read (the site's service
 * areas, and the zip code lookup), one asks the person, and one hands back the
 * plan. None of them writes to the site: code does that, once the person says yes.
 */
export function serviceMapTools(
  site: SiteClient,
  zips: ZipCodeLookup,
  ask: AskOwner,
  run: ServiceMapRun
) {
  const listAreas = tool({
    name: 'list_service_areas',
    description:
      'Every service area on the site now (their drafts): id, name, zip codes, price, and whether it is on the map. Read it first.',
    inputSchema: z.object({}),
    execute: async () => {
      const areas = await site
        .serviceAreas()
        .listServiceAreasPick(
          ['title', 'zipCodes', 'price', 'priceLabel', 'description', 'active', 'source'],
          { aposMode: AposMode.Draft }
        )
      for (const area of areas) {
        run.listed.set(area.id, {
          title: String(area.title),
          zipCodes: String(area.zipCodes ?? '').match(/\d{5}/g) ?? [],
          source: typeof area.source === 'string' ? area.source : null,
        })
      }
      return { areas }
    },
  })

  const checkZipCodes = tool({
    name: 'check_zip_codes',
    description:
      'Look up zip codes before planning with them: whether each one exists, its town and state, how far it is from the others you send (a nearby state is fine; hundreds of miles is not), and which listed area already covers it. Send the new zip codes together with two or three zip codes of the area they join.',
    inputSchema: z.object({ zipCodes: z.array(zipCode).min(1).max(30) }),
    execute: async ({ zipCodes }) => {
      const unique = [...new Set(zipCodes)]
      const found = await Promise.all(
        unique.map(async (zip) => {
          try {
            return { zip, place: await zips.find(zip), failed: false }
          } catch {
            return { zip, place: null, failed: true }
          }
        })
      )
      const places = found.flatMap((entry) => (entry.place ? [entry.place] : []))
      for (const place of places) {
        run.places.set(place.zip, place)
      }
      const distances = milesFromTheOthers(places)

      return {
        zipCodes: found.map(({ zip, place, failed }) => {
          const warnings: string[] = []
          const coveredBy = [...run.listed.values()].find((area) => area.zipCodes.includes(zip))
          if (failed) {
            warnings.push(
              'The lookup did not answer: ask the person whether this zip code is right'
            )
          } else if (!place) {
            warnings.push('No such zip code')
          } else {
            const miles = distances[places.indexOf(place)]
            if (miles !== null && miles > FAR_MILES) {
              warnings.push(`About ${miles} miles from the other zip codes`)
            }
          }
          if (coveredBy) {
            warnings.push(`Already in the area "${coveredBy.title}"`)
          }
          return { zip, place, warnings }
        }),
      }
    },
  })

  const askOwner = tool({
    name: 'ask_owner',
    description:
      'Ask the person a short question and wait for their answer. Use it when the note is unclear, or when check_zip_codes warns about a zip code, before planning with it.',
    inputSchema: z.object({ question: z.string().min(5).max(300) }),
    execute: async ({ question }) => {
      if (run.questionsAsked >= MAX_QUESTIONS) {
        return {
          error: `You have asked ${MAX_QUESTIONS} questions. Plan with what you know, and say in your summary what is still unclear.`,
        }
      }
      run.questionsAsked++
      return { answer: await ask(question) }
    },
  })

  const proposeChanges = tool({
    name: 'propose_changes',
    description:
      'Hand back your plan: every change the note asks for. Nothing is saved yet: the person reads the plan and says yes or no. Call it once, last.',
    inputSchema: z.object({ changes: z.array(plannedChange).min(1).max(20) }),
    execute: async ({ changes }) => {
      for (const change of changes) {
        if (change.action === 'create') {
          continue
        }
        const listed = run.listed.get(change.id)
        if (!listed) {
          return {
            error: `No listed area has the id ${change.id}. Call list_service_areas and use its ids.`,
          }
        }
        if (listed.source === 'zipFeed') {
          return {
            error: `"${listed.title}" comes from the zip feed import, which overwrites it. Leave it out of the plan, and say so.`,
          }
        }
      }
      run.plan = changes
      return { proposed: changes.length }
    },
  })

  return [listAreas, checkZipCodes, askOwner, proposeChanges] as const
}
```

## 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:

> [card] list\_service\_areas
>
> One call of the Fetchski Attract SDK: every service area on your site, with its zip codes and price.

> [card] check\_zip\_codes
>
> Looks each zip code up on [Zippopotam.us](https://zippopotam.us) (free, no account): whether it
> exists, its town, how far it is from the others, and which area already has it.

> [card] ask\_owner
>
> Asks you a question right in the terminal, and waits for your answer. At most three per run.

> [card] 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.

```mermaid theme={"dark"}
flowchart LR
    A[You type<br/>what changed] --> B[Agent reads your<br/>service areas]
    B --> C[Agent checks<br/>every zip code]
    C -- something odd --> D[Agent asks you]
    D --> C
    C --> E[You read the plan]
    E -- no, change this --> B
    E -- yes --> F[Drafts in the<br/>review queue]
    F --> G[A person publishes:<br/>the map changes]
```

> [!NOTE]
>
> **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](https://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.

**1. 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**.

**2. Name it**

**Label**: `Service map notes`.

**3. Pick the Service area drafts pack**

Under **Scopes**, keep **Start from a pack** and pick the **Service area drafts** pack. It ticks
one scope:

| Scope | Why |
| - | - |
| `content:service-areas:write` | Read the service areas' drafts, create and change drafts, and send them for review |

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`.

**4. Pick the signing mode**

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

**5. 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

**1. Create the app**

```bash Terminal theme={"dark"}
npm create adonisjs@latest service-map -- --kit=api
cd service-map
```

**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`.

**2. 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:

```ini .npmrc theme={"dark"} theme={"dark"}
@fetchski:registry=https://acme.example/api/v1/sdk/npm/
//acme.example/api/v1/sdk/npm/:_authToken=fsk_acme_…
```

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.

> [!NOTE]
>
> **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](https://docs.fetchski-attract.com/authentication.md)).

Then keep the file out of git. Add this line to the project's `.gitignore` (make the file if
there isn't one):

```text .gitignore theme={"dark"} theme={"dark"}
.npmrc
```

> [!WARNING]
>
> **`.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`.

**3. Install the four packages**

In a terminal, in the same folder as the `.npmrc`, run the line for the package manager your
project uses:

```bash npm theme={"dark"} theme={"dark"}
npm install @fetchski/attract-cms-adonis @fetchski/attract-cms-sdk @openrouter/agent zod
```

```bash pnpm theme={"dark"} theme={"dark"}
pnpm add @fetchski/attract-cms-adonis @fetchski/attract-cms-sdk @openrouter/agent zod
```

```bash bun theme={"dark"} theme={"dark"}
bun add @fetchski/attract-cms-adonis @fetchski/attract-cms-sdk @openrouter/agent zod
```

> [!TIP]
>
> **`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.

**4. Point the app at your site**

```bash Terminal theme={"dark"}
node ace configure @fetchski/attract-cms-adonis
```

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.`

**5. Add the model's environment variables**

Open `.env` and add these lines at the end, with your OpenRouter key:

```ini .env theme={"dark"}
# OpenRouter: your key, and the model the agent runs on
OPENROUTER_API_KEY=sk-or-v1-…
OPENROUTER_MODEL=~anthropic/claude-opus-latest
```

Replace `start/env.ts` with this whole file:

```ts start/env.ts theme={"dark"}
/*
|--------------------------------------------------------------------------
| Environment variables service
|--------------------------------------------------------------------------
|
| The `Env.create` method creates an instance of the Env service. The
| service validates the environment variables and also cast values
| to JavaScript data types.
|
*/

import { Env } from '@adonisjs/core/env'

export default await Env.create(new URL('../', import.meta.url), {
  // Node
  NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
  PORT: Env.schema.number(),
  HOST: Env.schema.string({ format: 'host' }),
  LOG_LEVEL: Env.schema.string(),

  // App
  APP_KEY: Env.schema.secret(),
  APP_URL: Env.schema.string({ format: 'url', tld: false }),

  // Session
  SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const),

  /*
  |----------------------------------------------------------
  | Variables for configuring @fetchski/attract-cms-adonis
  |----------------------------------------------------------
  */
  FETCHSKI_ATTRACT_SITE: Env.schema.string(),
  FETCHSKI_ATTRACT_URL: Env.schema.string({ format: 'url', tld: false }),
  FETCHSKI_ATTRACT_API_KEY: Env.schema.secret(),
  FETCHSKI_ATTRACT_SIGNING_SECRET: Env.schema.secret.optional(),

  // OpenRouter
  OPENROUTER_API_KEY: Env.schema.secret(),
  OPENROUTER_MODEL: Env.schema.string(),
})
```

And replace `.env.test` with this whole file:

```ini .env.test theme={"dark"}
SESSION_DRIVER=memory

# Tests fake the site and the model: placeholders only
FETCHSKI_ATTRACT_SITE=acme
FETCHSKI_ATTRACT_URL=https://acme.example
FETCHSKI_ATTRACT_API_KEY=fsk_acme_0a1b2c3d_not-a-real-secret
OPENROUTER_API_KEY=sk-or-v1-not-a-real-key
OPENROUTER_MODEL=test/fake-model
```

**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 `Secret`s, 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.

**6. Gather the model's options**

Create the file:

```ts config/openrouter.ts theme={"dark"}
import env from '#start/env'

/**
 * The model the agent runs on, and the limits every run stays inside.
 */
const openrouterConfig = {
  apiKey: env.get('OPENROUTER_API_KEY'),
  model: env.get('OPENROUTER_MODEL'),
  maxSteps: 12, // tool rounds per run, at most
  maxCostUsd: 0.5, // a run stops once it has spent this much
}

export default openrouterConfig
```

**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.

**7. Put the model in the container**

```bash Terminal theme={"dark"}
node ace make:provider openrouter --register
```

Then replace the file it made:

```ts providers/openrouter_provider.ts theme={"dark"}
import { OpenRouter } from '@openrouter/agent'
import type { ApplicationService } from '@adonisjs/core/types'
import type openrouterConfig from '#config/openrouter'

export default class OpenrouterProvider {
  constructor(protected app: ApplicationService) {}

  /**
   * One OpenRouter client for the whole app. Tests swap it for a fake model.
   */
  register() {
    this.app.container.singleton(OpenRouter, () => {
      const config = this.app.config.get<typeof openrouterConfig>('openrouter')

      return new OpenRouter({ apiKey: config.apiKey.release() })
    })
  }
}
```

**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`.

**8. Add the zip code lookup**

Create `app/services/zip_codes.ts`:

```ts app/services/zip_codes.ts theme={"dark"}
/** Where a zip code is, as the US postal lookup answered it. */
export interface ZipCodePlace {
  zip: string
  town: string
  state: string
  latitude: number
  longitude: number
}

/** Farther than this from the other zip codes in a check, a zip code is flagged. */
export const FAR_MILES = 50

/**
 * Looks up US zip codes on Zippopotam.us: free, no account and no key.
 * Tests swap this class for a fake, so they never call it.
 */
export class ZipCodeLookup {
  /** The place a zip code belongs to, or null when no such zip code exists. */
  async find(zip: string): Promise<ZipCodePlace | null> {
    const response = await fetch(`https://api.zippopotam.us/us/${zip}`, {
      signal: AbortSignal.timeout(10_000),
    })
    if (response.status === 404) {
      return null
    }
    if (!response.ok) {
      throw new Error(`The zip code lookup answered ${response.status}`)
    }
    const body = (await response.json()) as {
      places: {
        'place name': string
        'state abbreviation': string
        'latitude': string
        'longitude': string
      }[]
    }
    const place = body.places[0]
    return {
      zip,
      town: place['place name'],
      state: place['state abbreviation'],
      latitude: Number(place.latitude),
      longitude: Number(place.longitude),
    }
  }
}

/** Miles between two places, as the crow flies. */
export function milesBetween(a: ZipCodePlace, b: ZipCodePlace) {
  const radians = (degrees: number) => (degrees * Math.PI) / 180
  const dLat = radians(b.latitude - a.latitude)
  const dLon = radians(b.longitude - a.longitude)
  const h =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(radians(a.latitude)) * Math.cos(radians(b.latitude)) * Math.sin(dLon / 2) ** 2
  return Math.round(3959 * 2 * Math.asin(Math.sqrt(h)))
}

/** The middle value of a list of numbers. */
function median(values: number[]) {
  const sorted = [...values].sort((a, b) => a - b)
  const middle = Math.floor(sorted.length / 2)
  return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
}

/**
 * How far each place is from the middle of the OTHER places: one zip code in
 * another state is far from the rest, while the rest stay close together.
 */
export function milesFromTheOthers(places: ZipCodePlace[]) {
  return places.map((place) => {
    const others = places.filter((other) => other.zip !== place.zip)
    if (others.length === 0) {
      return null
    }
    const middle: ZipCodePlace = {
      zip: '',
      town: '',
      state: '',
      latitude: median(others.map((other) => other.latitude)),
      longitude: median(others.map((other) => other.longitude)),
    }
    return milesBetween(place, middle)
  })
}
```

**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.

**9. 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:

```ts app/services/service_map_tools.ts theme={"dark"}
import { tool } from '@openrouter/agent'
import { AposMode, type SiteClient } from '@fetchski/attract-cms-sdk'
import { z } from 'zod'
import {
  FAR_MILES,
  milesFromTheOthers,
  type ZipCodeLookup,
  type ZipCodePlace,
} from '#services/zip_codes'

/** A service area the agent listed, as the code around it needs it. */
export interface ListedArea {
  title: string
  zipCodes: string[]
  source: string | null
}

const zipCode = z.string().regex(/^\d{5}$/, 'A zip code is 5 digits')

const areaFields = {
  price: z.number().min(0).optional().describe('The amount shown for the area; 0 shows "included"'),
  priceLabel: z
    .string()
    .max(40)
    .optional()
    .describe('Shown instead of the price, e.g. "From $40" or "Call for pricing"'),
  description: z
    .string()
    .max(300)
    .optional()
    .describe('A sentence a visitor sees when they click the area'),
}

/** One change in the plan the person approves. Nothing is saved until they do. */
export const plannedChange = z.discriminatedUnion('action', [
  z.object({
    action: z.literal('create'),
    title: z.string().min(2).max(60).describe('Its name, e.g. "North side"'),
    zipCodes: z.array(zipCode).min(1).describe('Every zip code the new area covers'),
    ...areaFields,
  }),
  z.object({
    action: z.literal('update'),
    id: z.string().describe('The id list_service_areas gave'),
    title: z.string().min(2).max(60).optional(),
    addZipCodes: z.array(zipCode).optional().describe('Zip codes to add to the area'),
    removeZipCodes: z.array(zipCode).optional().describe('Zip codes to take out of the area'),
    ...areaFields,
  }),
  z.object({
    action: z.literal('turn_off'),
    id: z.string().describe('The id list_service_areas gave'),
  }),
])

export type PlannedChange = z.infer<typeof plannedChange>

/** What one planning run found, for the code around the agent. */
export interface ServiceMapRun {
  listed: Map<string, ListedArea>
  /** Every zip code check_zip_codes found, for the towns a new area shows. */
  places: Map<string, ZipCodePlace>
  questionsAsked: number
  plan: PlannedChange[] | null
}

/** Asks the person at the terminal a question and waits for their answer. */
export type AskOwner = (question: string) => Promise<string>

/** The most questions the agent may ask in one run. */
const MAX_QUESTIONS = 3

/**
 * The service map, as the agent sees it. Two tools read (the site's service
 * areas, and the zip code lookup), one asks the person, and one hands back the
 * plan. None of them writes to the site: code does that, once the person says yes.
 */
export function serviceMapTools(
  site: SiteClient,
  zips: ZipCodeLookup,
  ask: AskOwner,
  run: ServiceMapRun
) {
  const listAreas = tool({
    name: 'list_service_areas',
    description:
      'Every service area on the site now (their drafts): id, name, zip codes, price, and whether it is on the map. Read it first.',
    inputSchema: z.object({}),
    execute: async () => {
      const areas = await site
        .serviceAreas()
        .listServiceAreasPick(
          ['title', 'zipCodes', 'price', 'priceLabel', 'description', 'active', 'source'],
          { aposMode: AposMode.Draft }
        )
      for (const area of areas) {
        run.listed.set(area.id, {
          title: String(area.title),
          zipCodes: String(area.zipCodes ?? '').match(/\d{5}/g) ?? [],
          source: typeof area.source === 'string' ? area.source : null,
        })
      }
      return { areas }
    },
  })

  const checkZipCodes = tool({
    name: 'check_zip_codes',
    description:
      'Look up zip codes before planning with them: whether each one exists, its town and state, how far it is from the others you send (a nearby state is fine; hundreds of miles is not), and which listed area already covers it. Send the new zip codes together with two or three zip codes of the area they join.',
    inputSchema: z.object({ zipCodes: z.array(zipCode).min(1).max(30) }),
    execute: async ({ zipCodes }) => {
      const unique = [...new Set(zipCodes)]
      const found = await Promise.all(
        unique.map(async (zip) => {
          try {
            return { zip, place: await zips.find(zip), failed: false }
          } catch {
            return { zip, place: null, failed: true }
          }
        })
      )
      const places = found.flatMap((entry) => (entry.place ? [entry.place] : []))
      for (const place of places) {
        run.places.set(place.zip, place)
      }
      const distances = milesFromTheOthers(places)

      return {
        zipCodes: found.map(({ zip, place, failed }) => {
          const warnings: string[] = []
          const coveredBy = [...run.listed.values()].find((area) => area.zipCodes.includes(zip))
          if (failed) {
            warnings.push(
              'The lookup did not answer: ask the person whether this zip code is right'
            )
          } else if (!place) {
            warnings.push('No such zip code')
          } else {
            const miles = distances[places.indexOf(place)]
            if (miles !== null && miles > FAR_MILES) {
              warnings.push(`About ${miles} miles from the other zip codes`)
            }
          }
          if (coveredBy) {
            warnings.push(`Already in the area "${coveredBy.title}"`)
          }
          return { zip, place, warnings }
        }),
      }
    },
  })

  const askOwner = tool({
    name: 'ask_owner',
    description:
      'Ask the person a short question and wait for their answer. Use it when the note is unclear, or when check_zip_codes warns about a zip code, before planning with it.',
    inputSchema: z.object({ question: z.string().min(5).max(300) }),
    execute: async ({ question }) => {
      if (run.questionsAsked >= MAX_QUESTIONS) {
        return {
          error: `You have asked ${MAX_QUESTIONS} questions. Plan with what you know, and say in your summary what is still unclear.`,
        }
      }
      run.questionsAsked++
      return { answer: await ask(question) }
    },
  })

  const proposeChanges = tool({
    name: 'propose_changes',
    description:
      'Hand back your plan: every change the note asks for. Nothing is saved yet: the person reads the plan and says yes or no. Call it once, last.',
    inputSchema: z.object({ changes: z.array(plannedChange).min(1).max(20) }),
    execute: async ({ changes }) => {
      for (const change of changes) {
        if (change.action === 'create') {
          continue
        }
        const listed = run.listed.get(change.id)
        if (!listed) {
          return {
            error: `No listed area has the id ${change.id}. Call list_service_areas and use its ids.`,
          }
        }
        if (listed.source === 'zipFeed') {
          return {
            error: `"${listed.title}" comes from the zip feed import, which overwrites it. Leave it out of the plan, and say so.`,
          }
        }
      }
      run.plan = changes
      return { proposed: changes.length }
    },
  })

  return [listAreas, checkZipCodes, askOwner, proposeChanges] as const
}
```

**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.

> [card] 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.

> [card] 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.

**10. Write the agent**

Create `app/services/service_map_agent.ts`:

```ts app/services/service_map_agent.ts theme={"dark"}
import { inject } from '@adonisjs/core'
import app from '@adonisjs/core/services/app'
import { maxCost, OpenRouter, stepCountIs } from '@openrouter/agent'
import { AposMode, SiteClient } from '@fetchski/attract-cms-sdk'
import type openrouterConfig from '#config/openrouter'
import {
  serviceMapTools,
  type AskOwner,
  type PlannedChange,
  type ServiceMapRun,
} from '#services/service_map_tools'
import { ZipCodeLookup } from '#services/zip_codes'

const INSTRUCTIONS = `You keep a business's service map up to date. You get a note, in
plain words, about where the business now works and what it charges there.

- list_service_areas first. Change what is already there before adding anything.
- check_zip_codes on every zip code you would add, together with two or three zip codes of
  the area it joins. When it warns about one (no such zip code, far from the others, or
  already in a different area), ask_owner about it before you plan with it.
- When the note is unclear (which area, which price), ask_owner. Ask one short question
  at a time, and at most three.
- Never invent a zip code or a price. If the note names a town but no zip codes, ask.
- An area the business no longer serves is turned off, never removed.
- End with propose_changes, once, with every change. Then write two or three plain
  sentences for the person: what you plan, and anything you left out and why.`

/** A plan the person can read before anything is saved. */
export interface ServiceMapPlan {
  changes: PlannedChange[] | null
  /** The agent's words for the person: its plan, or why it has none. */
  summary: string
  /** One plain line per change, with area names, not ids. */
  lines: string[]
  costUsd: number
}

/** One area the site saved, as it answered it. */
export interface SavedArea {
  id: string
  title: string
}

@inject()
export class ServiceMapAgent {
  constructor(
    protected site: SiteClient,
    protected openrouter: OpenRouter,
    protected zips: ZipCodeLookup
  ) {}

  /** The run's state, kept between a plan and the save that follows it. */
  protected run: ServiceMapRun = {
    listed: new Map(),
    places: new Map(),
    questionsAsked: 0,
    plan: null,
  }

  /**
   * Has the agent read the map, check the zip codes, ask what it must, and
   * plan the changes. It saves nothing. Pass the person's answer to an earlier
   * plan as `feedback`, and it plans again.
   */
  async plan(note: string, ask: AskOwner, feedback?: { lines: string[]; answer: string }) {
    const config = app.config.get<typeof openrouterConfig>('openrouter')
    this.run.plan = null
    this.run.questionsAsked = 0

    const input = feedback
      ? `The note:\n\n${note}\n\nYou planned:\n${feedback.lines.join('\n')}\n\nThe person said no, and: ${feedback.answer}\n\nPlan again.`
      : `The note:\n\n${note}`

    const result = this.openrouter.callModel({
      model: config.model,
      instructions: INSTRUCTIONS,
      input,
      tools: serviceMapTools(this.site, this.zips, ask, this.run),
      stopWhen: [
        stepCountIs(config.maxSteps),
        maxCost(config.maxCostUsd),
        () => this.run.plan !== null, // planned: the person decides now
      ],
    })
    const summary = await result.getText()
    const usage = await result.getUsage()

    return {
      changes: this.run.plan,
      summary: summary.trim(),
      lines: (this.run.plan ?? []).map((change) => this.describe(change)),
      costUsd: usage.cost ?? 0,
    } satisfies ServiceMapPlan
  }

  /** Saves the approved plan as drafts and sends each one for review. Code, not the agent. */
  async save(changes: PlannedChange[]): Promise<SavedArea[]> {
    const saved: SavedArea[] = []

    for (const change of changes) {
      if (change.action === 'create') {
        const { action, zipCodes, ...fields } = change
        const towns = [...new Set(zipCodes.flatMap((zip) => this.run.places.get(zip)?.town ?? []))]
        const area = await this.site.serviceAreas().createServiceArea({
          ...fields,
          zipCodes: zipCodes.join(', '),
          publicNames: towns.map((name) => ({ name, placeType: 'City' as const })),
          aposMode: AposMode.Draft,
        })
        saved.push({ id: area.id, title: area.title })
        continue
      }

      if (change.action === 'turn_off') {
        const area = await this.site
          .serviceAreas()
          .updateServiceArea({ id: change.id, active: false, aposMode: AposMode.Draft })
        saved.push({ id: area.id, title: area.title })
        continue
      }

      const { action, id, addZipCodes = [], removeZipCodes = [], ...fields } = change
      const current = this.run.listed.get(id)?.zipCodes ?? []
      const zipCodes =
        addZipCodes.length || removeZipCodes.length
          ? [...new Set([...current, ...addZipCodes])].filter(
              (zip) => !removeZipCodes.includes(zip)
            )
          : undefined
      const area = await this.site.serviceAreas().updateServiceArea({
        id,
        ...fields,
        zipCodes: zipCodes?.join(', '),
        aposMode: AposMode.Draft,
      })
      saved.push({ id: area.id, title: area.title })
    }

    for (const area of saved) {
      await this.site.serviceAreas().submitServiceArea({ id: area.id })
    }
    return saved
  }

  /** One plain line for a planned change, with the area's name. */
  protected describe(change: PlannedChange) {
    const price = (fields: { price?: number; priceLabel?: string }) =>
      fields.priceLabel ?? (fields.price === undefined ? null : `$${fields.price}`)

    if (change.action === 'create') {
      const at = price(change)
      return `+ New area "${change.title}": ${change.zipCodes.join(', ')}${at ? `, ${at}` : ''}`
    }
    const title = this.run.listed.get(change.id)?.title ?? change.id
    if (change.action === 'turn_off') {
      return `- "${title}": taken off the map`
    }
    const parts = [
      change.title && `renamed "${change.title}"`,
      change.addZipCodes?.length && `adds ${change.addZipCodes.join(', ')}`,
      change.removeZipCodes?.length && `drops ${change.removeZipCodes.join(', ')}`,
      price(change) && `now ${price(change)}`,
      change.description && 'new description',
    ].filter(Boolean)
    return `~ "${title}": ${parts.join('; ') || 'no change'}`
  }
}
```

**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.

**11. Add the command**

```bash Terminal theme={"dark"}
node ace make:command update_service_map
```

Replace the file it made:

```ts commands/update_service_map.ts theme={"dark"}
import { inject } from '@adonisjs/core'
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { ServiceMapAgent } from '#services/service_map_agent'

/** How many times the person may say "no, change this" before the command stops. */
const MAX_ROUNDS = 3

export default class UpdateServiceMap extends BaseCommand {
  static commandName = 'service-map:update'
  static description = 'Tell the agent what changed on your service map; it plans, you approve'
  static options: CommandOptions = { startApp: true }

  @inject()
  async run(agent: ServiceMapAgent) {
    const note = await this.prompt.ask(
      'What changed? Write it the way you would tell a colleague',
      {
        validate: (value) =>
          value.trim().length >= 20 || 'Give it a sentence or two: which zip codes, which prices',
      }
    )
    const ask = (question: string) => this.prompt.ask(question)

    let costUsd = 0
    let feedback: { lines: string[]; answer: string } | undefined

    for (let round = 1; round <= MAX_ROUNDS; round++) {
      this.logger.info('The agent is reading your service map…')
      const plan = await agent.plan(note.trim(), ask, feedback)
      costUsd += plan.costUsd

      if (!plan.changes) {
        this.logger.error('The agent has no plan: nothing was saved')
        this.logger.log(plan.summary)
        this.logger.info(`Cost: $${costUsd.toFixed(4)}`)
        this.exitCode = 1
        return
      }

      this.logger.log('')
      this.logger.log(plan.summary)
      this.logger.log('')
      for (const line of plan.lines) {
        this.logger.log(`  ${line}`)
      }
      this.logger.log('')

      // Each round's prompts get their own name, so a test can answer each one.
      const save = await this.prompt.confirm('Save these as drafts and send them for review?', {
        name: `save-${round}`,
      })
      if (save) {
        const saved = await agent.save(plan.changes)
        this.logger.success(`${saved.length} draft(s) saved and sent for review`)
        this.logger.info(`Cost: $${costUsd.toFixed(4)}`)
        return
      }

      const answer =
        round < MAX_ROUNDS
          ? await this.prompt.ask('What should be different? Leave it empty to stop', {
              name: `change-${round}`,
            })
          : ''
      if (!answer.trim()) {
        break
      }
      feedback = { lines: plan.lines, answer: answer.trim() }
    }

    this.logger.warning('Nothing was saved.')
    this.logger.info(`Cost: $${costUsd.toFixed(4)}`)
  }
}
```

**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.

**12. Format, lint and type-check**

```bash Terminal theme={"dark"}
npm run format
node ace codegen
npm run lint
npm run typecheck
```

**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

**1. Run the command**

```bash Terminal theme={"dark"}
node ace service-map:update
```

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

```text Terminal theme={"dark"}
❯ What changed? Write it the way you would tell a colleague · From the 1st we also cover 10003 and 44101, at $35 a visit. We stop going to the East side.
[ info ] The agent is reading your service map…
```

**2. Answer its questions**

When a zip code looks wrong, the agent asks before it plans. Type your answer and press **Enter**:

```text Terminal theme={"dark"}
❯ 44101 is in Cleveland, Ohio, about 400 miles from your other areas. Did you mean a different zip code? · Yes, a typo: leave it out
```

The wording is the agent's, so yours will read differently.

**3. Read the plan, then say yes or no**

```text Terminal theme={"dark"}
I'll add 10003 to Downtown at $35 a visit, and take East side off the map. I left 44101 out, as you said.

  ~ "Downtown": adds 10003; now $35
  - "East side": taken off the map

❯ Save these as drafts and send them for review? (y/N)
```

`+` is a new area, `~` a change to one, `-` an area taken off the map. Type `y` and press **Enter**:

```text Terminal theme={"dark"}
[ success ] 2 draft(s) saved and sent for review
[ info ] Cost: $0.0910
```

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.

**4. 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

> [accordion] The agent has no plan: nothing was saved
>
> 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`.

> [accordion] Give it a sentence or two: which zip codes, which prices
>
> The note was shorter than 20 characters. Type a fuller one at the same prompt.

> [accordion] The agent asks about a zip code you know is right
>
> 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`.

> [accordion] The lookup did not answer
>
> 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.

> [accordion] User not found.
>
> The command stops with `UnauthorizedResponseError: User not found.`: OpenRouter refused
> `OPENROUTER_API_KEY`. Copy the key again from
> [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys); it starts with `sk-or-v1-`.

> [accordion] 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.

> [accordion] 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`.

> [accordion] Published, but the map didn't change
>
> 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.

> [accordion] npm install answers 401 Unauthorized
>
> 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.

**1. The fake model**

```ts tests/helpers/fake_model.ts theme={"dark"}
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { OpenRouter } from '@openrouter/agent'

/** One answer from the model: tool calls to make, or its closing text. */
export type FakeTurn =
  { calls: { name: string; arguments: Record<string, unknown> }[] } | { text: string }

/**
 * A stand-in for OpenRouter on 127.0.0.1: it answers each request with the
 * next turn, in the Responses API's own stream format, and keeps every
 * request it was sent. Nothing leaves the machine and nothing is spent.
 */
export async function fakeModel(turns: FakeTurn[]) {
  const requests: Record<string, unknown>[] = []

  const server = createServer(async (request, response) => {
    let body = ''
    for await (const chunk of request) {
      body += chunk
    }
    requests.push(JSON.parse(body) as Record<string, unknown>)

    const turn = turns[requests.length - 1] ?? { text: 'Done.' }
    const output =
      'calls' in turn
        ? turn.calls.map((call, index) => ({
            type: 'function_call',
            id: `fc_${requests.length}_${index}`,
            call_id: `call_${requests.length}_${index}`,
            name: call.name,
            arguments: JSON.stringify(call.arguments),
            status: 'completed',
          }))
        : [
            {
              type: 'message',
              id: `msg_${requests.length}`,
              role: 'assistant',
              status: 'completed',
              content: [{ type: 'output_text', text: turn.text, annotations: [] }],
            },
          ]

    const completed = {
      type: 'response.completed',
      sequence_number: 1,
      response: {
        id: `resp_${requests.length}`,
        object: 'response',
        created_at: 1,
        completed_at: 2,
        model: 'test/fake-model',
        status: 'completed',
        error: null,
        incomplete_details: null,
        instructions: null,
        metadata: {},
        parallel_tool_calls: true,
        frequency_penalty: 0,
        presence_penalty: 0,
        temperature: null,
        top_p: null,
        tools: [],
        tool_choice: 'auto',
        output,
        usage: {
          input_tokens: 1000,
          output_tokens: 200,
          total_tokens: 1200,
          cost: 0.001,
          input_tokens_details: { cached_tokens: 0 },
          output_tokens_details: { reasoning_tokens: 0 },
        },
      },
    }
    response.writeHead(200, { 'content-type': 'text/event-stream' })
    response.end(`event: response.completed\ndata: ${JSON.stringify(completed)}\n\n`)
  })

  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
  const { port } = server.address() as AddressInfo

  return {
    client: new OpenRouter({ apiKey: 'test', serverURL: `http://127.0.0.1:${port}/api/v1` }),
    requests,
    close: () => new Promise<void>((resolve) => server.close(() => resolve())),
  }
}
```

**Why:** `serverURL` points a real `OpenRouter` client at it, so the test swaps in that client and
nothing else changes.

**2. The fake zip codes**

```ts tests/helpers/fake_zip_codes.ts theme={"dark"}
import { ZipCodeLookup, type ZipCodePlace } from '#services/zip_codes'

/** Answers zip codes from a list instead of the lookup service. Unknown ones don't exist. */
export class FakeZipCodes extends ZipCodeLookup {
  constructor(protected places: ZipCodePlace[]) {
    super()
  }

  async find(zip: string) {
    return this.places.find((place) => place.zip === zip) ?? null
  }
}

export const NEW_YORK = [
  { zip: '10001', town: 'New York City', state: 'NY', latitude: 40.7484, longitude: -73.9967 },
  { zip: '10002', town: 'New York City', state: 'NY', latitude: 40.7152, longitude: -73.9877 },
  { zip: '10003', town: 'New York City', state: 'NY', latitude: 40.7313, longitude: -73.9892 },
]
export const OHIO = {
  zip: '44101',
  town: 'Cleveland',
  state: 'OH',
  latitude: 41.4995,
  longitude: -81.6954,
}
```

**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.

**3. The agent's tests**

```ts tests/unit/service_map_agent.spec.ts theme={"dark"}
import { test } from '@japa/runner'
import app from '@adonisjs/core/services/app'
import { OpenRouter } from '@openrouter/agent'
import { factories, fakeAttract } from '@fetchski/attract-cms-adonis/testing'
import { ServiceMapAgent } from '#services/service_map_agent'
import { ZipCodeLookup } from '#services/zip_codes'
import { fakeModel } from '#tests/helpers/fake_model'
import { FakeZipCodes, NEW_YORK, OHIO } from '#tests/helpers/fake_zip_codes'

const NOTE = 'We now also cover 10003 and 44101, at $35 a visit. We stop going to the East side.'

/** The site's two areas before the note. */
const AREAS = factories.ServiceAreaList({
  results: [
    { id: 'downtown:en:draft', title: 'Downtown', zipCodes: '10001, 10002', source: 'manual' },
    { id: 'east:en:draft', title: 'East side', zipCodes: '11211', source: 'manual' },
  ],
  pages: 1,
})

test.group('ServiceMapAgent', () => {
  test('a far-off, a made-up and an already-covered zip code are each flagged', async ({
    assert,
    swap,
    cleanup,
  }) => {
    const model = await fakeModel([
      { calls: [{ name: 'list_service_areas', arguments: {} }] },
      {
        calls: [
          {
            name: 'check_zip_codes',
            arguments: { zipCodes: ['10003', '44101', '99999', '10001', '10002'] },
          },
        ],
      },
      { text: 'Stopped.' },
    ])
    cleanup(() => model.close())
    fakeAttract({ 'serviceAreas.listServiceAreas': AREAS })
    swap(OpenRouter, model.client)
    swap(ZipCodeLookup, new FakeZipCodes([...NEW_YORK, OHIO]))

    const agent = await app.container.make(ServiceMapAgent)
    await agent.plan(NOTE, async () => 'unused')

    const check = JSON.stringify(model.requests[2])
    assert.match(check, /About 4\d\d miles from the other zip codes/)
    assert.include(check, 'No such zip code')
    assert.include(check, 'Already in the area \\\\\\"Downtown\\\\\\"')
  })

  test('the agent asks, plans, and nothing is saved until save()', async ({
    assert,
    swap,
    cleanup,
  }) => {
    const site = fakeAttract({
      'serviceAreas.listServiceAreas': AREAS,
      'serviceAreas.updateServiceArea': factories.ServiceArea({
        id: 'downtown:en:draft',
        title: 'Downtown',
      }),
    })
    const model = await fakeModel([
      { calls: [{ name: 'list_service_areas', arguments: {} }] },
      {
        calls: [{ name: 'ask_owner', arguments: { question: '44101 is in Ohio. Is that right?' } }],
      },
      {
        calls: [
          {
            name: 'propose_changes',
            arguments: {
              changes: [
                { action: 'update', id: 'downtown:en:draft', addZipCodes: ['10003'], price: 35 },
                { action: 'turn_off', id: 'east:en:draft' },
              ],
            },
          },
        ],
      },
      { text: 'Add 10003 to Downtown at $35; 44101 left out, as you said.' },
    ])
    cleanup(() => model.close())
    swap(OpenRouter, model.client)
    swap(ZipCodeLookup, new FakeZipCodes([...NEW_YORK, OHIO]))
    const questions: string[] = []

    const agent = await app.container.make(ServiceMapAgent)
    const plan = await agent.plan(NOTE, async (question) => {
      questions.push(question)
      return 'No, a typo: leave it out'
    })

    assert.deepEqual(questions, ['44101 is in Ohio. Is that right?'])
    assert.deepEqual(plan.lines, [
      '~ "Downtown": adds 10003; now $35',
      '- "East side": taken off the map',
    ])
    site.assertNotCalled('serviceAreas.updateServiceArea')

    await agent.save(plan.changes!)

    site.assertCalled(
      'serviceAreas.updateServiceArea',
      (args) =>
        args.id === 'downtown:en:draft' &&
        args.zipCodes === '10001, 10002, 10003' &&
        args.aposMode === 'draft'
    )
    site.assertCalled(
      'serviceAreas.updateServiceArea',
      (args) => args.id === 'east:en:draft' && args.active === false && args.aposMode === 'draft'
    )
    site.assertCalledTimes('serviceAreas.submitServiceArea', 2)
    site.assertNotCalled('serviceAreas.publishServiceArea')
  })

  test('an id the agent never listed is refused, and there is no plan', async ({
    assert,
    swap,
    cleanup,
  }) => {
    const model = await fakeModel([
      {
        calls: [
          {
            name: 'propose_changes',
            arguments: { changes: [{ action: 'turn_off', id: 'made-up' }] },
          },
        ],
      },
      { text: 'I could not find that area.' },
    ])
    cleanup(() => model.close())
    fakeAttract()
    swap(OpenRouter, model.client)
    swap(ZipCodeLookup, new FakeZipCodes(NEW_YORK))

    const agent = await app.container.make(ServiceMapAgent)
    const plan = await agent.plan(NOTE, async () => 'unused')

    assert.isNull(plan.changes)
    assert.include(JSON.stringify(model.requests[1]), 'No listed area has the id made-up')
  })
})
```

**4. The command's tests**

```ts tests/unit/update_service_map.spec.ts theme={"dark"}
import { test } from '@japa/runner'
import ace from '@adonisjs/core/services/ace'
import { OpenRouter } from '@openrouter/agent'
import { factories, fakeAttract } from '@fetchski/attract-cms-adonis/testing'
import UpdateServiceMap from '../../commands/update_service_map.js'
import { ZipCodeLookup } from '#services/zip_codes'
import { fakeModel } from '#tests/helpers/fake_model'
import { FakeZipCodes, NEW_YORK } from '#tests/helpers/fake_zip_codes'

const QUESTION = 'What changed? Write it the way you would tell a colleague'
const NOTE = 'We now cover 10003 too, in a new area called Union Square, at $40 a visit.'

/** The model's turns: it plans one new area, then says so. */
const PLANNING = [
  {
    calls: [
      {
        name: 'propose_changes',
        arguments: {
          changes: [{ action: 'create', title: 'Union Square', zipCodes: ['10003'], price: 40 }],
        },
      },
    ],
  },
  { text: 'One new area, Union Square.' },
]

test.group('service-map:update', (group) => {
  group.each.setup(() => {
    ace.ui.switchMode('raw')
    return () => ace.ui.switchMode('normal')
  })

  test('yes saves the plan as drafts, for review', async ({ swap, cleanup }) => {
    const site = fakeAttract({
      'serviceAreas.createServiceArea': factories.ServiceArea({
        id: 'union:en:draft',
        title: 'Union Square',
      }),
    })
    const model = await fakeModel([
      { calls: [{ name: 'check_zip_codes', arguments: { zipCodes: ['10003'] } }] },
      ...PLANNING,
    ])
    cleanup(() => model.close())
    swap(OpenRouter, model.client)
    swap(ZipCodeLookup, new FakeZipCodes(NEW_YORK))

    const command = await ace.create(UpdateServiceMap, [])
    command.prompt.trap(QUESTION).replyWith(NOTE)
    command.prompt.trap('save-1').accept()
    await command.exec()

    command.assertSucceeded()
    command.assertLog('  + New area "Union Square": 10003, $40')
    site.assertCalled(
      'serviceAreas.createServiceArea',
      (args) =>
        args.aposMode === 'draft' &&
        args.zipCodes === '10003' &&
        JSON.stringify(args.publicNames) === '[{"name":"New York City","placeType":"City"}]'
    )
    site.assertCalled('serviceAreas.submitServiceArea', (args) => args.id === 'union:en:draft')
  })

  test('no, then what to change, plans again', async ({ assert, swap, cleanup }) => {
    const site = fakeAttract({
      'serviceAreas.createServiceArea': factories.ServiceArea({
        id: 'gramercy:en:draft',
        title: 'Gramercy',
      }),
    })
    const model = await fakeModel([...PLANNING, ...PLANNING])
    cleanup(() => model.close())
    swap(OpenRouter, model.client)
    swap(ZipCodeLookup, new FakeZipCodes(NEW_YORK))

    const command = await ace.create(UpdateServiceMap, [])
    command.prompt.trap(QUESTION).replyWith(NOTE)
    command.prompt.trap('save-1').reject()
    command.prompt.trap('change-1').replyWith('Call it Gramercy')
    command.prompt.trap('save-2').accept()
    await command.exec()

    command.assertSucceeded()
    assert.include(JSON.stringify(model.requests[2]), 'The person said no, and: Call it Gramercy')
    site.assertCalledTimes('serviceAreas.createServiceArea', 1)
  })

  test('no, and nothing to change, saves nothing', async ({ swap, cleanup }) => {
    const site = fakeAttract()
    const model = await fakeModel(PLANNING)
    cleanup(() => model.close())
    swap(OpenRouter, model.client)
    swap(ZipCodeLookup, new FakeZipCodes(NEW_YORK))

    const command = await ace.create(UpdateServiceMap, [])
    command.prompt.trap(QUESTION).replyWith(NOTE)
    command.prompt.trap('save-1').reject()
    command.prompt.trap('change-1').replyWith('')
    await command.exec()

    command.assertSucceeded()
    command.assertLogMatches(/Nothing was saved/)
    site.assertNothingCalled()
  })

  test('a note too short is refused at the prompt', async ({ swap, cleanup }) => {
    const site = fakeAttract()
    const model = await fakeModel([{ text: 'That note has no zip codes or prices.' }])
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const command = await ace.create(UpdateServiceMap, [])
    command.prompt
      .trap(QUESTION)
      .assertFails('hi', 'Give it a sentence or two: which zip codes, which prices')
      .replyWith('Something changed on the map, not sure what yet.')
    await command.exec()

    command.assertFailed()
    command.assertLog('That note has no zip codes or prices.')
    site.assertNothingCalled()
  })
})
```

**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.

**5. Run them**

```bash Terminal theme={"dark"}
npm run format
node ace test
```

**You should see:**

```text Terminal theme={"dark"}
[ info ] loading hooks...
[ info ] generating indexes...
[ info ] codegen: created 5 file(s)
[ info ] booting application to run tests...
✔✔✔✔✔✔✔

 PASSED

Tests  7 passed (7)
```

## Turn it off

> [card] 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.

> [card] Revoke the OpenRouter key
>
> Delete it at [openrouter.ai/settings/keys](https://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.

## Related

> [card] [Update a service area](https://docs.fetchski-attract.com/adonisjs/reference/service-areas/update-service-area.md)
>
> Every field `updateServiceArea()` takes

> [card] [Testing in AdonisJS](https://docs.fetchski-attract.com/adonisjs/testing.md)
>
> `fakeAttract()` and the assertions

> [card] [Required scopes](https://docs.fetchski-attract.com/scopes.md)
>
> The scope each operation needs

> [card] [Turn a voice memo into a blog post draft](https://docs.fetchski-attract.com/recipes/adonis-agent-voice-memo-to-blog-post.md)
>
> The same kind of agent, on your blog


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.