URL: /capabilities/measure/search-readout · Id: `page:/capabilities/measure/search-readout`

# Search readout

> Each week: where each saved term ranks and how it moved, which engines name you, and the clicks Search Console counted.

Each week: where each saved term ranks and how it moved, which engines name you, and the clicks [Search](/plays?kind=search) Console counted.

```text title="Run it · Ask"
Tell me how my rankings and AI answers moved since September, spend up to $1, and show me the plan first.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Keyword.track({ goal: 'Tell me how my rankings and AI answers moved since September, spend up to $1, and show me the plan first.', budget: { amount: 1, currency: 'USD' } })
```

```json title="Run it · Response"
{
  "mode": "try",
  "stored": false,
  "estimate": "About 98¢ per 50 terms",
  "sample": {
    "term": "rent ledger template",
    "position": "14",
    "change": "−9",
    "clicks": "31",
    "named by": "perplexity"
  }
}
```

- **Kind:** Workflow
- **Price:** 98¢ per 50 terms
- **Your time:** 5 minutes
- **Approval:** Runs on its own
- **Deepens:** [`process:content-marketing`](https://startupbuilder.dev/workflows/processes/content-marketing)

## What you get
- **50 terms** · Each week: where each saved term ranks and how it moved, which engines name you, and the clicks Search Console counted. · [`noun:keyword`](https://startupbuilder.dev/business/nouns/keyword)
- `terms[].position` · Rank in the top 100, or `null` when it is not there; never 0.
- `terms[].change` · Places moved since last week: −9 is nine places up.
- `terms[].clicks` · Clicks Search Console counted for the term this week; it lags 2 to 3 days.
- `answerShare` · Answers naming you, over all answers this week. Read it as a four-week average: one week moves with the engines, not with you.
- `organicVisits` · Visits from search this week, from Analytics.

**Connect once:** [Google Search Console](/code/integrations/google-search-console), [Google Analytics](/code/integrations/google-analytics). **How long:** About 3 minutes.

## Trigger
It runs weekly at 07:00 on the first weekday, as a Monitor over the saved terms and questions. A move past a threshold emits `Keyword.measured` with `change`, and Triggers fire on it.

```ts
every('mon 07:00', ($) => $.Keyword.track({ site: 'leaseline.app', terms: 'saved', questions: 'saved' }))
```

## Steps
Your agent does these steps, one row per step; each step’s check is part of its row and its price.

| Step | Verb | Returns | Price | Approval |
| --- | --- | --- | --- | --- |
| 1. Read the results page for each saved term | [`Keyword.search`](/code/data/keywords/search) | Your position in the top 100, and who holds the slots above you | About 1.5¢ | Runs on its own |
| 2. Ask each engine each question three times | [`Keyword.ask`](/code/data/keywords/ask) | Who each answer names and cites | About 96¢ | Runs on its own |
| 3. Read the week’s clicks from Search Console | [`Integration.call`](/code/integrations/calls/call) | Clicks and impressions per term for the last 7 days | Free | Runs on its own |
| 4. Read organic visits and answer share | [`Metric.query`](/code/apps/analytics-and-experiments/query) | `organic-visits` and `answer-share` this week, beside last week | Free | Runs on its own |
| 5. Write the readout row per term | `Collection.query` | Position, change, clicks and cited or not, one row per term | Free | Runs on its own |

Calls: `Keyword.search`, then `Keyword.ask`, then `Integration.call`, then `Metric.query`, then `Collection.query`.

About 98¢ per 50 terms, each time it runs, capped at $1.50.

## Example: give it a goal
> Tell me how my rankings and AI answers moved since September, spend up to $1, and show me the plan first.

The plan it shows runs 5 steps, priced before it spends anything; Run it above shows the result it returns.

## When something fails

| When | Then |
| --- | --- |
| A term is not in the top 100 | It reads `position: null`, never 0, and its change is blank that week. |
| Search Console has a gap that week | `clicks` is empty for the week with a note; the ranks and answers still land. |
| You call it twice in one ISO week | The second call returns the stored readout at $0: it is idempotent per site and week. |
| Goal mode is asked for a one-off look back | It reads the stored weekly Events first at $0 and pays only for the weeks missing; repeating the readout stays the Monitor’s job. |

Each step retries three times, then falls through to the next route; one item's failure lands in `skipped` with its reason, never failing the call. Before a step would cross `maxCost` it stops and returns what it has, with a `resume` link. Each step's idempotency key is the call's key plus the step and the item: `leaseline-keyword-track-1-<step>-<item>`.

## Change it
| Ask | Compiles to |
| --- | --- |
| Tell me the day a term drops off page one. | [`Monitor.create`](/code/data/monitors/create) |
| Ask once per engine instead of three times to halve the cost. | [`Keyword.track`](/capabilities/measure/search-readout) |
| Refresh any page that slipped five places in a month. | [`Site.grow`](/capabilities/make-content/monthly-search-content) |

```ts
import { $, workflow } from 'startup-builder'

export default workflow({
  name: 'keyword-track',
  verb: 'Keyword.track',
  input: { site: 'string', terms: "string[] | 'saved'", questions: "string[] | 'saved'" },
  trigger: { schedule: 'mon 07:00' },
  maxCost: 1.5,
  execute: async (ctx, { site, terms = 'saved', questions = 'saved', engines = ['chatgpt', 'perplexity', 'gemini', 'google-ai-mode'], runs = 3 }) => {
    const ranks = await ctx.step('ranks', () => $.Keyword.search({ keywords: terms, venue: 'google-search', site, maxCost: 0.05 }))
    const answers = await ctx.step('answers', () => $.Keyword.ask({ questions, venues: engines, runs, maxCost: 1.1 }))
    const console = await ctx.step('console', () => $.Integration.call({ integration: 'google-search-console', op: 'performance', days: 7 }))
    const m = await ctx.step('metrics', () => $.Metric.query({ metrics: ['organic-visits', 'answer-share'], period: 'week' }))
    return readout(ranks, answers, console, m)
  },
})
```

Extend it with `extends: 'Keyword.track'` and `base()`, or [compose your own](/code/compose) from these steps.

## Works with
**Plays that order it:** [Answer engine optimization](/plays/aeo), [Editorial SEO](/plays/editorial-seo), [Programmatic SEO](/plays/programmatic-seo).

It serves [Measure](/capabilities/measure), and runs in [Launch and search](/strategy/launch-and-search). It deepens [`process:content-marketing`](https://startupbuilder.dev/workflows/processes/content-marketing).

## For agents

| Fact | Value |
| --- | --- |
| Guide | `Keyword.track` |
| Guide approval | `limits` |
| Goal | Tell me how my rankings and AI answers moved since September, spend up to $1, and show me the plan first. It may change counts, the order of steps, soft filters and the route a read takes, and it stops early when the goal is met. It never relaxes your words, raises its own budget, answers its own approval, calls a Play or acts outside your standing approval. |
| Trigger | `every('mon 07:00', ($) => $.Keyword.track({ site: 'leaseline.app', terms: 'saved', questions: 'saved' }))` |
| Retry | Three times with backoff, then the next route; the same idempotency key never charges, sends or posts twice |
| Idempotency key | `leaseline-keyword-track-1` |
| Lookups | A `find:` input asks unless exactly one record fits |

| Step | Action class | Seat rule that would hold it |
| --- | --- | --- |
| 1. Read the results page for each saved term | None | None. |
| 2. Ask each engine each question three times | None | None. |
| 3. Read the week’s clicks from Search Console | None | None. |
| 4. Read organic visits and answer share | None | None. |
| 5. Write the readout row per term | None | None. |

The amounts live once, on the hub ([approvals](https://startupbuilder.dev/agents/approvals)).

| Step | Function | Doer | Checker | Assurance | Inputs |
| --- | --- | --- | --- | --- | --- |
| 1. Read the results page for each saved term | `code` | Code | Code checks each result against its declared type | `measured` | `$0.site, $0.terms` |
| 2. Ask each engine each question three times | `code` | Code | Code checks each result against its declared type | `measured` | `$0.questions, $0.engines, $0.runs` |
| 3. Read the week’s clicks from Search Console | `code` | Code | Code checks each result against its declared type | `measured` | `$0.site` |
| 4. Read organic visits and answer share | `code` | Code | Code checks each result against its declared type | `measured` | `$0.site` |
| 5. Write the readout row per term | `code` | Code | Code checks each result against its declared type | `measured` | `$1, $2, $3, $4` |

The doer is never the checker, and the weakest assurance on the path is the whole call’s.

## Under the hood

| Kind | What it touches |
| --- | --- |
| Systems | [Analytics and experiments](/code/apps/analytics-and-experiments) |
| Nouns | [`noun:keyword`](https://startupbuilder.dev/business/nouns/keyword), [`noun:integration`](https://startupbuilder.dev/business/nouns/integration), [`noun:metric`](https://startupbuilder.dev/business/nouns/metric), [`noun:collection`](https://startupbuilder.dev/business/nouns/collection) |
| Events | `Integration.called`, `Metric.queried`, `Collection.queried` |

## Reference

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `site` | `Hostname` | Required | Your site. |
| `terms` | `string[] \| 'saved'` | `'saved'` | The terms to rank, or every term your Keyword opportunity map marked `earn`. |
| `questions` | `string[] \| 'saved'` | `'saved'` | The buyer questions to ask the answer engines, or the ones your AI answer audit saved. |
| `engines` | `VenueId[]` | `['chatgpt', 'perplexity', 'gemini', 'google-ai-mode']` | The answer engines asked. |
| `runs` | `integer` | `3` | Asks per question per engine; share is read across all of them. |
| `maxCost` | `number` | `1.50` | The most this call spends each time it runs, in USD. It stops before a step would cross it and returns what it has, with a `resume` link. |

Emits `Keyword.tracked`. Refuses with [`max-cost`](/code/refusals#max-cost) before it spends, and with the Data calls and Accounts groups ([refusals](/code/refusals)).