Agent skill
measure
Reports what a GTM run actually did — coverage first, then spend as an honest range, then hit rates by hop and provider.
Filed under Calls, demos and discovery.
From richapiai/gtm-skills · 34 skill entries · 0 · pushed 2026-09-18
What it does when it runs
Reports what a GTM run actually did — coverage first, then spend as an honest range, then hit rates by hop and provider. Use when asked "how did that run go", "what did that cost", "what's my hit rate", "credits per meeting", "measure this play", or "was that worth it". Reads the ledger and the run journal; makes zero API calls and spends nothing. Can also write an aggregate-only artifact that is safe to forward. (richapi-gtm)
Automated analysis of the skill and the 0 files bundled beside it. A skill’s own description is written to be selected by an agent, so it describes the job and not the dependencies.
- Keys and connectors you must supply
- None found.
- Hosts it reaches
- No third-party host appears in the skill or its bundled files.
- Tool permissions it declares
- Bash(richapi-skills-preflight:*)
- Bash(node:*)
- Read
- Write
- Actions present in the files
- shellwrites files
Install it
View source on GitHub ↗git clone --depth 1 --filter=blob:none --sparse https://github.com/richapiai/gtm-skills.git /tmp/gtm-skills git -C /tmp/gtm-skills sparse-checkout set "skills/measure" mkdir -p ~/.claude/skills/measure cp -R "/tmp/gtm-skills/skills/measure/." ~/.claude/skills/measure/
Picked up without a restart. A project skill of the same name is shadowed by your personal one. For one repository only, swap ~/.claude/skills for .claude/skills. Claude Code docs ↗
Or take the whole library
This repo ships a .claude-plugin manifest, so Claude Code can install all 34 skills at once. Plugin skills are invoked as /<plugin>:<skill>, so they never collide with your own.
/plugin marketplace add richapiai/gtm-skills /plugin
The folder is the same in every client that implements the format — 46 of them — so if yours is not above, only the destination changes.
The skill
Source on GitHub ↗Reproduced in full from richapiai/gtm-skills/blob/c1a5d5881be89b65b155d772aef4b491aef3354e/skills/measure/SKILL.md, which is licensed MIT (repository). 3,529 words, 10 headings.
Measure a run
You are the accountant, not the salesperson. Your output is what the run actually did, in the order that matters to the person paying for it: what was not found, then what it cost, then what worked.
Two rules outrank everything else on this page, and both of them cost you a nicer number:
- A range stays a range. Most metered endpoints never report their charge back. For those the pre-call estimate is the only figure that will ever exist, and turning an estimate into a "you spent" is a lie with a decimal point on it. The report says at least X, up to Y and says which lines are which.
- Coverage before wins. What the run did not find is the number that decides whether it was worth running. It is also the number a reporting tool is most tempted to bury under a hit-rate table. It goes first, above the money.
Inference mode — local
This skill runs on local inference only. It makes zero API calls, metered or
otherwise, and never calls ai_enrich. Everything it reports is arithmetic over files
the pack already wrote: the ledger, the run journal, and the local activation store.
There is nothing here a model needs to be paid to infer — the pack already runs inside
an agent that can read a JSONL file for free, and buying an opinion about your own
ledger would be the silliest credit this pack could spend.
Before anything else
Run the preflight and read the keys:
richapi-skills-preflight
None of them can block this skill, and it is worth saying why rather than skipping the
step. API_KEY_SET: no is fine — nothing here calls the API. BALANCE: unknown is
expected and is reported as unknown, never inferred. SUPPRESSION: STOP does not block
a report either; it does mean the run you are measuring may not have been filtered, so
say that out loud in your summary rather than presenting the coverage number as clean.
There is no richapi measure verb. The runtime ships enrich, call, search,
preflight, catalog and gates; measuring is local work over state the pack already
has, so it is the script below and nothing else. Do not tell the user to run a command
that does not exist.
What this reads, and what it must never re-derive
| Question | Answer comes from | Never |
|---|---|---|
| What did it cost? | _lib/ledger.mjs → Ledger.totals and Ledger.reconcile | a second cost calculation |
| Is that figure provable? | _lib/receipt.mjs → buildReceipt, spendPhrase | rounding a range into a number |
| Did the report overstate? | _lib/receipt.mjs → assertNeverOverstates | catching that error |
| Who was covered? | _lib/journal.mjs → summarize | reading contact rows |
| Hit rates by hop and provider | _lib/share-render.mjs → renderShareable | filtering rows by hand |
| Activation and kill/scale bands | _lib/activation.mjs → evaluateBands | inventing a verdict |
The pack has exactly one cost calculation and this skill is not allowed to be the
second one. Ledger.totals already separates verified actuals from unverifiable
estimates; buildReceipt already turns that into a floor and a ceiling; and
assertNeverOverstates recomputes from the raw ledger lines and throws if the
report claims more than the ledger can support. The script below calls that guard
before it renders a single character, and it does not wrap it in a try. A report that
cannot be proven is not printed.
Step 1 — pick the run
One report covers one run. Cross-run rollups are deliberately out of scope (see the
boundary section): the journal's resume key is (row_id, hop) and that key is only
unique within a run, so folding two journals together silently merges rows that are
not the same row. A wrong denominator is worse than a missing report.
With no RUN given, the script measures the most recently written journal under
gtm/runs/. Say which run you measured. "Your last run" and "the run you are asking
about" are not reliably the same thing.
Step 2 — run the report
From the pack root. It spends nothing, calls nothing, and writes one file:
ROOT=/path/to/project RUN=run-2026-08-28-abc node --input-type=module -e "${GTM_MEASURE:?set this to the gtm-measure script below}"
where $GTM_MEASURE is the script below. Write it to a file and run it if that is
easier; it is the same script either way.
// ==== gtm-measure v1 ====
// What a run actually did. ZERO API calls. Run from the pack root.
//
// The whole design of this file is "do not recompute anything the pack already
// computed". Money comes from the Ledger and the receipt; coverage and hit rates come
// from the journal via the share renderer; the activation bands come from
// activation.mjs. This file's own arithmetic is limited to counting rows and dividing
// a spend range by a self-reported outcome count.
//
// env: ROOT (default .) project root holding gtm/
// RUN (default: newest journal under gtm/runs/)
// OUT (default gtm/measure/<run>-report.md)
// SHARE ("1" also writes gtm/measure/<run>-share.md, aggregate-only)
// OUTCOMES (optional .json of SELF-REPORTED counts, e.g. {"meetings": 3})
// USAGE (optional .json body from GET /usage, for reconciliation)
// GATES_FILE(alternate gates.yaml; test seam)
// NOW (ISO instant; test seam, so a clock is never implicit)
//
// exit 0 = report written · 2 = nothing to measure · 3 = written, coverage below floor
import fs from 'node:fs';
import path from 'node:path';
import { Ledger, readBalanceCache } from './_lib/ledger.mjs';
import { buildReceipt, assertNeverOverstates, spendPhrase, renderReceipt } from './_lib/receipt.mjs';
import { renderShareable, renderShareableText, assertAggregateOnly } from './_lib/share-render.mjs';
import { readJournal, summarize } from './_lib/journal.mjs';
import { loadGates, gateValue, checkCoverage, MissingGateKey, STOP } from './_lib/gates.mjs';
import { readActivation, activationPath, snapshot, localCohort, evaluateBands, renderActivation } from './_lib/activation.mjs';
const ROOT = path.resolve(process.env.ROOT || '.');
const GTM = path.join(ROOT, 'gtm');
const NOW = process.env.NOW ? new Date(process.env.NOW) : new Date();
const gates = loadGates(process.env.GATES_FILE || undefined);
const die = (code, msg) => { console.error('measure: ' + msg); process.exit(code); };
// --- which run -------------------------------------------------------------
const runsDir = path.join(GTM, 'runs');
function newestRun () {
let names = [];
try { names = fs.readdirSync(runsDir).filter((f) => f.endsWith('.jsonl')); } catch { return null; }
let best = null;
for (const n of names) {
const st = fs.statSync(path.join(runsDir, n));
if (!best || st.mtimeMs > best.mtimeMs) best = { id: n.replace(/\.jsonl$/, ''), mtimeMs: st.mtimeMs };
}
return best?.id ?? null;
}
const RUN = process.env.RUN || newestRun();
if (!RUN) die(2, 'no run journal found under ' + runsDir + ' — there is nothing to measure yet.');
const journalPath = path.join(runsDir, RUN + '.jsonl');
if (!fs.existsSync(journalPath)) die(2, 'no journal for run "' + RUN + '" at ' + journalPath);
const journal = readJournal(journalPath);
if (journal.lines.length === 0 && journal.corrupt.length === 0) die(2, 'journal for run "' + RUN + '" is empty.');
// --- coverage, from the journal (aggregate-only, by construction) ----------
// row_id is used ONLY as a Set key for cardinality. It is caller-supplied and is
// routinely an email or a CRM record id, so it is never emitted anywhere below —
// the same discipline share-render.mjs applies.
const units = summarize(journal.lines);
const rowsSeen = new Set();
const rowsWithAnyOk = new Set();
for (const u of units.values()) {
rowsSeen.add(u.row_id);
if (u.status === 'ok') rowsWithAnyOk.add(u.row_id);
}
const rowsTotal = rowsSeen.size;
const rowsFound = rowsWithAnyOk.size;
const rowsNotFound = rowsTotal - rowsFound;
let catalog = null;
try { catalog = JSON.parse(fs.readFileSync('./_lib/api-catalog.json', 'utf8')); } catch { catalog = null; }
// The aggregate view. This is also what the shareable artifact is built from, so a
// field that is not in here can never reach a forwarded file.
const share = renderShareable(journal.lines, {
catalog,
runIdLabel: RUN,
now: () => NOW.toISOString(),
});
assertAggregateOnly(share);
// --- money: the ledger's own arithmetic, never a second copy of it ---------
const ledger = new Ledger({ dir: GTM, runId: RUN }); // adopts this run's lines
const cachedBalance = readBalanceCache();
const receipt = buildReceipt({
ledger,
balance: cachedBalance?.balance ?? null,
balanceSource: cachedBalance?.source ?? 'unknown',
gates,
runLabel: RUN,
});
// THE GUARD. Not wrapped, not caught: a report that cannot be proven is not printed.
assertNeverOverstates(receipt, ledger);
let usageBody = null;
if (process.env.USAGE) {
try { usageBody = JSON.parse(fs.readFileSync(process.env.USAGE, 'utf8')); } catch { usageBody = null; }
}
const reconciliation = ledger.reconcile(usageBody);
// --- the coverage gate, read from gates.yaml, never typed here -------------
let coverageGate;
try { coverageGate = checkCoverage(gates, share.coverage_pct); }
catch (e) {
if (!(e instanceof MissingGateKey)) throw e;
coverageGate = { decision: STOP, gate: e.key, reason: e.message + ' — failing closed (law 5)' };
}
// --- activation + kill/scale bands: surfaced, not reimplemented ------------
const act = snapshot(readActivation(activationPath()));
const bands = evaluateBands({
cohort: localCohort(readActivation(activationPath()), { now: () => NOW }),
gates,
gateValue,
});
// --- outcomes: SELF-REPORTED, never observed by the pack -------------------
// The pack cannot see a send, a reply or a meeting: sending is external forever. So
// these are counts the operator typed, they are labelled as such, and nothing here
// promotes them to something the pack verified. Law 4's shape, applied past credits.
let outcomes = null;
if (process.env.OUTCOMES) {
try {
const raw = JSON.parse(fs.readFileSync(process.env.OUTCOMES, 'utf8'));
outcomes = {};
for (const [k, v] of Object.entries(raw || {})) {
if (/^[a-z][a-z0-9_]{0,31}$/.test(k) && Number.isFinite(Number(v))) outcomes[k] = Number(v);
}
if (Object.keys(outcomes).length === 0) outcomes = null;
} catch { outcomes = null; }
}
// --- the spend block, and the guard that the block never overstates --------
const spendBlock = renderReceipt(receipt);
function assertBlockWithinLedger (text, r) {
const cap = r.credits_ceiling;
for (const m of text.matchAll(/(?:spent|at least|up to)\s+([\d.,]+)/gi)) {
const claimed = Number(String(m[1]).replace(/,/g, ''));
if (Number.isFinite(claimed) && claimed > cap + 1e-9) {
throw new Error('measure: the report claims ' + claimed + ' credits, above the ledger ceiling of ' + cap);
}
}
}
assertBlockWithinLedger(spendBlock, receipt);
// --- render ----------------------------------------------------------------
const rate = (hits, n) => (n > 0 ? Math.round((hits / n) * 1000) / 10 + '% (n=' + n + ')' : 'n/a (n=0)');
const L = [];
L.push('# Measure — run ' + RUN);
L.push('');
L.push('Generated ' + NOW.toISOString() + ' from the ledger and the run journal. Zero API calls.');
L.push('');
// 1. COVERAGE FIRST. What was not found decides whether the run was worth it.
L.push('## Coverage — what this run did NOT find');
L.push('');
L.push('- rows in the run: ' + rowsTotal);
L.push('- **not found: ' + rowsNotFound + '** <- the number that decides whether the run was worth it');
L.push('- found (any hop): ' + rowsFound);
L.push('- coverage: ' + (share.coverage_pct === null ? 'n/a' : share.coverage_pct + '%'));
L.push('- state: ' + share.state);
L.push('');
L.push('Rows that never completed are counted as not found, because from the operator\'s '
+ 'side of the desk an in-flight row and a missing row are the same missing row:');
L.push('');
L.push('- in flight at interrupt: ' + share.rows.in_flight);
L.push('- not started: ' + share.rows.not_started);
L.push('- failed: ' + share.rows.failed);
L.push('- halted on budget: ' + share.rows.halted_budget);
L.push('- dropped by suppression: ' + share.rows.dropped_suppressed);
L.push('');
L.push('Coverage gate `' + coverageGate.gate + '`: ' + coverageGate.decision.toUpperCase() + ' — ' + coverageGate.reason);
if (journal.corrupt.length > 0) {
L.push('');
L.push('Journal health: ' + journal.corrupt.length + ' unreadable line(s). Coverage above is a '
+ 'LOWER bound on what was attempted; a damaged line is not evidence of a completed row.');
}
L.push('');
// 2. Money, as a range unless every line is verifiable.
L.push('## Spend');
L.push('');
L.push(spendBlock);
L.push('');
L.push('Reconciliation against `GET /usage`: ' + reconciliation.status);
L.push('');
L.push('```');
L.push(reconciliation.report);
L.push('```');
L.push('');
if (!receipt.exact) {
L.push('The figure above is a RANGE and stays one. ' + receipt.unverifiable_lines
+ ' of ' + receipt.calls + ' call(s) hit an endpoint that does not report its charge, '
+ 'so the estimate is the only number that will ever exist for them. Do not quote the '
+ 'ceiling as a spend.');
L.push('');
}
// 3. Only now: what worked.
//
// The per-hop credit column comes from the JOURNAL, not from the ledger, so it is
// labelled as such and is never a spend claim. The ledger is the money record of
// record; if the two disagree the divergence is printed rather than hidden behind
// whichever number reads better.
L.push('## What worked — by hop');
L.push('');
L.push('| hop | endpoint | planned | done | failed | hit rate | journal est. credits |');
L.push('|---|---|---|---|---|---|---|');
let journalCredits = 0;
for (const h of share.per_hop) {
const credits = h.cost_status === 'actual' ? String(h.credits_actual) : '~' + h.credits_estimated + ' (est)';
journalCredits += h.cost_status === 'actual' ? h.credits_actual : h.credits_estimated;
L.push('| ' + h.hop + ' | ' + h.endpoint + ' | ' + h.planned + ' | ' + h.completed + ' | '
+ h.failed + ' | ' + rate(h.completed, h.completed + h.failed) + ' | ' + credits + ' |');
}
L.push('');
L.push('The credit column above is the **journal\'s** per-hop figure, kept here for '
+ 'attribution. The spend section is the money record; nothing in this table is a spend claim.');
if (journalCredits > receipt.credits_ceiling + 1e-9) {
L.push('');
L.push('DIVERGENCE: the journal accounts for ' + Math.round(journalCredits * 1000) / 1000
+ ' credits across these hops while the ledger can support at most '
+ receipt.credits_ceiling + '. The LEDGER figure governs. A gap this way round usually '
+ 'means journal lines exist for calls the ledger never recorded — treat the run as '
+ 'partially unaccounted rather than as cheaper than it looks.');
}
L.push('');
L.push('## What worked — by provider');
L.push('');
if (share.providers.length === 0) {
L.push('No provider was attributed on any hop. That is a gap in the data, not a result.');
} else {
L.push('| provider | attempts | hits | hit rate | mean confidence |');
L.push('|---|---|---|---|---|');
for (const p of share.providers) {
L.push('| ' + p.provider + ' | ' + p.attempts + ' | ' + p.ok + ' | ' + rate(p.ok, p.attempts)
+ ' | ' + (p.mean_confidence === null ? 'n/a' : p.mean_confidence) + ' |');
}
}
L.push('');
L.push('Every rate above carries its denominator. A rate without an `n` invites a decision '
+ 'the sample cannot support, which is the same mistake the kill/scale bands refuse to make.');
L.push('');
// 4. Outcomes and credit ROI — self-reported, and labelled every time.
L.push('## Outcomes — self-reported, not observed');
L.push('');
if (!outcomes) {
L.push('None supplied. The pack cannot see a send, a reply or a meeting — sending is external '
+ 'forever — so it reports no outcome rather than inferring one. Supply counts via OUTCOMES '
+ 'if you want the credit-ROI line.');
} else {
for (const [k, v] of Object.entries(outcomes)) L.push('- ' + k + ': ' + v + ' (self-reported)');
const denom = Number(outcomes.meetings ?? outcomes.replies ?? 0);
const label = outcomes.meetings !== undefined ? 'meeting' : 'reply';
if (denom > 0) {
const lo = Math.round((receipt.credits_floor / denom) * 100) / 100;
const hi = Math.round((receipt.credits_ceiling / denom) * 100) / 100;
L.push('');
L.push(receipt.exact
? '- credits per ' + label + ': ' + hi
: '- credits per ' + label + ': between ' + lo + ' and ' + hi
+ ' — the spend is a range, so the ROI is a range too.');
}
L.push('');
L.push('These counts came from the operator, not from the API. Nothing above verified them.');
}
L.push('');
// 5. Activation, straight from activation.mjs.
L.push('## Activation and kill/scale bands');
L.push('');
L.push('```');
L.push(renderActivation(act, bands));
L.push('```');
L.push('');
L.push('Bands are computed by `_lib/activation.mjs` from thresholds in the gate file. A band '
+ 'below its minimum sample, or with no thresholds configured, reads YELLOW and never RED: '
+ 'absence of data is not evidence of failure.');
L.push('');
L.push('_Coverage is stated before wins on purpose. Row identifiers, contact fields and '
+ 'response bodies appear nowhere in this report._');
const text = L.join('\n') + '\n';
const out = process.env.OUT || path.join(GTM, 'measure', RUN + '-report.md');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, text, 'utf8');
// --- the shareable artifact, built from journal METADATA only --------------
let sharePath = null;
if (process.env.SHARE === '1') {
const body = renderShareableText(share) + '\n\n' + spendPhrase(receipt) + '\n';
assertAggregateOnly({ rendered_share_artifact: body });
assertBlockWithinLedger(body, receipt);
sharePath = path.join(GTM, 'measure', RUN + '-share.md');
fs.mkdirSync(path.dirname(sharePath), { recursive: true });
fs.writeFileSync(sharePath, body, 'utf8');
}
console.log('run ' + RUN);
console.log('coverage not found ' + rowsNotFound + ' of ' + rowsTotal
+ ' (' + (share.coverage_pct === null ? 'n/a' : share.coverage_pct + '%') + ' covered)');
console.log('spend ' + spendPhrase(receipt));
console.log('reconciled ' + reconciliation.status);
console.log('report ' + out);
if (sharePath) console.log('share ' + sharePath);
process.exit(coverageGate.decision === STOP ? 3 : 0);
// ==== end gtm-measure v1 ====
Step 3 — report it in the order the file is written in
Read the file back in order. Do not reshuffle it into "here's the good news first".
- Lead with what was not found. Then the coverage percentage, then the gate verdict. If the coverage gate STOPped, that is the headline and the fix is named by the gate key, not by you.
- Quote the spend line verbatim. If it says at least X, up to Y, that is what you say. Never average the two, never round to the ceiling, never round to the floor, and never describe the ceiling as "roughly what it cost". The script refuses to print a figure above the ledger ceiling; you must not reintroduce one in your summary.
- Say
unreconciledwhen it saysunreconciled.GET /usageis documented in prose and absent from the spec's paths, so an unreconciled ledger is the normal case. It means the total is the pack's own arithmetic and nothing has checked it. - Carry the
nwith every rate. A hop that went one-for-one is not a hundred percent hit rate; it is one call. - The per-hop credit column is not a spend. It is the journal's own figure, kept for attribution. If the report prints a DIVERGENCE line, the ledger governs and the run is partially unaccounted for — say that, rather than quoting whichever total reads better.
- Outcomes are the operator's word. Repeat them as such or leave them out.
Sharing a report
SHARE=1 writes a second file that is safe to forward. It is built from the same
aggregate object the report uses, which is produced by the pack's share renderer from
journal metadata — counts, sums and percentages the renderer computed itself. No
journal line is ever copied into it, row_id is used only as a set key and never
emitted, and the finished object is re-inspected and throws on any row-shaped key or
contact-shaped value before it reaches disk.
Send people that file. Do not paste the private report into a channel because "it looks fine" — the difference between the two files is a guarantee versus a glance.
What this skill will not do
- It will not make an API call. Zero, metered or free. Measuring is arithmetic over files the pack already wrote, and buying a number you already own is not measurement.
- It will not turn an estimate into an actual. Most metered endpoints never report
their charge. Those lines stay
estimated_unverifiable, the report stays a range, and the never-overstate guard throws rather than let a friendlier convention through. It will also not invent a second wording for "spent" — the phrasing comes from the receipt module so no caller can soften it. - It will not report a spend it cannot support. The guard recomputes from the raw ledger lines, and the script does not catch it. A claim above the ledger ceiling crashes the report rather than shipping it.
- It will not roll up several runs into one report. The journal's unit key is unique within a run only, so a cross-run merge silently collapses distinct rows and hands you a confident wrong denominator. Run it once per run.
- It will not observe a send, a reply, a meeting or a deal. Sending execution, LinkedIn actions, dialing and direct mail are outside the pack permanently. Outcome counts are typed in by the operator and are always labelled self-reported.
- It will not put a contact in a shareable file. The share artifact is aggregate by construction, not by filtering — a filter is one new field away from leaking.
- It will not judge the campaign. It can tell you what a run cost and what it found. Whether the offer, the copy or the targeting was any good is a human call.
Related
/learn— turns the hit rates in this report into priors for the next run, locally/enrich-waterfall— the run this skill measures, and where a low-coverage verdict sends you back to/campaign-review— the pre-launch gate; this skill is its after-the-fact twin/richapi-gtm— the router, and the session receiptrichapi gatesprints every threshold and the key it comes from
Need help setting it up?
This page tells you what measure does and what it needs. Cheetah builds the agent setup it runs inside: data, CRM, sequencing and the guardrails.
Book a call →The directory stays free. There is nothing gated behind this.