What you need
- The Subtext capture snippet installed and recording real sessions. Start with Install the capture snippet if you haven’t.
- A Subtext MCP connection your agent can use unattended. The official Subtext plugin 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/subtextwith 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, 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 gives you this for free if you have one.
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.
The shape
Three rules hold the shape together.- 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.
- 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.
- 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.
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 setStatus to Dismissed.
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.
Theme
One row per recurring pattern. Created and updated by the trend agent. People move the status in the store.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.1
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.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.
2
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.Anomaly search: touched the area and had a failed request
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.3
Pass 1: triage on a cheap model
Fan out in batches of about 15 sessions. Each subagent calls 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
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.Pass 1 prompt sketch
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.4
Pass 2: full review on a capable model
One subagent per selected session, one observation or A subagent that dies mid-review sends no reply, so the
null back. Most sessions produce null, and that is fine.Pass 2 prompt sketch
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.5
Validate, stamp the evidence moment, write
Validate every record before writing: allowed enum values, codes inside the taxonomy, a parseable Write the same moment into
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.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.6
Append the ledger line
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.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.1
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.
2
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.3
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.4
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’sSession Time, write back sorted.Trend: compare the last two weeks of the merged counts.
5
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.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.1
Gate on the ledger
2
Decide what changed, and what may post
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.3
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.Pass the theme’s
Digest template
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.4
Write back, only after the post succeeds
For each theme the message actually carries, one update with all three fields together: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.Budget
Credits per observer run, per area: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-openwith no matchingreview-closein 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 URLopens the replay player at the same instantEvidence 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 Countsintact. - A theme with
Status = Closedgains 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: what an agent does once a session is open.
- Tools reference:
review-searchpredicates,review-open,review-zoom,review-closeand their credits. - Linear triage: an on-demand counterpart, where a session URL on a ticket triggers a review.

