Agent skill
scheduled-workflow
Runs a saved workflow on a schedule, under an envelope the user approved in advance for a bounded, named set of work.
Filed under Prospecting and list building.
From richapiai/gtm-skills · 34 skill entries · 0 · pushed 2026-09-18
What it does when it runs
Runs a saved workflow on a schedule, under an envelope the user approved in advance for a bounded, named set of work. Use when asked to "run this every week", "schedule this", "automate this refresh", "do this nightly", or "set up a recurring enrichment". The scheduled run re-derives the plan before it spends, compares it against what was approved, and STOPS on any divergence — a repricing, a bigger plan, an exhausted envelope, an expired approval, or any gate that would have asked a human. Makes zero API calls itself. (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:*)
- Bash(richapi-skills-preflight:*)
- Bash(node:*)
- Bash(rm:*)
- Bash(touch:*)
- Bash(printf:*)
- Bash(grep:*)
- 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/scheduled-workflow" mkdir -p ~/.claude/skills/scheduled-workflow cp -R "/tmp/gtm-skills/skills/scheduled-workflow/." ~/.claude/skills/scheduled-workflow/
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/scheduled-workflow/SKILL.md, which is licensed MIT (repository). 6,560 words, 20 headings.
Run a workflow on a schedule
Law 3 says every paid call is named and costed before it runs. That law assumes a human is present. A schedule's entire purpose is that nobody is.
You cannot fix this by dropping the gate, and you cannot fix it by prompting into the void at three in the morning. What works is moving the approval earlier in time rather than removing it, and then refusing to run when the world has changed since.
The rule this skill is built on
Unattended,
confirmcollapses tostop.
That single line is the whole resolution, and it makes the gate stronger than it is for an attended run, not weaker.
Attended, the pack has three verdicts: allow, confirm, stop. confirm means a
human decides. With no human there is no third verdict, so anything that is not
allow is stop. A scheduled run may therefore only ever execute work that:
- the user approved as a named, priced plan — not as a budget, a plan; and
- the gate engine would wave through outright, with no question asked.
That has teeth, and the teeth are visible immediately. Every endpoint in
gates.yaml:always_ask.endpoints returns confirm regardless of remaining budget, so
no schedule in this pack may ever contain one of them. The skill refuses at
scheduling time, not at three in the morning. The same applies past
gates.yaml:unbounded_endpoints.pages_before_confirm: an unattended page-walk is
bounded at the last page a human did not have to approve.
A budget alone would not be enough and it is worth saying why, because "give it a monthly cap" is the obvious answer and it is the wrong one. A cap answers how much. It does not answer on what. Law 3 is a naming law before it is a costing law, and a cap with an unnamed call set behind it is an opt-out paid call with a ceiling on it.
The one carve-out, stated rather than buried
gates.yaml:session_budget confirms are accepted unattended. Every other confirm stops.
A session_budget confirm asks one question — do you want to spend this much of the
budget you set? — and the envelope approval is literally the answer to it. The user was
shown this plan, priced at this ceiling, and typed the ceiling out. Asking again, in the
middle of the night, with nobody to answer, protects nothing and makes every schedule
impossible.
It answers that question and no other. gates.yaml:always_ask.reason says those
endpoints confirm "regardless of remaining budget" — they are cost outliers and personal
data reach, and a budget approval cannot answer a consent question. The page gate stops
too, and that one matters most: for an unbounded endpoint the planned ceiling rests on
gates.yaml:unbounded_endpoints.assumed_results_per_page, so it is not a real ceiling at
all. An envelope cannot bound what the catalog cannot price.
The session_budget stop is untouched and fires as normal, here and again inside the
runtime.
Note the scope, because /signal-watch draws the line
differently for its own standing approval and says the spend fractions "cannot be
pre-approved away". Attended, that is right and this skill does not contradict it: an
interactive cycle still confirms. What is being carved out here is narrower — a single
run, whose --budget is the ceiling of a plan the user was shown and typed the total
of, at a moment when no confirm can be answered at all. If the two readings ever need to
be one reading, this is the narrower of them.
Inference mode — local
This skill runs on local inference only. It makes zero API calls, metered or
free, and never calls ai_enrich. Arming a schedule, checking one before it fires,
recording what it spent and reporting the burndown are all arithmetic over files the
pack already wrote: a dry-run plan, the generated catalog, the gate file and the ledger.
Nothing here needs a model's opinion, let alone a paid one.
The scheduled run itself spends — through richapi enrich, richapi call or
richapi search, gated exactly as an attended run would be. This skill decides whether
that command is allowed to start.
Before anything else
richapi-skills-preflight
Read the keys. API_KEY_SET: no does not block this skill — nothing here calls the API
— but it does mean the scheduled command will fail at its first call, so say so rather
than arming a schedule that cannot run. BALANCE: unknown is expected and is reported
as unknown; the envelope is a bound on what a run may plan, never a claim about what is
left in the account. SUPPRESSION: STOP blocks the scheduled run itself, fail-closed,
inside the runtime — do not arm around it.
There is no richapi schedule verb. The runtime ships enrich, call, search,
preflight, catalog and gates. Scheduling 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 will this run cost? | _lib/dryrun.mjs → buildPlan, via richapi ... --dry-run --json | a second pricing pass |
| Would a gate object? | _lib/run.mjs → gatePlanFor | a hand-rolled gate list |
Is a confirm acceptable? | never, unattended | treating silence as consent |
| Has the price moved? | _lib/api-catalog.json → the pricing block, fingerprinted | a remembered price |
| What did the run actually cost? | _lib/ledger.mjs → Ledger.totals, then _lib/receipt.mjs → buildReceipt | the plan estimate |
| May the report claim that figure? | _lib/receipt.mjs → assertNeverOverstates | catching that error |
The envelope burns down by the receipt ceiling, never the floor. Eleven metered endpoints never report their charge, so their floor is zero; a schedule that burned the floor would run forever on unverifiable spend and report that it had spent nothing. The ceiling is the only figure that cannot let a schedule overspend its envelope.
Step 1 — dry-run the work you want to repeat
Free, zero calls, and it is the artifact everything downstream is bound to.
richapi call enrich_company --in accounts.csv --dry-run --json > plan.json
Read the plan with the user. This is the named set of work: which endpoints, how many calls, what each one costs, which rows are already cached, and whether the total is a ceiling because a hop is conditional. If they would not approve this plan attended, they must not approve it unattended.
Step 2 — approve an envelope, not a budget
ROOT=. MODE=arm ID=weekly-accounts PLAN=plan.json RUNS=8 EVERY_HOURS=168 \
CMD='["richapi","call","enrich_company","--in","accounts.csv","--out","enriched.csv"]' \
APPROVE=<the exact envelope total the script prints> \
node --input-type=module -e "${GTM_SCHEDULE:?set this to the gtm-schedule script below}"
where $GTM_SCHEDULE is the script below, in every mode. Write it to a file and run
it if that is easier; it is the same script either way.
arm refuses more often than it accepts, and each refusal is the point:
- Any endpoint the gate engine would
confirm— an always-ask endpoint, a page past the free page, a single call worth a large fraction of the budget — refuses here, named, with the gate key that refused it. - Any endpoint in
gates.yaml:disabledrefuses. - A per-run ceiling outside
gates.yaml:session_budget.min_creditsandgates.yaml:session_budget.max_creditsrefuses; that ceiling becomes the run's--budget, so it has to be a budget the pack will accept. - A run count above
gates.yaml:skills.scheduled_workflow.max_runs_per_approval, an interval belowgates.yaml:skills.scheduled_workflow.min_interval_hours, or a total abovegates.yaml:skills.scheduled_workflow.max_envelope_creditsrefuses. The session budget bounds one run; these bound the whole standing approval, which is the thing that actually runs away. All three are read, never typed — and if one cannot be read, nothing is armed. - The approval is typed, not clicked.
APPROVEmust be the exact envelope total, by the same rule the CLI uses for an attended plan — the check isconfirmAcceptedfrombin/richapi.mjs, imported rather than re-implemented, so "y" cannot approve a schedule any more than it can approve a run.
What gets written to gtm/schedules/<id>/envelope.json is the whole contract: the
command, the endpoint set, the per-run ceiling and floor, the run count, the total
envelope, an expiry, and a price fingerprint taken from the catalog's pricing
block for every endpoint in the plan.
Step 3 — what the scheduled run does when nobody is watching
The trigger — Claude's own scheduler, cron, a CI timer, whatever the user already has
— runs three things in order. It must not run the second without the first.
# 1. Re-plan. Free, zero calls. The plan is re-derived, never remembered.
richapi call enrich_company --in accounts.csv --dry-run --json > fresh.json
# 2. Check the fresh plan against the approved envelope. Exit 3 means STOP; the only
# line on stdout is the budget, and a stop prints none, so there is nothing to run on.
VERDICT=$(ROOT=. MODE=check ID=weekly-accounts PLAN=fresh.json \
node --input-type=module -e "${GTM_SCHEDULE:?set this to the gtm-schedule script below}") || exit 0
eval "$(printf '%s\n' "$VERDICT" | grep '^RICHAPI_SCHEDULE_BUDGET=')"
# 3. Only now. --budget is the per-run ceiling the user approved.
richapi call enrich_company --in accounts.csv --out enriched.csv \
--budget "$RICHAPI_SCHEDULE_BUDGET" --yes --json > run.json
# 4. Burn the envelope down by what the ledger says, not by what the plan said.
ROOT=. MODE=record ID=weekly-accounts \
RUN="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("run.json","utf8")).run_id)')" \
node --input-type=module -e "${GTM_SCHEDULE:?set this to the gtm-schedule script below}"
--yes in step 3 is not "skip the gate". It is "the gate already ran, at approval time,
against this exact plan, and step 2 has just confirmed the plan has not moved". Remove
step 2 and --yes becomes an opt-out paid call, which law 3 forbids outright.
check stops (exit 3, nothing spent) on every one of these:
| Divergence | Why it stops rather than adapts |
|---|---|
disarmed | gtm/schedules/DISARMED exists. Whoever created it wins. |
no_envelope | The envelope is missing, unparseable, or not the schema it claims. A schedule that cannot read its own contract has no contract. Fail closed. |
approval_expired | An approval is not perpetual: it ages out at gates.yaml:skills.scheduled_workflow.approval_max_age_days. Sixteen of fifty-three surviving endpoints repriced in four months; an old approval is an approval of a world that no longer exists. |
runs_exhausted | The approved run count is spent, and it could never exceed gates.yaml:skills.scheduled_workflow.max_runs_per_approval. gates.yaml:session_budget.on_stop is raise_or_abort, and an envelope does not roll over either. |
too_soon | The cadence floor, gates.yaml:skills.scheduled_workflow.min_interval_hours. A trigger that misfires is a trigger that spends the envelope by lunchtime. |
repriced | The catalog's pricing block for an approved endpoint no longer fingerprints the same. Named, with both readings printed. |
plan_drift | The fresh plan reaches an endpoint the user never approved. |
envelope_exceeded | The fresh plan's ceiling is above the approved per-run ceiling. |
envelope_exhausted | The fresh plan's ceiling is above what remains. |
gate_stop / gate_confirm | gatePlanFor returned anything other than allow, on any endpoint or any planned page. |
halted | A previous run came in over its per-run ceiling. The schedule halts itself. |
A repricing stops the run even when the new price is lower. That is deliberate, and
it is the case people argue with. The approval was of a plan whose cost basis no longer
exists; direction is not the test. A price that reads cheaper per unit while
result_count_field moved underneath it bills more, and the pack has already been
bitten once by billing semantics changing rather than billing numbers —
post_keyword_search once billed per result on the total match count rather than the
page, and was disabled until the spec changed that. The
fingerprint therefore covers the whole pricing block, not the credit figure. Re-approve;
it is one dry run and one typed total.
Stop means stop, not "spend the envelope and stop there." A truncated run buys a partial list nobody asked for, and the pack has no way to tell the user which half they got until they come back. The whole run does not start.
Step 4 — what the user sees when they come back
ROOT=. MODE=status ID=weekly-accounts node --input-type=module -e "${GTM_SCHEDULE:?set this to the gtm-schedule script below}"
gtm/schedules/<id>/runs.jsonl gets a line for every fire, including the ones that
stopped. This is the part unattended systems get wrong: a silent stop and a silent
success look identical from a distance, so a schedule that has been refusing to run for
six weeks reads as a schedule that is working. A stop is written as loudly as a run.
Report the status verbatim, in this order, and do not reshuffle it into good news first:
- Stops first, with their reason codes. If the last fire stopped, that is the headline and the reason names its own fix.
- The spend as the receipt states it. The wording comes from
spendPhrase, which is where every spend claim in this pack is worded. If it says at least X, up to Y, say that. Never average, never round to the ceiling and call it the cost. - The burndown is a ceiling burndown. Say so. Remaining envelope is what remains against the worst case, so the schedule may well have more room than the number suggests — and that is the direction the error must point.
- Unverifiable lines, counted. If most of a schedule's spend is on endpoints that never report a charge, the user should learn that from the status page rather than from an invoice.
Stopping a schedule you did not write
The person who has to stop a runaway schedule at the weekend is rarely the person who armed it, and they will not have read this page. So there is no verb to learn, no flag to remember, and nothing to parse:
touch gtm/schedules/DISARMED # every schedule in this workspace, next fire
rm -rf gtm/schedules/weekly-accounts # just this one, permanently
Both work because the envelope is read fail closed. A check that cannot find, read
or validate its envelope stops; it never falls back to "no envelope, no limit". Deleting
the contract deletes the permission. That is the same law 5 shape as a missing gate key,
applied to the one file a schedule cannot run without.
Revoking the API key also stops all spend, everywhere, immediately. It is the blunt instrument and it is worth naming, because at two in the morning blunt is fine.
The script
// ==== gtm-schedule v1 ====
// Schedules as an approved envelope over a named plan. ZERO API calls.
// Run from the pack root.
//
// The design in one sentence: this file NEVER decides what a call costs, whether a
// gate objects, or what a run actually spent. It asks dryrun/gates/ledger/receipt and
// refuses on their answer. Its own arithmetic is comparison and subtraction.
//
// env: MODE arm | check | record | status
// ROOT project root holding gtm/ (default .)
// ID schedule id
// PLAN a `richapi ... --dry-run --json` file (arm, check)
// RUNS runs approved (arm)
// EVERY_HOURS cadence, in hours (arm)
// CMD the scheduled command, JSON array (arm)
// APPROVE the exact envelope total, typed (arm)
// RUN run id whose ledger lines to record (record)
// GATES_FILE / CATALOG_FILE / NOW (test seams)
//
// exit 0 = go / ok · 2 = usage or input · 3 = STOP (and nothing was spent)
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { loadGates, gateValue, createSession, setBudget, MissingGateKey, STOP, CONFIRM }
from './_lib/gates.mjs';
import { gatePlanFor } from './_lib/run.mjs';
import { Ledger } from './_lib/ledger.mjs';
import { buildReceipt, assertNeverOverstates, spendPhrase } from './_lib/receipt.mjs';
import { confirmAccepted } from './bin/richapi.mjs';
const ENVELOPE_SCHEMA = 'gtm.schedule_envelope.v1';
const ROOT = path.resolve(process.env.ROOT || '.');
const GTM = path.join(ROOT, 'gtm');
const SCHEDULES = path.join(GTM, 'schedules');
const MODE = (process.env.MODE || '').trim();
const ID = (process.env.ID || '').trim();
const NOW = process.env.NOW ? new Date(process.env.NOW) : new Date();
const die = (code, msg) => { console.error('schedule: ' + msg); process.exit(code); };
const round = (n) => Math.round((Number(n) || 0) * 1000) / 1000;
const dir = () => path.join(SCHEDULES, ID);
const envelopePath = () => path.join(dir(), 'envelope.json');
const logPath = () => path.join(dir(), 'runs.jsonl');
if (!['arm', 'check', 'record', 'status'].includes(MODE)) {
die(2, 'MODE must be arm | check | record | status');
}
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(ID)) {
die(2, 'ID must be a short lowercase slug — it is a directory name under gtm/schedules/');
}
// --- everything policy-shaped is read, never typed --------------------------
// A MissingGateKey anywhere below is a STOP, not a default (law 5). The four
// skills.scheduled_workflow.* keys are read here and cited in the prose above in
// their gates.yaml: form; inside gateValue() they are bare because the argument is
// a key path, not a citation. If one ever stops resolving, this script fails closed
// and no schedule can be armed. That is the correct state for a skill that spends
// unattended: half-configured is not configured.
const gates = loadGates(process.env.GATES_FILE || undefined);
function policy () {
return {
maxAgeDays: Number(gateValue(gates, 'skills.scheduled_workflow.approval_max_age_days')),
maxRuns: Number(gateValue(gates, 'skills.scheduled_workflow.max_runs_per_approval')),
minIntervalHours: Number(gateValue(gates, 'skills.scheduled_workflow.min_interval_hours')),
maxEnvelope: Number(gateValue(gates, 'skills.scheduled_workflow.max_envelope_credits')),
budgetMin: Number(gateValue(gates, 'session_budget.min_credits')),
budgetMax: Number(gateValue(gates, 'session_budget.max_credits')),
onStop: String(gateValue(gates, 'session_budget.on_stop')),
};
}
const catalogPath = process.env.CATALOG_FILE
|| path.join(path.resolve('.'), '_lib', 'api-catalog.json');
function catalog () {
try { return JSON.parse(fs.readFileSync(catalogPath, 'utf8')); } catch (e) {
die(3, 'cannot read the catalog at ' + catalogPath + ' — without it no call can be '
+ 'costed, so this is a STOP: ' + e.message);
}
}
// --- the price fingerprint --------------------------------------------------
// The WHOLE pricing block, not the credit figure. A per-result price that reads
// cheaper while result_count_field moved underneath it bills more, and that class of
// change is what once disabled post_keyword_search. Direction is not the test; identity is.
const PRICING_KEYS = ['model', 'credits_per_call', 'credits_base', 'credits_per_result',
'result_count_field', 'billing_field_present_in_response', 'bounded', 'page_gated',
'disabled_by_default'];
function pricingOf (def) {
const p = def?.pricing ?? null;
if (!p) return null;
return Object.fromEntries(PRICING_KEYS.map((k) => [k, p[k] ?? null]));
}
function fingerprint (def) {
const p = pricingOf(def);
if (!p) return null;
return createHash('sha256').update(JSON.stringify(p)).digest('hex').slice(0, 16);
}
// --- the plan artifact ------------------------------------------------------
function readPlan (which) {
const file = process.env.PLAN;
if (!file) die(2, which + ' needs PLAN=<a `richapi ... --dry-run --json` file>');
let doc;
try { doc = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); } catch (e) {
die(2, 'cannot read PLAN at ' + file + ': ' + e.message);
}
const plan = doc.plan ?? doc;
if (plan?.schema !== 'gtm.dryrun_plan.v1') {
die(2, 'PLAN is not a gtm.dryrun_plan.v1 artifact. Produce it with '
+ '`richapi <verb> ... --dry-run --json`; do not hand-write it.');
}
if (doc.mode && doc.mode !== 'dry-run') {
die(2, 'PLAN came from a run that actually spent (mode=' + doc.mode + '). '
+ 'An envelope must be bound to a dry run, which is free and makes zero calls.');
}
const endpoints = plan.per_hop.filter((h) => h.calls_planned > 0).map((h) => h.endpoint);
return {
plan,
kind: doc.kind ?? 'rows',
pages: Array.isArray(doc.pages) ? doc.pages : [],
endpoints: [...new Set(endpoints)],
ceiling: round(plan.totals.credits_estimated),
floor: round(plan.totals.credits_estimated_floor ?? plan.totals.credits_estimated),
};
}
// --- the unattended gate ----------------------------------------------------
//
// gatePlanFor is the pack's gate engine, unchanged. The only thing added here is the
// collapse: with nobody present, a `confirm` has no one to answer it, so it is a stop.
//
// ONE CARVE-OUT, and it is the only line on this page anyone could call a weakening,
// so it is stated in full rather than buried.
//
// `session_budget.*` confirms are accepted. Every other confirm stops. The reason is
// that a session_budget confirm asks exactly one question — "do you want to spend this
// much of the budget you set?" — and the envelope approval IS the answer to it: the
// user was shown this plan, priced at this ceiling, and typed that ceiling out. Asking
// again, at three in the morning, with no one to answer, is not a control; it would
// make every schedule impossible while protecting nothing, and gate fatigue is how the
// only control there is gets killed.
//
// It answers that question and NO OTHER. `always_ask` explicitly says it fires
// "regardless of remaining budget", so a budget approval cannot answer it — those are
// per-call cost outliers and personal-data reach, and they stop. The page gate stops
// too, and that one matters most: for an unbounded endpoint the planned ceiling is
// built on an ASSUMED results-per-page, so it is not a real ceiling at all, and the
// human between pages is the only actual bound. An envelope cannot bound what the
// catalog cannot price.
//
// The session_budget STOP is untouched and still fires: if the plan exceeds the
// budget, the run is stopped by the engine, here and again inside the runtime.
const BUDGET_GATE = /^session_budget\b/;
function unattendedGate (fresh, budget, cat) {
const session = createSession({ gates, runId: 'schedule:' + ID });
const set = setBudget(session, budget);
if (set.decision === STOP) return [{ code: 'gate_stop', why: set.reason }];
const g = gatePlanFor({ plan: fresh.plan, catalog: cat, session, pages: fresh.pages });
const out = [];
for (const d of g.stops) {
out.push({ code: 'gate_stop', why: (d.endpoint ?? '?') + ': ' + d.reason + ' [' + d.gate + ']' });
}
for (const d of g.confirms) {
if (BUDGET_GATE.test(String(d.gate ?? ''))) continue; // answered by the envelope
out.push({
code: 'gate_confirm',
why: (d.endpoint ?? '?') + ': the gate asks a human here — ' + d.reason + ' [' + d.gate
+ ']. Unattended there is no human, so a confirm is a stop. The envelope answered '
+ 'the budget question; it did not answer this one. This workflow cannot be '
+ 'scheduled as written; narrow it until every non-budget gate allows it outright.',
});
}
return out;
}
// --- the log ----------------------------------------------------------------
// A stop is written as loudly as a run. Silence is how an unattended system lies.
function log (line) {
fs.mkdirSync(dir(), { recursive: true });
fs.appendFileSync(logPath(), JSON.stringify({ ts: NOW.toISOString(), ...line }) + '\n', 'utf8');
}
function readLog () {
try {
return fs.readFileSync(logPath(), 'utf8').split('\n').filter(Boolean)
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
} catch { return []; }
}
// --- the envelope, read fail closed -----------------------------------------
// Missing, unparseable or wrong-schema is a STOP, never "no envelope, no limit".
// That is what makes `rm -rf gtm/schedules/<id>` a kill switch nobody has to learn.
function readEnvelope () {
let raw;
try { raw = fs.readFileSync(envelopePath(), 'utf8'); } catch {
return { ok: false, why: 'no envelope at ' + envelopePath() + '. A schedule with no '
+ 'approved envelope has no approval; nothing runs.' };
}
let e;
try { e = JSON.parse(raw); } catch (err) {
return { ok: false, why: 'envelope is unparseable (' + err.message + '). A schedule '
+ 'that cannot read its own contract has no contract.' };
}
if (e?.schema !== ENVELOPE_SCHEMA) {
return { ok: false, why: 'envelope schema is "' + e?.schema + '", expected ' + ENVELOPE_SCHEMA };
}
return { ok: true, envelope: e };
}
function writeEnvelope (e) {
fs.mkdirSync(dir(), { recursive: true });
fs.writeFileSync(envelopePath(), JSON.stringify(e, null, 2) + '\n', 'utf8');
}
// --- the divergence check ---------------------------------------------------
function divergences (e, fresh, cat, pol) {
const stops = [];
const push = (code, why) => stops.push({ code, why });
if (e.halted) {
push('halted', 'this schedule halted itself: ' + e.halted
+ '. Re-arm it deliberately once you know why.');
}
const expires = Date.parse(e.expires_at);
if (!Number.isFinite(expires)) push('approval_expired', 'envelope has no readable expiry');
else if (NOW.getTime() > expires) {
push('approval_expired', 'the approval expired at ' + e.expires_at
+ '. Sixteen of fifty-three surviving endpoints repriced in four months, so an old '
+ 'approval is an approval of a world that no longer exists. Re-dry-run and re-arm.');
}
if (e.runs_used >= e.runs_approved) {
push('runs_exhausted', e.runs_used + ' of ' + e.runs_approved
+ ' approved runs are spent, and an envelope does not roll over (policy: ' + pol.onStop + ')');
}
const last = readLog().filter((l) => l.outcome === 'ran').pop();
if (last) {
const gapH = (NOW.getTime() - Date.parse(last.ts)) / 3600000;
if (Number.isFinite(gapH) && gapH < e.every_hours) {
push('too_soon', 'last run was ' + round(gapH) + 'h ago; this schedule fires no more '
+ 'often than every ' + e.every_hours + 'h. A misfiring trigger spends the whole '
+ 'envelope before anyone is awake.');
}
}
// Repricing. Named, with both readings, for every approved endpoint.
for (const [ep, recorded] of Object.entries(e.price_fingerprint ?? {})) {
const def = cat.endpoints?.[ep];
if (!def) {
push('repriced', ep + ' is no longer in the catalog. It was approved; it cannot be priced.');
continue;
}
const now = fingerprint(def);
if (now !== recorded) {
push('repriced', ep + ' repriced since approval.\n'
+ ' approved: ' + JSON.stringify(e.pricing_at_approval?.[ep] ?? null) + '\n'
+ ' now: ' + JSON.stringify(pricingOf(def)) + '\n'
+ ' This stops the run whether the new price is higher or lower: the approval '
+ 'was of a plan whose cost basis no longer exists. Re-dry-run and re-arm.');
}
}
if (fresh) {
const approved = new Set(e.endpoints);
for (const ep of fresh.endpoints) {
if (!approved.has(ep)) {
push('plan_drift', 'the fresh plan reaches ' + ep + ', which is not in the approved '
+ 'endpoint set (' + e.endpoints.join(', ') + '). Nobody named that call.');
}
}
if (fresh.ceiling > e.per_run_ceiling_credits + 1e-9) {
push('envelope_exceeded', 'the fresh plan\'s ceiling is ' + fresh.ceiling
+ ' against an approved per-run ceiling of ' + e.per_run_ceiling_credits
+ '. The run does not start. It is not truncated to fit: a half-bought list is a '
+ 'partial charge for something nobody asked for.');
}
const remaining = round(e.envelope_credits - e.credits_spent_ceiling);
if (fresh.ceiling > remaining + 1e-9) {
push('envelope_exhausted', 'the fresh plan\'s ceiling is ' + fresh.ceiling
+ ' and only ' + remaining + ' remains of the approved envelope');
}
const budget = Math.min(e.per_run_ceiling_credits, Math.max(remaining, 0));
if (budget > 0) for (const d of unattendedGate(fresh, budget, cat)) push(d.code, d.why);
}
return stops;
}
function reportStops (stops, where) {
console.error('schedule ' + ID + ': STOP — nothing was spent.');
for (const s of stops) console.error(' [' + s.code + '] ' + s.why);
log({ outcome: 'stopped', where, stop_codes: stops.map((s) => s.code),
reasons: stops.map((s) => s.code + ': ' + s.why.split('\n')[0]) });
}
// --- modes ------------------------------------------------------------------
function armMode () {
const pol = policy();
const cat = catalog();
const fresh = readPlan('arm');
const runs = Number(process.env.RUNS);
const every = Number(process.env.EVERY_HOURS);
let cmd = null;
try { cmd = JSON.parse(process.env.CMD || 'null'); } catch { /* reported below */ }
if (!Array.isArray(cmd) || cmd.length === 0 || !cmd.every((s) => typeof s === 'string')) {
die(2, 'CMD must be a JSON array of strings — the exact command the schedule runs. '
+ 'A schedule whose command is described in prose is a schedule nobody can audit.');
}
if (!Number.isInteger(runs) || runs < 1 || runs > pol.maxRuns) {
die(2, 'RUNS must be a whole number of runs within the pack\'s per-approval maximum');
}
if (!Number.isFinite(every) || every < pol.minIntervalHours) {
die(2, 'EVERY_HOURS is below the pack\'s cadence floor');
}
if (fresh.ceiling <= 0) {
die(2, 'this plan spends nothing — there is no envelope to approve. If every row is '
+ 'cached or suppressed, schedule it when it has work to do.');
}
if (fresh.ceiling < pol.budgetMin || fresh.ceiling > pol.budgetMax) {
die(2, 'the per-run ceiling falls outside the session-budget bounds in gates.yaml, and '
+ 'it becomes this run\'s --budget, so the pack would refuse it at run time');
}
const envelope = round(fresh.ceiling * runs);
if (envelope > pol.maxEnvelope) {
die(2, 'the total envelope exceeds the pack\'s maximum for one approval');
}
// The same unattended gate the scheduled run will face. Refusing here, in front of
// the user, is the whole point: a schedule that could never run must not be armed.
const blocked = unattendedGate(fresh, fresh.ceiling, cat);
if (blocked.length > 0) {
console.error('schedule ' + ID + ': cannot be armed.');
for (const b of blocked) console.error(' [' + b.code + '] ' + b.why);
console.error('\nNothing was written. Narrow the workflow, or run it attended.');
process.exit(3);
}
console.log('Schedule ' + ID);
console.log(' command ' + cmd.join(' '));
console.log(' endpoints ' + fresh.endpoints.join(', '));
console.log(' per run floor ' + fresh.floor + ', ceiling ' + fresh.ceiling
+ (fresh.plan.totals.estimate_is_ceiling ? ' (ceiling — conditional work may not fire)' : ''));
console.log(' runs approved ' + runs + ' every ' + every + 'h');
console.log(' ENVELOPE ' + envelope + ' (ceiling x runs)');
const unverifiable = fresh.endpoints
.filter((ep) => cat.endpoints?.[ep]?.pricing?.billing_field_present_in_response !== true);
console.log('');
console.log('The envelope burns down by the receipt CEILING, never the floor.');
console.log(unverifiable.length
? ' unverifiable ' + unverifiable.join(', ')
+ '\n These never report their charge, so their floor is zero and a floor'
+ '\n burndown would never end. Expect the burndown to overstate.'
: ' unverifiable none — every endpoint here reports its charge, so the'
+ '\n ceiling is also the actual and the burndown is exact.');
console.log('');
if (!confirmAccepted(process.env.APPROVE, envelope)) {
die(3, 'not approved. Re-run with APPROVE set to the exact envelope total printed '
+ 'above. Typing the total IS the approval — "yes" is not one.');
}
const expires = new Date(NOW.getTime() + pol.maxAgeDays * 86400000).toISOString();
writeEnvelope({
schema: ENVELOPE_SCHEMA,
id: ID,
created_at: NOW.toISOString(),
expires_at: expires,
command: cmd,
endpoints: fresh.endpoints,
kind: fresh.kind,
pages: fresh.pages,
per_run_ceiling_credits: fresh.ceiling,
per_run_floor_credits: fresh.floor,
estimate_is_ceiling: Boolean(fresh.plan.totals.estimate_is_ceiling),
runs_approved: runs,
every_hours: every,
envelope_credits: envelope,
runs_used: 0,
credits_spent_ceiling: 0,
unverifiable_endpoints: unverifiable,
pricing_at_approval: Object.fromEntries(
fresh.endpoints.map((ep) => [ep, pricingOf(cat.endpoints?.[ep])])),
price_fingerprint: Object.fromEntries(
fresh.endpoints.map((ep) => [ep, fingerprint(cat.endpoints?.[ep])])),
catalog_provenance: fresh.plan.catalog_provenance ?? null,
halted: null,
});
log({ outcome: 'armed', envelope_credits: envelope, runs_approved: runs,
per_run_ceiling_credits: fresh.ceiling, expires_at: expires });
console.log('armed. envelope ' + envelopePath());
console.log('disarm: touch ' + path.join(SCHEDULES, 'DISARMED') + ' | rm -rf ' + dir());
process.exit(0);
}
function checkMode () {
if (fs.existsSync(path.join(SCHEDULES, 'DISARMED'))) {
reportStops([{ code: 'disarmed', why: path.join(SCHEDULES, 'DISARMED')
+ ' exists. Someone stopped every schedule in this workspace; that wins.' }], 'check');
process.exit(3);
}
const read = readEnvelope();
if (!read.ok) {
reportStops([{ code: 'no_envelope', why: read.why }], 'check');
process.exit(3);
}
const e = read.envelope;
const pol = policy();
const cat = catalog();
const fresh = readPlan('check');
const stops = divergences(e, fresh, cat, pol);
if (stops.length > 0) { reportStops(stops, 'check'); process.exit(3); }
const remaining = round(e.envelope_credits - e.credits_spent_ceiling);
const budget = Math.min(e.per_run_ceiling_credits, remaining);
log({ outcome: 'cleared', plan_ceiling: fresh.ceiling, budget, envelope_remaining: remaining });
console.log('RICHAPI_SCHEDULE_BUDGET=' + budget);
console.log('schedule ' + ID + ': GO. plan ceiling ' + fresh.ceiling
+ ', envelope remaining ' + remaining + ' of ' + e.envelope_credits + '.');
process.exit(0);
}
function recordMode () {
const runId = (process.env.RUN || '').trim();
if (!runId) die(2, 'record needs RUN=<run id>');
const read = readEnvelope();
if (!read.ok) die(3, read.why);
const e = read.envelope;
const ledgerFile = path.join(GTM, 'api-calls.jsonl');
let lines = [];
try {
lines = fs.readFileSync(ledgerFile, 'utf8').split('\n').filter(Boolean)
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
.filter((l) => l && l.run_id === runId);
} catch { lines = []; }
// The pack has exactly one cost calculation and this is not a second one.
const ledger = Object.assign(new Ledger({ dir: GTM }), { lines });
const receipt = buildReceipt({ ledger, gates, runLabel: runId });
assertNeverOverstates(receipt, ledger); // deliberately not wrapped in a try
const burn = receipt.credits_ceiling; // ceiling, never floor
e.runs_used += 1;
e.credits_spent_ceiling = round(e.credits_spent_ceiling + burn);
const over = burn > e.per_run_ceiling_credits + 1e-9;
if (over) {
e.halted = 'run ' + runId + ' cost up to ' + burn + ' against a per-run ceiling of '
+ e.per_run_ceiling_credits + '. Halted rather than allowed to repeat.';
}
writeEnvelope(e);
log({ outcome: 'ran', run_id: runId, calls: receipt.calls,
credits_floor: receipt.credits_floor, credits_ceiling: receipt.credits_ceiling,
exact: receipt.exact, unverifiable_lines: receipt.unverifiable_lines,
spend_phrase: spendPhrase(receipt),
envelope_remaining: round(e.envelope_credits - e.credits_spent_ceiling),
halted: e.halted });
console.log('run ' + runId + ': ' + spendPhrase(receipt));
console.log('envelope: burned ' + burn + ' (ceiling), '
+ round(e.envelope_credits - e.credits_spent_ceiling) + ' of ' + e.envelope_credits
+ ' remaining, ' + e.runs_used + ' of ' + e.runs_approved + ' runs used.');
if (over) { console.error('schedule ' + ID + ': HALTED — ' + e.halted); process.exit(3); }
process.exit(0);
}
function statusMode () {
const read = readEnvelope();
if (!read.ok) { console.error('schedule ' + ID + ': ' + read.why); process.exit(3); }
const e = read.envelope;
const entries = readLog();
const stopped = entries.filter((l) => l.outcome === 'stopped');
const ran = entries.filter((l) => l.outcome === 'ran');
const L = [];
L.push('# Schedule ' + ID);
L.push('');
if (e.halted) L.push('**HALTED.** ' + e.halted);
if (fs.existsSync(path.join(SCHEDULES, 'DISARMED'))) {
L.push('**DISARMED.** ' + path.join(SCHEDULES, 'DISARMED') + ' exists; nothing fires.');
}
const lastStop = stopped[stopped.length - 1];
if (lastStop && (!ran.length || Date.parse(lastStop.ts) > Date.parse(ran[ran.length - 1].ts))) {
L.push('');
L.push('**The last fire STOPPED.** ' + (lastStop.reasons ?? []).join(' | '));
L.push('A stop is not a pause. It repeats every fire until the cause is fixed.');
}
L.push('');
L.push('- command: `' + (e.command ?? []).join(' ') + '`');
L.push('- endpoints: ' + e.endpoints.join(', '));
L.push('- approved: ' + e.created_at + ', expires ' + e.expires_at);
L.push('- runs: ' + e.runs_used + ' of ' + e.runs_approved + ', every ' + e.every_hours + 'h');
L.push('- envelope: ' + e.credits_spent_ceiling + ' burned of ' + e.envelope_credits
+ ', ' + round(e.envelope_credits - e.credits_spent_ceiling) + ' remaining');
L.push('- fires: ' + ran.length + ' ran, ' + stopped.length + ' stopped');
L.push('');
L.push('The burndown above is a CEILING burndown. '
+ (e.unverifiable_endpoints?.length
? 'These endpoints never report their charge, so their floor is zero and only the '
+ 'ceiling can bound them: ' + e.unverifiable_endpoints.join(', ') + '.'
: 'Every endpoint here reports its charge, so the ceiling is also the actual.'));
L.push('');
L.push('| when | outcome | detail |');
L.push('|---|---|---|');
for (const l of entries.slice(-20)) {
const detail = l.outcome === 'ran' ? l.spend_phrase
: l.outcome === 'stopped' ? (l.stop_codes ?? []).join(', ')
: l.outcome === 'armed' ? ('envelope ' + l.envelope_credits)
: 'plan ceiling ' + (l.plan_ceiling ?? '?');
L.push('| ' + l.ts + ' | ' + l.outcome + ' | ' + String(detail).replace(/\|/g, '/') + ' |');
}
const text = L.join('\n') + '\n';
const out = process.env.OUT || path.join(dir(), 'status.md');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, text, 'utf8');
console.log(text);
process.exit(0);
}
try {
if (MODE === 'arm') armMode();
else if (MODE === 'check') checkMode();
else if (MODE === 'record') recordMode();
else statusMode();
} catch (err) {
if (err instanceof MissingGateKey) {
die(3, err.message + '\n A missing gate key is a STOP, not "no gate" (law 5). '
+ 'Nothing was armed and nothing was spent.');
}
throw err;
}
// ==== end gtm-schedule v1 ====
Do not build a watchlist here
/signal-watch has its own recurring-cost problem and its
own machinery for what to watch — the entity list, its ceilings in
gates.yaml:watchlist.max_entities and gates.yaml:watchlist.max_entities_hard_stop,
the refresh batching, the per-watch cadence floor at
gates.yaml:watchlist.min_refresh_interval_hours, and the dedupe that stops a signal
being reported twice. None of that belongs here and this skill must not grow a second
copy of it.
The split is clean, and both pages state it the same way: /signal-watch decides what
to look at and what the standing charge is; this skill decides whether a run may start
when nobody is watching. A recurring watch is armed like anything else — dry-run one
cycle, approve the envelope, let check gate each fire. It composes because a watch
cycle is page one only, which the page gate allows outright.
The two skills hold overlapping but differently-scoped approvals — a subscription projection there, a per-run envelope here — and neither should grow into the other. If a watch ever needs a per-fire cost control it should take this envelope rather than invent one: two envelope implementations means two answers to "may this spend", and this pack has already caught two modules computing the same set and getting different answers.
What this skill will not do
- It will not make an API call. Zero, metered or free. Arming, checking, recording and reporting are arithmetic over files the pack already wrote.
- It will not run unattended work a human would have been asked about. Every
always-ask endpoint and every page past the free page is a
confirm, and unattended aconfirmis astop. The single carve-out isgates.yaml:session_budget, for the reason given above and for no other reason. This is the constraint people will most want relaxed and it is the one thing on this page that is not negotiable. - It will not start a run whose plan is over the envelope, and it will not truncate
one to fit. There is no partial mode, because a half-bought list is a full charge for
work nobody approved and the pack cannot say which half arrived. Be precise about the
other case, though: if reality diverges during a run — a per-result endpoint returns
more than the plan assumed — the runtime's own
gates.yaml:session_budget.fractions.stophalts it at the approved ceiling, mid-run. That is a truncation, it is the correct backstop, andrecordreports it and halts the schedule rather than letting it repeat. - It will not proceed through a repricing and reconcile afterwards. Sixteen of fifty-three surviving endpoints repriced in four months. Repricing is routine, and "spend first, apologise in the morning" is not a cost control.
- It will not treat an approval as perpetual. Approvals expire. Re-approving is one dry run and one typed total.
- It will not burn the envelope by the floor. Eleven metered endpoints never report their charge; a floor burndown on those never ends.
- It will not create the trigger for you. Cron, a CI timer or Claude's own scheduler fires the command; this skill decides whether the command may spend. It does not install itself into anything, because a scheduler this skill cannot see is a scheduler it cannot honestly claim to have disarmed.
- It will not send, post, dial or upload. Sending execution, LinkedIn actions, dialing and direct mail are outside the pack permanently, on a schedule as much as by hand.
- It will not decide the cadence for you. How stale your data is allowed to get is a business judgement. The pack enforces a floor on how often a schedule may fire, not an opinion on how often it should.
Related
/cost-optimizer— where the envelope went, and whether the recurring work was worth what it cost/signal-watch— the standing charge and the watchlist; it declines to schedule itself and hands the clock here/measure— what one scheduled run actually did, coverage first/enrich-waterfall— the most commonly scheduled workflow, and the dry-run pattern an envelope is bound to/comply— retention and erasure keep running while the schedule does; an unattended schedule is still writing PII/richapi-gtm— the router, and the session receiptrichapi gatesprints every threshold and the key it comes from
Other skills for the same job
Different authors, same problem. Matched on the words in the skill name, across every library in the catalogue except this one.
- content-workflow by thatrebeccarae · 162
- suede-workflow-skills by JasonColapietro · 127
- gtm-workflow by eliasstravik · 15
- meta-workflow-optimizer by Ad-Superpowers · 5
- automation-workflow-designer by ekatasingh1107 · 2
- account-workflow by Sparq-Holding-Inc · 1
Need help setting it up?
This page tells you what scheduled-workflow 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.