PostHog customer deep dive
Research an account, then produce an
email (first touch, follow-up, or reply), a
call-prep brief with its notebook prompt, or for any other ask whatever shape the ask needs on the same research. Output goes to chat. Customer and account systems are read-only: never send, never post, never write to Vitally or PostHog. The only writes are local:
during Setup, and a per-run scratchpad (
deep-dive-<account>-<HHMM>
under the session scratchpad, or
) holding the docs cache, the context file and the gatherer digests.
The deliverable is generated once, into chat, and never also written to a file.
Input:
, usually an email address; also a domain, an account name, or a Vitally account id (either UUID kind).
Verify every claim before writing it. Every number comes from a query you ran this run; every product fact and link from a docs search you ran this run. Show a derived figure's arithmetic inline (
1.72M polls/day / 2,880 per instance = ~600 instances
) so a slipped digit is visible. Nothing from memory, nothing inferred from a number you did not pull. Where a claim cannot be verified, write the question instead: an unanswered question costs a follow-up, a confident wrong fact costs the relationship. Every table in this skill tells you what to check; none is a citation.
A claim about the customer's code or SDK config must quote that code from the site scan's fetched page or bundle. Data shows the effect; only code shows the cause, so an identity or billing mechanism is never named from event and billing data alone. Events the SDK emits (
,
, an explicit
event) are assertable from those events; init options and config values need the quoted code.
Read
first: every per-user value and tool binding. A required value reading
means ask before the step that needs it, or run Setup. A source set to
is skipped silently; that is configuration, not a skip, and a run with every optional source at
is complete.
| Reference | Read when |
|---|
| Before Step 1, every run |
references/agent-briefs.md
| First, before Round 1a. It carries main's own reading-map row, so it decides what else main opens, plus the conventions block, the per-role map, the context file spec and the return rules |
| Steps 1 and 2, by main, only the sections main's map row names. Gatherers never open it; they get its conventions inline |
references/queries-account.md
| Steps 1 and 2, by main (Round 1 slugs) and by the gatherers assigned to it |
references/queries-products.md
, references/queries-money.md
| Step 2, by the gatherer whose map row names the section. Each file's headings are its index |
| Round 0 (Round 2 on a public-provider admin email) |
| Before drafting any recommendation, and by to pick pages |
| Before drafting anything a customer will see |
, references/mode-call-prep.md
| Step 5, the one file for the detected artifact. Any other ask runs the same research and takes the shape the ask needs |
Batch main's reference reads into one block. Opening
whole is the largest read in the critical path and most of it belongs to gatherers, who get it inline.
Two bundled scripts, each a floor and never a ceiling: run them, then keep going wherever the account points somewhere they do not reach, and name what you ran beyond them. A run that stops exactly where the scripts stop answered the script's questions instead of the customer's.
- sends any HogQL query over the HTTP API on four targets ( project 2, EU project 1, / the direct ClickHouse connection). fires many at once, one JSON object per line with the keys , , and optional ; any other key set is a before the first query runs. The MCP stays primary; this is the fallback when the gateway is down and the only path for EU.
scripts/site-scan.sh <domain>
runs the common shape of the Round 0 scan.
Every other HTTP call, Vitally REST included: build the JSON in Python inside a single-quoted heredoc (
), write it to a file, send with
. Bash expands
,
inside inline strings and this skill is full of
-prefixed names; Python's
hits
CERTIFICATE_VERIFY_FAILED
, so curl sends and Python only builds and parses.
Setup (first run, or when is not yours)
Probe before asking, confirm before writing. In one pass: check the tool list for the PostHog exec gateway and the Vitally MCP; check the env for
,
POSTHOG_PERSONAL_API_KEY_EU
,
; check the tool list for each optional category in
, recording exact tool names; detect the timezone. Show one message with what was found, what is missing and the defaults you keep, ask only what cannot be probed (calendar id, booking link), and write
back. A missing optional tool is
; a missing required tool stops the run.
When something is missing, hand the person the fix, not a diagnosis. carries the whole install, one numbered section per tool, each ending in a check that proves it worked, and it is never loaded at runtime, so read the section for whatever is missing and give them the commands from it inline: the shell and key setup (section 0), the MCP registration, the
command for a shared credential, or the mint URL for a personal one. Say which credentials are shared and which are theirs, since reusing someone else's PostHog personal key is the common mistake. Then re-probe and confirm before running. A first run that ends in "the Vitally MCP is missing" and nothing else is how a shared skill loses the person who was trying it.
Step 1. Resolve to a Vitally account
| Input | Path |
|---|
| Email | mcp__vitally__get_user_details
. Take and (the PostHog org id) |
| Domain | the queries. The MCP ignores and returns hundreds of KB; avoid it |
| Account name | billing_customer WHERE name ILIKE '%...%'
. Self-serve signups share company names: prefer the row with non-null / . The Vitally name tools are unreliable ( returns 0 for exact names, ignores , is filtered to the caller's CSM) |
| Account ID | use directly |
If all fail, ask for the account name. An id handed in can be either UUID kind: the Vitally MCP resolves both, the warehouse matches org ids on
vitally.accounts.external_id
only.
One customer carries a different name in each system, so check what the name you were handed refers to. Vitally's account name,
(often a legal entity),
,
and the per-project names in
are five independent fields that sometimes disagree, and the name you were given can turn out to be a near-empty second project rather than the account. State in the header which name maps to what whenever they diverge, and report on the project carrying the volume.
hits two limits. It can
filter on large accounts (stripping custom traits, printing "Removed N trait fields"; a small account can come back complete, so the warehouse traits read is the authority either way) and can
overflow on a several-hundred-user account. Split the read by source; custom traits carry a
prefix.
| Read from | Fields |
|---|
in the warehouse (WHERE id = '<VITALLY_ACCOUNT_ID>'
), which filters out | , onboardingMinimumEligibility
, onboardingUsageOutreachSentDate
, , , , |
| , , , , , , , , + + , replayCountLast30DaysIfSendingData
, , , , , |
Always run the domain sibling sweep, both halves. A sibling org is a duplicate paying twice, a consolidation question, or an account a teammate owns. The two queries see different populations (
and
) and neither alone is complete; skipping one is how a duplicate-billing sibling stays hidden. Flag any sibling in the header: one org with multiple projects usually beats parallel orgs.
Then probe usage across every org the sweep returned and build the context on the one carrying the volume. returns
, whichever account Vitally lists first, and on a multi-org customer that is routinely not the live one, so every downstream figure would describe a dead org.
This gates the Round 2 launch: no gatherer starts until it has resolved, because a gatherer given the wrong org id does perfect work on the wrong company.
sql
SELECT organization_id, count() AS days, sum(event_count_in_period) AS events,
sum(recording_count_in_period) AS recordings, sum(mobile_recording_count_in_period) AS mobile_recordings,
sum(billable_feature_flag_requests_count_in_period) AS flag_requests
FROM billing_usage_by_org_date
WHERE organization_id IN (<every org id the sweep returned>) AND date >= today() - 30
GROUP BY organization_id ORDER BY events DESC LIMIT 20
An org missing from the result has no Cloud usage in the window, which is an answer. Where the resolved org and the live org differ, say so in the header, report on the live one, and treat the resolved one as a sibling finding: a paid subscription on a dead org is money leaving for nothing.
Step 2. Parallel pull
Put the PostHog MCP on
project 2 (check its active-environment block;
to
if not). It often defaults to a dev project where every query returns zero rows.
Detect the region from
vitally.custom.usEuInstance
(an array, e.g.
), populated far more widely than
; fall back to
, then
, where
means EU. Project 2 answers almost everything for both regions; the exceptions are EU experiment definitions and the EU direct connection, both on the EU key and EU project 1.
How to fan out
Fan the gathering out, reconcile in one head, then fan the verification out separately. The two fan-outs are not interchangeable: gathering happens before there are claims, verification after.
A round costs what its deepest agent costs, and an agent costs its longest chain of dependent calls. So
batch first (every read depending only on the context goes out in one block; on the HTTP path one
),
widen second (one grouped query per org and region rather than one per team, per
),
split last and only along a true dependency. No brief carries a serial chain longer than 8 to 10 reads: count the chain, not the calls.
- Budget in tool calls, never seconds. Cap each gatherer at 12 to 15, and split the brief BEFORE launching if the list runs past 15. Nobody can estimate an agent's seconds; everyone can count reads in a brief.
- The budget caps shape, never scope. Never drop, defer or narrow a read to fit it. The move when a brief is too big is to split it, and a read that still will not fit runs over budget and is named in the closing note.
- Split by call count; never merge briefs by topic. Concurrent agents are near-free in wall clock; calls inside one agent serialize into one chain. Merging trades a cheap resource for an expensive one, so the instinct to reduce agent count is backwards.
- 8 to 10 concurrent agents, each batching 4 wide. Past that ClickHouse returns 202 and the HTTP path returns 429, and the forced serial retries are slower than not splitting. The cap is machine-wide, so expect an occasional refusal and retry rather than shipping without the refused brief.
- Splitting stops paying below about 12 calls, because every agent carries a fixed startup cost whatever you give it. Split down to 12 to 15, then stop.
- A full call-prep set exceeds the concurrency cap, so it runs in waves. Wave one: , , . The first two frame every other finding (what moved, and what a limit actually cut off); the third is slowest. goes last and never takes a required gatherer's slot.
| Round | Who | Does what |
|---|
| 0. Pre-resolve | 1 subagent, the moment the domain is known | on the admin email domain |
| 1a. Unblock | Main, three ordered batches | Everything deciding who to research and which batch to run, because both are wrong to guess. Batch 1: the Vitally resolve, , and BOTH halves of . Batch 2: the multi-org volume probe, which names the live org. Batch 3, and only now that the live org is known: the scope probe, the stage trait, get_account_conversations
and the calendar read. Then write the context file (), detect the mode, and launch Round 2. The three batches are ordered, not one block. Running the scope probe beside the sweep profiles whichever org happened to return, and on the multi-org account this gate exists for that is the dead one, so its numbers land in the context file every gatherer then trusts |
| 1b. Resolve | Main, one block, concurrent with Round 2 | , the read, , , , , , , , , . Naming these is what stops them being silently skipped. is one query, it names the actor behind every limit, and a limit moved before a call reframes the money picture |
| 2. Gather | The roles below, in waves | Every role the mode requires, last |
| 2. (concurrently) | Main | Reading the conversation bodies, Step 4 roster, refining mode detection as returns |
| 3. Reconcile | Main, in ONE parallel block | The roll call, the hedge sweep, the named pairs, then re-run the header figures and the number driving the top recommendation. List every claim the output will make. Surface any disagreement between two sources with both figures, never resolve it |
| 4. Verify | Main where the cache covers the claim, one subagent per uncovered cluster | A live docs search per claim, returning the URL and the verbatim line that proves it |
| 5. Write | Main | The output |
Give every subagent
the path to the context file, the conventions block, its reading-map row, and the return rules, all from
.
The context is resolved once by main and no gatherer re-resolves any of it; a gatherer missing a value reports the gap rather than querying for it. The Round 0 agent is the exception: it gets only the domain.
Probe an optional source once. Gong, Slack and Clay share a shape: emptiness is knowable in one call, and a zero ends that source. Report "searched X, zero results", which is a finding and not a skip.
The role roster, what each owns and which sections each opens, is one table in . It lives there because that is the file main reads to write the briefs, and two tables listing the same roles in two always-loaded files is how they drift apart.
holds the exact sections each role opens, and that map is what the brief carries.
Ask each gatherer 3 to 4 questions, not six: six numbered questions invite six investigations and turn a 12-call role into a 30-call one.
Probe an unfamiliar property in the block you are already firing, never in its own round: add
JSONExtractKeys(assumeNotNull(properties))
for that event.
The scope probe, and its three guards
The Round 1a scope probe (one
aggregate) knows which products carry volume. Pass it into each product brief as known state, each line carrying its own proof (
billable_feature_flag_requests_count_in_period = 0 in billing_usage_by_org_date over the window
, never a bare "flags are zero").
It changes what a brief says, never whether it runs. Three guards, and it is unsafe without all three:
- Write for every product the probe has no column for. Experiment volume and web analytics volume have no source anywhere; heatmap volume lives only on the usage report's map; lives on the org usage report.
- Check on before trusting a flat zero: the table is Cloud only, so a self-hosted org reads zero everywhere while emitting usage daily, and it carries no row for a day with no usage.
- Each brief says "verify against the product's own source and report what you found", never "confirm and move on".
Never use the probe to skip a role the mode requires. Skipping is where a zero and an unmeasurable become the same thing, which is the failure this skill exists to prevent. An agent that ran and confirmed a zero is not a skip.
Round 3: roll call, hedge sweep, named pairs
Three checks before any re-run, because each catches a whole missing input rather than a wrong digit.
The roll call. List the roles the mode requires against the roles launched, and the Round 1 slugs required against the ones that returned. Every gatherer justifies its own skipped reads, but nothing checks the level above it, so a role dropped under time pressure disappears silently while the output reads complete. Launch the gap, or name it in the closing note. "Short of time" is a reason to state, never a reason to omit. This matters most on an ask that is neither an email nor a brief, where there is no expected section for the reader to notice missing.
The hedge sweep, and it produces a list, not an intention. Read every digest for the gatherers' own uncertainty ("probable not proven", "unmeasurable", "needs confirming", "worth reconciling", "the biggest skip is", "ask on the call") and write every hit into a numbered list before closing any of them. Each line gets a disposition: closed with the number, or carried into the output as a named open question. A hedging gatherer has almost always named the read that would close it, so each is cheap and pre-scoped.
Doing this in your head is what fails. A sweep held as an intention closes the easy hedges, and the one it drops is reliably the one sitting under the top recommendation, because that is the hedge whose answer takes work. An unresolved hedge is how a finding dies: the agent did its job, the signal sits in the digest, and it never becomes a sentence. Two specific shapes to catch, both of which have shipped as assertions: a hedge that would have SIZED a lever you are already recommending (you keep the recommendation and lose the number that makes it land), and a hedge naming a cause that another gatherer was holding the data to confirm.
The named pairs, sources that must agree:
- A site-scan absence against the customer's own event stream. The scan is authoritative for what it found, never for what it did not. If their team receives events carrying that host in , the tool is installed there whatever the scan said. One query, before any absence claim reaches the output.
- The header's money figures against the invoice.
- Two gatherers against each other, wherever they touched the same object. Reachability claims are the ones that collide: one role probes a table, gets and reports unmeasurable, while another reads a near-identical name successfully and publishes a finding from it. Both digests are then true and the output carries a contradiction. Before writing, list every object more than one role touched and confirm they agree on whether it exists and what it holds.
Round 4 verification, and the docs cache
Verification runs against the finished claim list; Round 2 pre-warming is additive and may never shrink it.
Where the cache covers a claim, verify in main; fan out only for the claims it missed. The invariant holds either way: every claim gets its own live search, and the count of searches against claims is what proves it happened. Searching from
before the findings exist verifies levers you predicted and misses the ones the data produced, so build the claim list first (the pre-draft check in
), then verify every lever and product fact it states.
Three lines govern the cache, and every Round 4 brief states them verbatim: a cached page is evidence only for a claim whose OWN live search returned that URL; the cache supplies a page body, never a verdict and never a citation; the cache is per run, never a page a previous run fetched. A verifier returns the verdict, the canonical URL and the verbatim sentence.
is permitted, and a claim that comes back that way is cut or rewritten as a question, never shipped with the nearest-looking link. Cite
first, a PostHog-authored
page when no docs page covers it, never Community Questions or GitHub issues. If
is unreachable, verify each URL with a plain HTTPS fetch.
The closing note
Nothing accumulates across runs, so this note is the only record. At Step 5, after the output, add a short note in chat: which agents ran long, any brief that overran its cap, and any fix this run revealed that belongs in the skill. Suggestion only. Two figures belong in it because both are correctness checks, not telemetry:
- Claims whose own citation URL you fetched, against total claims. Count it from a list you append to as each verification happens, never from memory at the end of the run: a number reconstructed at the end is a number you will inflate, and an inflated count is worse than none, because the ratio is the only thing standing between a real Round 4 and a warm cache. Count fetched citations rather than searches run, because a search that returned a confident, adjacent page is exactly the failure this is watching for and it looks identical to a search that answered the question.
- Roles launched against roles required, and Round 1 slugs returned against Round 1 slugs required. Print both halves. A dropped role is the loud half and gets noticed anyway; a Round 1 slug that quietly never ran is the silent half, and it is the one that reaches the output as an absence nobody questioned.
- Hedges found, closed, and carried, as three numbers from the Round 3 list.
What main covers beyond the roles
Round 2 subagents own the reads in the table; main does not re-run them. Beyond it: Vitally's
get_account_conversations
(limit 20; metadata only,
is Gmail or calendar,
is support),
get_account_feature_requests
,
. Conversation bodies, notes and tasks come from the warehouse (
,
,
), avoiding the MCP calls that overflow on large accounts.
Every email with the account syncs to Vitally, so that pair of reads is the whole comms record and a mailbox search adds nothing. Do not reach for one: a
on a contact's first name matched a different customer's personal address at another domain and nearly put that account's failed payment into this brief. If you search a mailbox anyway, read the recipient domain before treating a message as this account's. Keep both the Vitally traits and the postgres reads where they overlap and cross-check them. The calendar runs every run with an explicit start/end window (unbounded text searches return nothing), by attendee name from the
subject, else by account name and admin emails.
Four names main needs by hand, because main verifies numbers without opening a query file. The event saying a limit actually cut a product off is
; who changed a limit is
; the live forecast is
joined to on ; current limits are
. A guessed column here returns an empty result that reads exactly like "nothing was limited", the inverse of the truth.
These are pointers, not recipes: open and in before writing either query, because both carry filters and aliases that decide whether the result means anything, and writing SQL from this paragraph alone has produced a column of nulls and a hard 400.
Call prep runs every role. An email run drops the roles whose product the account does not implicate, and names each one dropped. In every mode, skip an individual read only when it cannot apply, and name each skip. Lookback 30 days; re-run a single query at 14 days (follow-up) or 7 (same-week call prep) only where the shorter window changes the conclusion.
Step 3. Mode detection
This step flags, it never blocks; any mode or request runs on any account. The skill only drafts, so a human weighs every output; raise contradictions loudly, never withhold work.
Flag at the top when the account looks already-handled:
onboardingMinimumEligibility
false, a terminal stage (
,
,
), or a teammate's recent message on the same topic. An assignment is never a flag; only an active thread is.
The user's explicit ask always wins: "draft outreach for X" produces the draft whatever the account state, flag leading, never a refusal. The signals below decide only when the ask is open. A pasted inbound is a reply; a named call is call prep.
| Signal | Read from | Watch for |
|---|
| stage | Pipeline stage | vitally.custom.onboardingPipeline
| The only field carrying the stage, and every live value carries a numeric prefix: , , , , . Match the full string including the prefix; a bare matches nothing, and neither does the unprefixed trait key. Retired values still in the wild (, /, /, ) get flagged rather than routed on. Absent means never entered the pipeline: it routes like only when the account is eligible with no AE or CSM. Absent beside an assigned AE or CSM means sales has it: note it, still draft, put the loop-in in the framing. An assigned OS says nothing here |
| outreach | Initial outreach sent | vitally.custom.onboardingUsageOutreachSentDate
| Fall back to vitally.custom.initialOutreach
, often null even when outreach went out |
| threads | Onboarding-team conversations | admin ids | AE and support threads do not count |
| meeting | Booked FUTURE meeting | conversation with an acceptance subject (, localized, e.g. ) | A hint only. Confirm the date on the calendar and fall through if the meeting is past, declined or not found, or a stale acceptance routes to call prep forever |
| inbound | Inbound after outbound | lastInboundMessageTimestamp
vs lastOutboundMessageTimestamp
| Counts AE, CSM and support threads and fires on system messages, so it is often true off a calendar acceptance. A prompt to read the actual latest inbound body, never a reason to route alone. If the latest inbound is a calendar or system message, or is addressed to another owner, the signal is not satisfied |
Read the stage from the trait, not from
/ Vitally
. Those are a different field with a different vocabulary (
,
,
,
,
,
), carry no
and no
, and no value matches the trait's exactly. Segments corroborate the stage and hold the durable sales-referral record; they never define the stage.
Routing, for an open ask only:
- Terminal stage, or a live thread on the same topic held by a teammate OR by the sender: the flag leads, the research is still full, and the recommendation is a note or a loop-in rather than cold outreach. This sets the framing and never the mode, so carry on down the list; a run that stops here has a flag and no output. The sender's own recent thread is a collision exactly like a teammate's: not a reason to withhold a draft, but the thing the draft must not repeat.
- inbound true and the latest inbound is a real customer question: email, reply state
- else meeting present: call prep
- else stage is , or absent with
onboardingMinimumEligibility
true and no AE or CSM, outreach null, threads 0: email, first-touch state. An absent stage beside an assigned AE or CSM falls through to 4
- else email, follow-up state
Read the conversation history before finalizing; thread context outranks any single signal. Flag and keep going on: outreach exists but stage still
; stage
with no outreach trace; terminal timestamps on a non-terminal stage, the signature of a sales-disqualification reset.
Step 4. People on the account (report it, do not route on it)
Report the roster in the header every run, assigned or not:
OS <name or NONE> · CSM <name or NONE> · AE <name or NONE>
. Middle dots, never pipes, which break the table cell.
An assignment is roster data, never a warning; an active thread earns the flag. An OS stays assigned after onboarding completes and an AE or CSM is often simply unassigned, so
is common and correct. Never turn either into "this belongs to X, check first": it reads as a blocker, is usually wrong, and buries the research. A teammate who recently messaged the account on the topic at hand IS a collision: read the conversation history for that, not the ownership fields.
Sources in order: the Vitally
array (the
rows in
) carries role label, email and name together;
accounts_replacement_v2.csm_name
/
and Vitally
/
corroborate;
billing_customer.sales_info.owner_*
and
are the fallback.
is an account tier, not a person. A set
sfdc.last_task_disqualified__c
with no AE or CSM means sales passed and onboarding has the account again: note the date.
To answer "was this account ever referred to sales", read the segment, not the stage. Adding that segment fires the handoff playbook and it persists after the stage moves on, so a referred account routinely shows another stage. Read it with
sfdc.last_task_disqualified__c
: segment present and disqualified means it came back and is yours; segment present and not disqualified means sales still has it.
On annual plans read
,
,
,
projectedCreditExpiryDate
,
creditExpiryVsAnnualPlanExpiry
. Credits projected to run out before the plan ends go in the header, since they change the conversation rather than the draft.
Step 5. Output
No em dashes, en dashes or double hyphens in any output, the internal brief included.
Read
before drafting anything a customer will see,
before stating any recommendation, and the one mode file for the artifact.
Common header for every email and every call-prep brief, and for any other ask whose artifact is an account brief. Render it as a real markdown table, never inside a code fence (the fence below is only the template's source). Drop a row that does not apply rather than writing
.
# <Account> | <Artifact: first touch / follow-up / reply / call prep>
> **Flag:** <only when one applies: a teammate's live thread on this topic, a stage mismatch, a sibling org. Never a roster name.>
| | |
|---|---|
| **Pipeline** | <stage> |
| **Health** | <X>/10 |
| **Renewal** | <date> (<n> days, monthly cycle or contract) |
| **MRR** | $<actual> booked to $<forecast> forecast (<±delta>) |
| **Paid** | <products> |
| **Limits set** | <product $each>. Mark any at or near its forecast; say NONE where the largest line has no limit |
| **Limits biting** | Which products a limit has ACTUALLY cut off, from `quota-limits`, each with its start date and whether it is dropping now. NONE is an answer and is written out. A limit that is set and a limit that is destroying data are different facts |
| **People** | OS <name or NONE> · CSM <name or NONE> · AE <name or NONE> |
| **Users** | <total> listed, <n> with seats, <n> with real activity |
| **Org** | <link> |
Header field rules, since several traits collide:
| Field | Rule |
|---|
| Renewal | On an annual plan is the monthly billing-cycle end, not the contract renewal: use (or numberOfDaysUntilEndOfContract
). On a monthly account they are the same |
| MRR | is actual, is forecast, both net of any credit discount ( is the gross figure, do not mix them). Delta is forecasted_mrr - usage_mrr
; a negative delta on an annual account is usually the annual-vs-usage artifact, not churn. is a different comparison |
| Health | from . Two warehouse columns share the name and neither belongs here: vitally.accounts.health_score
is a stale copy, account_health_scores.health_score
is PostHog's own engagement model |
| Users total | from ; per-query counts can differ |
| Org link | https://us.posthog.com/project/2/groups/0/<ORG_ID>
(organization is group type 0) |
Before handing the output over, confirm all eight and send anything unchecked back to the round that owns it: every claim traces to this run's queries or live searches; every skip is named per subagent with its reason; every trap hit is reported, and a run reporting none was interrogated; every source disagreement shows both figures unresolved; the header is a real table with the People row present; no section shipped as settled while an agent owning its subject was still running; the Round 3 roll call ran and any shortfall is named; the closing note states both figures.
The write gate is the one traded away under time pressure, so it is explicit: a still-running agent owns its subject until it returns. Wait for it, or write the section and mark it provisional in the output. Marked provisional satisfies the checklist; shipped as settled does not. A confident section a pending agent then contradicts costs the user a correction on work they have already read, and may have already acted on.
Pipeline reminder
A reminder to raise with the user, never a write to perform unless they ask for that write in so many words. Once they say they acted: outreach sent means
moves to
; passed to sales means
. Suggest a Vitally note only when the research concluded "do not reach out", recording why. A draft-only run needs no write.
Improving the skill
When a run reveals a fix, ask whether to fold it in. Write the rule into the file that owns it, in the fewest words, and fix the rule that failed rather than adding an exception. Two rules keep these files honest.
Every line changes what the model does; provenance, measurements and orientation go to
, which is never loaded at runtime, or get cut.
The shape of the data is durable and gets written down (a join key, a column that does not exist, an id that collides across regions);
the state of the product is perishable and belongs nowhere (which SDKs support a feature, what a tier costs, where a setting lives), so search for it every time and never record the answer.