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

# UX Digest at Scale: Several Areas, Several Teams, One Pipeline

> Split the UX digest into three scheduled agents with an observations table, a run ledger, and per-area digests. For teams that outgrow the single routine.

This is the scaled-up form of the [UX digest](/docs/recipes/ux-digest). Start there. The single routine on that page covers one product area and one channel. Split it into the three agents below when you have several areas with different owners, when triage needs to fan out across subagents, or when you want a cost ledger per run.

It runs as three agents on a schedule. The observer reviews a sample of recent sessions and writes short, evidence-linked observations. The trend agent groups new observations into themes and keeps the counts moving week over week. The publisher posts a digest of the themes that changed, with a link from every theme down to the sessions behind it.

You can hand this page to your own agent. It describes behaviors and contracts rather than one implementation, so your agent can use the store, channel, and scheduler you already have and build the glue to fit. The pilot numbers below come from a reference build on Notion, Slack, and Claude Code.

## What you need

* The **Subtext capture snippet** installed and recording real sessions. Start with [Install the capture snippet](/docs/install/overview) if you haven't.
* A **Subtext MCP** connection your agent can use unattended. The official [Subtext plugin](/docs/install/manual#install-the-subtext-plugin-for-your-agent) for Claude Code, Cursor, and Codex configures it and runs inside each harness's scheduling and automation features. For any other harness, connect to the hosted server at `https://api.fullstory.com/mcp/subtext` with an API key (`Authorization: Bearer YOUR_SUBTEXT_API_KEY`) rather than interactive OAuth.
* **A store with two tables** your agent can read and write through a tool: Notion, Linear, Airtable, a Postgres table, a Google Sheet, or two JSONL files in a repo. It needs a relation (or a foreign key) from observations to themes.
* **A channel** where the team already reads, with a tool that can post a message. Slack is the obvious fit. GitHub Discussions or a Linear project update also work.
* **A scheduler** that can run a prompt unattended: Claude Code scheduled tasks, a cron job driving any MCP-capable agent, a Linear [Loop](/docs/recipes/linear), or a hosted routine.
* **A route list** for each product area you care about. Two or three URL fragments per area is enough. A [sightmap](/docs/session-review/overview#what-the-sightmap-adds) gives you this for free if you have one.

<Note>
  Nothing here is a daemon. Each agent is a prompt that runs on a schedule with MCP access to Subtext, your store, and your channel. The scheduler and the headless runner come from your harness. This recipe supplies the contract between the three runs, so they can hand work to each other without a shared process.
</Note>

## The shape

```mermaid theme={null}
flowchart LR
  S[(Subtext sessions)] --> O[Observer<br/>per area]
  O -->|writes| OB[(Observations)]
  OB --> T[Trend]
  T -->|creates / updates| TH[(Themes)]
  TH --> P[Publisher]
  P -->|digest| C[Channel]
  O & T & P -.->|append| L[(Run ledger)]
  L -.->|gate| P
```

Three rules hold the shape together.

1. **Observations are immutable evidence. Themes are mutable memory.** An observation is written once and points at one session at one moment. A theme is rewritten every run it grows, and it points at its observations rather than copying them.
2. **Only the trend agent writes theme content. Only the publisher writes the "last published" fields. Only people move a theme's status.** Each run touches its own columns and no others, so a crash mid-run never leaves a half-owned row.
3. **The publisher gates on the trend run's ledger line.** If trend did not succeed today, nothing posts. Stale themes beat half-rebuilt ones.

### Pick a cadence

The three agents run in order: observer, then trend, then publisher. How often the cycle repeats is up to you.

* **Daily** is the default. Most apps produce a fresh sample every 24 hours, and the digest lands before the team's day starts.
* **Weekly** suits a low-traffic app or an internal tool. Running more often than the traffic supports spends credits re-reviewing the same handful of sessions.
* **Never more often than the search window.** The observer searches "since the last run." A run that starts before new sessions exist finds nothing and stops.

`Weekly Counts` on a theme aggregates by ISO week regardless of cadence, so trend direction reads the same either way.

## Pick your parts

Settle these before your agent builds anything. They are the only vendor-specific choices.

| Decision           | What it needs to do                                                                            | Reference choice                                             |
| ------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| Observations table | Create rows in small batches. Filter by session id and run id. Filter for rows with no theme.  | Notion database                                              |
| Themes table       | Read every row. Update one row's properties in a single call. Hold a relation to observations. | Notion database                                              |
| Channel            | Post a markdown message.                                                                       | Slack MCP, posting as a person                               |
| Product areas      | A name, an owner key (a Jira project, a team slug), a list of route fragments, and a channel.  | A YAML file, mirrored into a Notion table for people to read |
| Scheduler          | Run three prompts in order, on your cadence.                                                   | Cron driving Claude Code                                     |
| Run ledger         | Append one JSON line per run. Read back "did agent X succeed on UTC date D?"                   | `telemetry/runs.jsonl`                                       |
| Models             | Cheap model for triage, capable model for deep review and for grouping.                        | Sonnet for pass 1, Opus for pass 2 and trend                 |

Whatever the store, keep the same column names everywhere. The prompts refer to them by name, and a rename in one place breaks two agents.

## The data model

### Observation

One row per finding, written by the observer, never edited by an agent afterward. A person may set `Status` to `Dismissed`.

| Field              | Rule                                                                                                                                                                                               |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Observation`      | The title. A specific claim, not a category. "Report list stays blank after a 504 on ListReports", not "reports error".                                                                            |
| `Insight`          | What happened, with exact figures and endpoint names, then the user impact. Ends with up to three lines: `Route: <matched route>`, `Flow: <page flow, ' > ' separated>`, `Evidence at: +14,270ms`. |
| `Observation Type` | One of `Friction`, `Error`, `Confusion`, `Abandonment`, `Success`, `Opportunity`.                                                                                                                  |
| `Severity`         | `Critical`, `High`, `Medium`, `Low`.                                                                                                                                                               |
| `Confidence`       | 0 to 1.                                                                                                                                                                                            |
| `Product Team`     | The owner key of the area the *problem* belongs to, which may differ from the area that was sampled.                                                                                               |
| `Codes`            | One or more from the fixed taxonomy below. Validation rejects anything else.                                                                                                                       |
| `Tags`             | Free-form, lowercase, hyphenated. At most two new tags per observation.                                                                                                                            |
| `Session ID`       | `<device id>:<session id>`, copied from the search result.                                                                                                                                         |
| `Session URL`      | The session URL with the evidence moment stamped on as a third colon segment in absolute epoch milliseconds. The replay player opens there.                                                        |
| `Session Time`     | Session start, ISO 8601 UTC with a trailing `Z`, copied from the search result.                                                                                                                    |
| `Routine Run ID`   | `YYYY-MM-DD-observer-<area key>`. Lets a retry find what it already wrote.                                                                                                                         |
| `Themes`           | Relation, filled from the theme side. Empty means "not yet themed".                                                                                                                                |

**The codes taxonomy.** Twelve fixed codes. Tags can grow; codes cannot. That split is what lets the trend agent match a new observation to a three-week-old theme.

| Code                  | Meaning                                                             |
| --------------------- | ------------------------------------------------------------------- |
| `silent-failure`      | A request failed and the UI showed nothing.                         |
| `dead-end-state`      | The user reached a screen with no way forward.                      |
| `unhandled-exception` | A console exception during the flow.                                |
| `slow-first-paint`    | First paint or largest paint far behind load, or over 3s.           |
| `slow-task-response`  | A user action waited more than a few seconds for a result.          |
| `abandon-before-load` | The session ended seconds after opening, before content rendered.   |
| `wrong-input-target`  | The user typed or clicked somewhere the app did not expect.         |
| `mode-confusion`      | The user acted as if the app were in a different state than it was. |
| `unstable-url-state`  | The URL and the visible state disagreed, or a reload lost state.    |
| `tool-switch-abandon` | The user left for another tool mid-task and did not come back.      |
| `usage-concentration` | Heavy use of one narrow path, worth knowing about.                  |
| `success-path`        | A clean run through the flow, recorded as a baseline.               |

### Theme

One row per recurring pattern. Created and updated by the trend agent. People move the status in the store.

| Field                                                                | Rule                                                                                                                                                                            |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Theme`                                                              | A problem statement, at most 80 characters.                                                                                                                                     |
| `Slug`                                                               | `THM-<area key lowercase>-<3-to-5-word-pattern>`. Assigned once, never changed. Titles can be rewritten; slugs are how a ticket and last week's digest refer to the same thing. |
| `Summary`                                                            | One sentence, at most 160 characters, plain words. Who is affected and what goes wrong. No endpoint names, no error codes. This is the only prose the digest shows.             |
| `Hypothesis`                                                         | Suspected cause, one or two sentences.                                                                                                                                          |
| `Proposed Experiment`                                                | What to change and which metric moves.                                                                                                                                          |
| `Product Team`                                                       | The owner key. A theme never spans teams.                                                                                                                                       |
| `Severity`                                                           | Highest across all linked observations. Only ever rises.                                                                                                                        |
| `Codes`, `Tags`                                                      | Union across linked observations.                                                                                                                                               |
| `Observations`                                                       | Relation. The full list, existing plus new, on every write. A link left out is a link removed.                                                                                  |
| `Observation Total`                                                  | Length of that list, as a plain number. Written every run because a rollup can't be read back reliably through most tools.                                                      |
| `Weekly Counts`                                                      | Plain text, one `YYYY-Www: n` line per ISO week with at least one observation, oldest first. Never JSON.                                                                        |
| `Trend`                                                              | `New` on create. Then `Rising`, `Falling`, or `Flat` from the last two weeks of `Weekly Counts`, a missing week counting as zero.                                               |
| `Status`                                                             | `New`, `Tracked`, `Ticketed`, `Shipped`, `Verified fixed`, `Closed`. Set to `New` on create. Moved only by people, in the store.                                                |
| `Jira Key`, `Shipped On`                                             | Set only by people.                                                                                                                                                             |
| `Synthesis Run ID`, `Last Synthesized`                               | Stamped by trend on every write.                                                                                                                                                |
| `Last Published At`, `Last Published Status`, `Last Published Total` | Written by the publisher after a successful post. `has_changed` compares current values against these.                                                                          |

## Agent 1: the observer

The observer runs once per enabled area. It spends a small, predictable number of credits and comes back with a handful of observations a reader could act on.

<Steps>
  <Step title="Fix the sample size">
    Fifty to eighty summarized sessions per area per run is enough to see the trends and anomalies behind a targeted search. Reviewing more mostly re-reads the same patterns at a much higher token cost, so the sample does not scale with traffic.

    ```python theme={null}
    SUMMARIES = 80      # sessions that get a review-summary, per area per run
    DEEP_REVIEWS = 10   # sessions that get a full review-open
    BASELINE = 2        # of the deep reviews, drawn at random from low scorers
    ```

    When an area has fewer sessions than that in the window, review what is there. When it has many more, narrow the search with a tighter route fragment or the anomaly clause alone, rather than raising the sample.
  </Step>

  <Step title="Search twice per area">
    `review-search` matches substrings in navigation URLs, so reduce each route to a literal fragment first. Take the longest run of non-wildcard segments; when two runs tie, take the later one, because every route shares the same prefix and the distinguishing segments come after the wildcard. `/ui/*/reports/v2/**` becomes `/reports/v2`, and `/ui/*/session/**` becomes `/session`.

    The search index covers page navigations, custom events, and requests with status 400 or greater. It does not index clicks, console messages, or successful requests. That is why the baseline search below is navigation-only.

    Run two searches, one credit each.

    ```json Anomaly search: touched the area and had a failed request theme={null}
    { "since": "24h", "limit": 100,
      "where": { "and": { "operands": [
        { "or": { "operands": [
          { "has": { "match": { "navigate": { "url": { "contains": "/reports/v2", "case_insensitive": true } } } } },
          { "has": { "match": { "navigate": { "url": { "contains": "/reports/share", "case_insensitive": true } } } } }
        ] } },
        { "has": { "match": { "network": { "status": { "gte": 400 } } } } }
      ] } } }
    ```

    The baseline search is the same predicate without the network clause. Set `since` to the time since the last run. Merge the two result sets, dedupe by `device_id:session_id`, and take `SUMMARIES` sessions, anomaly hits first. Keep a `source` flag on each session (`anomaly` or `baseline`); once the sets are merged nothing else tells them apart, and the baseline slice below needs it.

    Each result line carries the session URL and its start time. Store both verbatim. Do not rebuild a URL from its parts.

    If the search returns nothing, write a failed ledger line and post one line to the channel saying the run stopped. A silent stop looks the same as a scheduler that never fired.
  </Step>

  <Step title="Pass 1: triage on a cheap model">
    Fan out in batches of about 15 sessions. Each subagent calls `review-summary` per session (one credit, no session handle, nothing to close) and scores it 0 to 10 on how likely a full review is to find something worth recording.

    ```text Pass 1 prompt sketch theme={null}
    For each session, call review-summary with its session URL. Read the transcript.
    Do not call review-open.

    Score each session 0-10 on how likely a full review is to find something worth
    recording. The summary opens with a map of signal counts by kind, including an
    error count. Start there.

    Raise the score for: a 5xx, or a 4xx on the request behind the user's own
    action, especially after a long wait; a console exception during an action; a
    page paint far behind the load event or over 4 seconds, when the user reacted
    to it by clicking again, reloading, or leaving; a failed action with no retry;
    repeated searches or filter changes that keep returning nothing; a detour to
    help docs or a support tool mid-task.

    Score low for: a session under ten seconds with no error, which is a bounce; a
    long idle tail of background polling, which is a tab left open; repeated 401 or
    403 on a feature the user is not entitled to, which is a gate working; ordinary
    successful use. One exception: an unusually clean success path is worth a
    middling score, so the trend agent can tell "this got worse" from "this was
    always bad".

    Return JSON only, one entry per session, including the dull ones:
      {"device_id": "...", "session_id": "...", "score": 8,
       "reason": "The chat pane stayed blank for ten seconds and the user left",
       "suspected_codes": ["silent-failure", "dead-end-state"]}

    Write `reason` as one plain sentence about what the user experienced, under 140
    characters. No counts of errors, no lists of endpoints, no exception names.
    Use only codes from the taxonomy.
    ```

    Write each batch's JSON to a file and reply with one line. Transcripts must never travel back to the orchestrator; they would fill its context before pass 2 starts.

    Then pick `DEEP_REVIEWS` sessions: the top scorers, minus `BASELINE` slots filled **at random** from sessions that scored 3 or below, preferring ones the baseline search returned. Without the baseline slice the digest reads as relentlessly negative and the trend agent can't separate "got worse" from "always was".

    Before dispatching pass 2, query the Observations table for this run id and drop any session already recorded. A retry then costs nothing.

    <Tip>
      Some harnesses run a permission classifier over subagent dispatches. In the pilot, three pass 2 dispatches were refused on the wording of `reason` alone ("cascading 403s across \~15 endpoints"). If a dispatch is refused, rewrite the reason in plain words about what the user experienced and retry once. Count the refusals. A session that ends up neither reviewed nor closed makes the run fail, and the report should name it.
    </Tip>
  </Step>

  <Step title="Pass 2: full review on a capable model">
    One subagent per selected session, one observation or `null` back. Most sessions produce `null`, and that is fine.

    ```text Pass 2 prompt sketch theme={null}
    You review exactly one session and return at most one observation.

    1. review-open with the session URL. Keep the client_id. Write it to your result
       file immediately, before you read anything else.
    2. Read the map first: page flow, signal counts by kind, error tags. Form one or
       two hypotheses before zooming.
    3. review-zoom with resolution {"error": "standard"} when the map shows errors.
       Narrow with t0_ms/t1_ms for a suspect window. Drop to "machine" or "detail"
       only on the one kind your hypothesis needs. If a response is too large,
       narrow the window and try again. Never abandon with the session open.
    4. review-snapshot only when pixels settle something a transcript cannot: a
       blank pane, a misleading empty state, a stuck spinner.
    5. review-close(client_id, use_case="ux_review", was_helpful=<true|false>) when
       done. Always, including on error paths. All three arguments are required.

    Record an observation only when a reader could act on it or learn from it.
    Tag the area the problem belongs to, not every area the user passed through.
    If this was a baseline pick and the path was clean, record a Success
    observation with the success-path code.

    Cite exact figures: "404 after 10,648ms", "FCP 7,892ms". Never invent a number.
    Set evidence_offset_ms to the session-relative moment a reader should land on.

    Privacy: replace customer org names, domains, email addresses, and anything the
    user typed with <customer org>, <customer domain>, <user input>. In the flow,
    replace org ids with * so flows compare across customers.

    Reply with two lines: the client_id line, then "observation" or "null". The
    full JSON goes in your result file.
    ```

    A subagent that dies mid-review sends no reply, so the `client_id` it wrote on open is the only handle the orchestrator has to close the session. After the fan-out, sweep the files rather than the replies: a file with a `client_id` line and no JSON gets closed by the orchestrator with `was_helpful=false`. Unclosed sessions leak.
  </Step>

  <Step title="Validate, stamp the evidence moment, write">
    Validate every record before writing: allowed enum values, codes inside the taxonomy, a parseable `session_time` with a zone. Fix a bad record; never write around the validator.

    Stamp the evidence moment onto the session URL as a third colon-separated segment in absolute epoch milliseconds: session start plus `evidence_offset_ms`. Both URL shapes take it.

    ```text theme={null}
    .../session/<device>:<session>            ->  .../session/<device>:<session>:1789162229921
    .../client-session/<uuid>:<uuid>          ->  .../client-session/<uuid>:<uuid>:1789162229921
    ```

    Write the same moment into `Insight` as `Evidence at: +14,270ms`, so a reader who follows the link and one who reads the row land on the same instant.

    If your store has a fixed-option tag column, collect every new tag across the run and extend the option list once before the first write. In most stores that update replaces the whole option list, so send every existing option plus the new ones, and never rename or drop one. Observations and Themes each carry their own tag column; updating one does not update the other.

    Write in small batches. The reference implementation writes two rows per call because larger batches were rejected.
  </Step>

  <Step title="Append the ledger line">
    ```json theme={null}
    {"agent": "observer", "area": "REPORTS", "run_id": "2026-09-11-observer-REPORTS",
     "started_at": "2026-09-11T05:00:12Z", "ended_at": "2026-09-11T05:41:03Z",
     "succeeded": true, "credits": 142,
     "sessions_searched": 57, "sessions_summarized": 40, "sessions_reviewed": 10,
     "observations_written": 7, "tokens_in": 900000, "tokens_out": 45000, "cost_usd": 12.00,
     "pass1_tokens": 300000, "pass2_tokens": 600000, "pass1_cost_usd": 1.50, "pass2_cost_usd": 10.50}
    ```

    Timestamps are UTC with a trailing `Z`. The publisher's gate matches `ended_at` by its UTC calendar date, and a local-time value hides the run. Count credits from the calls you actually made: one per search, one per summary, ten per open.
  </Step>
</Steps>

## Agent 2: the trend agent

The trend agent runs once across all areas, after the observer, on a capable model with no fan-out. It reads two tables and writes one. It opens no sessions.

<Steps>
  <Step title="Read every theme">
    Read all themes, not just recent ones. A pattern that went quiet for three weeks and came back matches an old theme, and the match only happens if the old theme is in memory.
  </Step>

  <Step title="Read the unthemed observations">
    Read observations whose `Themes` relation is empty and whose `Status` is not `Dismissed`. That empty relation is the whole work queue. Linking from the theme side fills it, so a row leaves the queue the moment it is themed, and a re-run after a crash picks up exactly what is left.

    If the queue is empty, append a successful ledger line with `observations_written: 0` and stop. A quiet run is an ordinary outcome, but the ledger line is not optional. Without it the publisher reads this as a run that never fired and holds every digest.
  </Step>

  <Step title="Group, then match">
    Work one owner key at a time; a theme never spans teams. Within a team, group by the same underlying pattern: the same codes and overlapping tags, or the same route, or the same failing endpoint. An observation belongs to exactly one group. A group of one is fine; the next run's observations attach to it.

    For each group, look for an existing theme with the same owner key and either the same slug family or **at least two codes in common and at least one tag in common**. Prefer updating over creating. A theme that grows week over week is the signal this whole pipeline exists to produce; a second theme for the same pattern splits the count in half and hides it. When two themes match, take the one sharing more codes, then the older one.

    A `Closed` theme still matches and never reopens. Link the new observations, let the total and the weekly counts grow, leave `Status` alone. Somebody decided this is not worth acting on, and a growing count is the evidence they would need to reverse that. The publisher keeps it out of the digest either way.
  </Step>

  <Step title="Write each theme in one call, merging rather than recomputing">
    The run only loaded the observations that had no theme yet. Nothing re-reads the rows already linked, so any field recomputed from the rows in hand throws the theme's history away. On an update, every accumulating field starts from the value just read:

    * `Severity`: the higher of the existing value and the highest new observation.
    * `Codes`, `Tags`: existing union new.
    * `Observations`: existing list plus new links, sent as the full list.
    * `Weekly Counts`: parse the existing lines, add one to the ISO week of each new observation's `Session Time`, write back sorted.
    * `Trend`: compare the last two weeks of the merged counts.

    Never split one theme's write across calls. A half-written theme carries the new observation list against last week's counts, and nothing downstream can tell.

    The summary is the only prose people see, so keep it to one sentence, at most 160 characters, in words a product manager reads in three seconds. "New admins get logged out one minute into setup and lose their form" is a summary. "504 on ListReports cascades to 15 endpoints" is an insight and belongs in the observation.
  </Step>

  <Step title="Append the ledger line">
    `succeeded` is true only if every observation in the queue ended up linked. One rejected write makes it false, and the publisher then holds the previous themes rather than posting half-rebuilt ones.
  </Step>
</Steps>

## Agent 3: the publisher

The publisher runs after the trend agent. It reads the Themes table and posts to the channel. It opens no sessions.

<Steps>
  <Step title="Gate on the ledger">
    ```python theme={null}
    if last_successful_run(ledger, agent="trend", utc_date=today) is None:
        append_failed_ledger_line()
        post_one_line("Publisher stopped: no successful trend run today.")
        stop
    ```
  </Step>

  <Step title="Decide what changed, and what may post">
    ```python theme={null}
    def has_changed(theme):
        if theme.last_published_at is None: return True     # never published
        return (theme.status != theme.last_published_status
                or theme.observation_total != theme.last_published_total)

    def is_publishable(theme, exemplars):
        if not theme.store_url: return False                # nothing for the title to link to
        if theme.status == "Closed": return False           # somebody closed it in the store
        return any(e.session_url for e in exemplars)        # must have a playable session
    ```

    Normalize empty cells to `None` before the comparison. An empty string is not `None`, and a theme with a blank `last_published_at` would otherwise republish every run.

    For each changed theme, load its linked observations and keep the three most severe, most confident that have a session URL. Those are the digest's evidence links. A theme with no playable session does not post; the whole point is that a reader can click from the channel down to one session at one moment.
  </Step>

  <Step title="Render one digest per area">
    Themes sorted most severe first, capped at 20, then trimmed from the end until the message fits the channel's limit (5000 characters for Slack). Themes held back by the cap or the trim are counted into one "more themes not shown" line. Never post them as a second message. They keep their old "last published" values and come back next run.

    ```markdown Digest template theme={null}
    ## 🔭 Reports
    _UX digest · 2026-09-11 · 3 themes_

    **1. [Shared report links open to an empty page for viewers](https://store/theme/…)**
    High · New · 6 observations
    People who open a shared report link see a blank page and leave within ten seconds.
    Evidence: [session 1](https://…/client-session/…:…:1789162229921) · [session 2](…) · [session 3](…)

    **2. [Export waits over 20s with no progress indicator](https://store/theme/…)**
    Medium · Tracked · 4 observations
    Exports of large reports show nothing for twenty seconds and some users click export again.
    Evidence: [session 1](…) · [session 2](…)

    **3. [Date picker resets on filter change](https://store/theme/…)**
    Low · New · 2 observations
    Changing any filter resets the date range, so users re-enter dates several times per session.
    Evidence: [session 1](…) · [session 2](…)

    _2 more themes not shown; see the store._
    _Themes and observations live in the store._
    ```

    Pass the theme's `Summary` through unedited. Do not summarize, rewrite, or compose one for a theme that has none. The renderer collapses whitespace, trims anything past 160 characters, and escapes markdown inside titles and summaries. The store URLs go in unescaped.
  </Step>

  <Step title="Write back, only after the post succeeds">
    For each theme the message actually carries, one update with all three fields together:

    ```text theme={null}
    Last Published At      now, UTC
    Last Published Status  the theme's current Status, verbatim
    Last Published Total   the theme's current Observation Total
    ```

    A partial write leaves `Last Published Total` empty, which makes the theme republish on every later run.

    If the write-back fails, the theme republishes next run. Noisy but harmless. Writing before posting risks silent loss, which is worse.
  </Step>
</Steps>

## Budget

Credits per observer run, per area:

```text theme={null}
credits = searches + summaries + 10 × opens
        = 2 + SUMMARIES + 10 × DEEP_REVIEWS
```

At the defaults that is 2 + 80 + 100 = 182 credits a run, plus model tokens for one cheap pass and ten capable-model reviews. The pilot run on a low-traffic area spent 141 credits and roughly $14.50 in tokens to write 9 observations, about $1.61 per observation. Trend and publisher spend no credits.

Track the pass 1 and pass 2 token split separately in the ledger. It is the number that decides whether pass 2 can drop to a cheaper model, and it cannot be recovered from a combined total afterwards.

<Tip>
  `SUMMARIES` and `DEEP_REVIEWS` are the budget. Raise `DEEP_REVIEWS` for an area only after a week of digests shows the extra opens produce themes people act on.
</Tip>

## Conformance checklist

Hand this to whatever agent builds your version. Each line is a test it should pass before the first scheduled run.

* A `review-open` with no matching `review-close` in a subagent's result file gets closed by the orchestrator.
* An observation with a code outside the taxonomy, or a session time with no zone, is rejected before any write.
* The evidence moment on `Session URL` opens the replay player at the same instant `Evidence at:` names.
* Re-running the observer with the same run id writes zero duplicate observations.
* Extending the tag option list keeps every existing option.
* Updating a theme with two new observations leaves every earlier week in `Weekly Counts` intact.
* A theme with `Status = Closed` gains observations but never appears in a digest.
* A theme with no playable session URL never appears in a digest.
* The digest never exceeds the channel's message limit.
* The publisher posts nothing on a day with no successful trend ledger line, and says so.
* Every run, including one that stops early, appends a ledger line.

## Related

* [Session Review overview](/docs/session-review/overview): what an agent does once a session is open.
* [Tools reference](/docs/session-review/tools-reference): `review-search` predicates, `review-open`, `review-zoom`, `review-close` and their credits.
* [Linear triage](/docs/recipes/linear): an on-demand counterpart, where a session URL on a ticket triggers a review.
