@spendgraph/workflows
v0.6.0
Published
Ready-made workflows assembled from the spendgraph packages.
Maintainers
Readme
@spendgraph/workflows
Ready-made workflows assembled from the spendgraph packages. One so far: noesis, a question-answering loop that decides how much of itself a question is worth.
npm install @spendgraph/workflowsimport { run } from "@spendgraph/workflows";
const result = await run(client, llm, "Has the price changed since March?", {
tools: bus,
context: { now: new Date().toISOString(), locale: "IN / INR" },
});
result.text; // the answer, as a person reads it
result.status; // "answered" | "abstained" | "escalated"
result.route; // which of the four it earnedclient is a PromptClient and llm an Llm. The two are separate on
purpose: the prompt client pulls the stored wording and records what the call
cost, and the model client makes it.
Three routes, chosen in code
Stage 0 is a router prompt that returns five signals — difficulty, stakes,
confidence, ambiguity, tool need — and never a route. routeFor in
src/noesis/route.ts turns those into one, from thresholds you can move after
an eval run without touching a prompt.
| route | what it runs | when |
| --- | --- | --- |
| direct | 6C alone | easy, unambiguous, already known |
| standard | frame, split, plan, execute, check, close | everything in between |
| full | standard plus four audits | high stakes, expert difficulty, or real ambiguity |
Stakes are tested first, because that is the one signal where being wrong is expensive in only one direction: an easy question answered the long way costs tokens, a critical one answered directly costs more than that.
The stages
| | | | | --- | --- | --- | | 0 | triage | five signals | | 1 | question brief | the question interrogated, never answered | | 2 | decompose | ordered sub-questions, at most seven, depth one | | 3A | plan candidates | approaches, the one chosen, the steps that follow | | 3B | premortem | the plan assumed dead, and what killed it | | 3C | cqot gate | eight questions of the plan | | 4 | execution | code — every sub-question through the tool its hint named | | 4A | think checkpoint | predicted against actual, against 3A's own tolerance | | 4B | discriminating test | the one test that tells the live hypotheses apart | | 5A | cove questions | the verification questions the draft invites | | 5B | cove answers | each answered blind | | 5 | verification | code — 5A and 5B paired back together by id | | 5C | standards review | the answer against eight standards | | 6A | closure checklist | the last questions before it may close | | 6B | red team | the answer attacked | | 6C | toulmin | the artifacts compiled into a claim and its qualifier | | R | reflection | the earliest wrong decision behind a failed gate |
Two of them are code and not model calls. Stage 4 dispatches and does not
reason, because deep_recall and web_search already return a synthesised
answer. Stage 5 pairs 5A's claims back to 5B's blind answers, and it has to be
code: whatever does it sees both the claim and the answer written without it,
and a model given both is no longer a blind check of anything.
Choosing models
stage sets the model every stage runs on. stages overrides it by name, for
the stages worth paying more or less for.
await run(client, llm, question, {
stage: { model: "claude-haiku-4-5" },
stages: {
plan_candidates: { model: "claude-sonnet-4-6" },
red_team: { model: "claude-opus-4-8" },
answer: { model: "claude-sonnet-4-6" },
},
});The keys are the stage names from the table above with the spaces closed up —
triage, question_brief, plan_candidates, red_team, answer — and
STAGE_KEYS maps every id to its own. triage and answer also have
opts.triage and opts.answer, which
take more than a model — a current task, and the answer's escalating tiers —
and win where both are set.
params does not follow a model it was not written for. A stage naming its
own model gets its own params or none: Haiku takes temperature, Sonnet 5
rejects it outright, and one shared request body fails on whichever it was not
written for. A stage overriding only params still merges over stage — except
answer, which escalates through tiers, so its params reach it only alongside
a model.
What a run cost
usage is the model spend: six numbers rather than two, summed across every
stage and every retry, split byStage and byModel because the answer can
cascade. A back edge refunds nothing.
usage.tools is what the tools reported, and it is deliberately not folded
in — a tool call is somebody else's API at somebody else's rates and is no
rollout of ours.
result.usage.inputTokens; // the model side
result.usage.byModel["claude-sonnet-4-6"];
result.usage.tools.calls; // the tool side
result.usage.tools.failed; // and what did not come back
result.usage.tools.costMicros; // where a tool priced itself
result.usage.tools.byTool.web_search; // tokens, keyed by the tool that ranBill both or you undercharge every run that touched the web: web_search
reports Perplexity's four token counts and deep_recall reports micro-USD,
and stage 4 records zero tokens of its own.
The signals
Stage 0 estimates five numbers and never a route. The prompt block that produces
them is a versioned prompt on the server (triage-router-v1-81c2cc), which
nothing in this repository can gate — a revision promoted there takes effect on
every consumer without a deploy.
The ledger
Every stage files its result, its rollouts and its tokens in one place. That
record is what 6C compiles from, keyed by stage id and name together — "3A plan
candidates" — because the model writes one of them back as
grounds[].source_stage and either has to lead to the entry.
It also carries the retry budget. A gate that will not let the answer through spends one, runs R to find the earliest wrong decision, and cuts the ledger back to where the loop restarts, so nothing stale is compiled into the answer. What it cost stays: tokens were spent and rollouts were recorded, and going back does not un-spend them. The nth retry escalates instead of looping again.
Asking, and stopping
Two different problems, so two mechanisms.
onAsk is asking. Stage 1 is the only stage that reaches for a person mid-run,
and only when it says the asker aimed at the wrong thing; a replacement question
restarts the framing with it, and with nobody to ask the run proceeds on the
question as put.
result.status is stopping. answered is an answer to act on. abstained is
6C saying the grounds supported no claim. escalated is a gate, or a spent
retry budget, asking for a person — and it outranks an abstention, because a
gate that asked for somebody said so about the whole run. Both still carry
text, because saying what is not known is worth reading.
onDeliver runs once, after the loop, and only for answered. Anything that
renders a deliverable belongs there: nothing downstream reads it, no gate can
check it, every back edge would make it again, and generating a
finished-looking document out of an abstention is the worst thing this loop
could produce.
Where it sits
@spendgraph/harness holds the patterns — refine, route, chain,
parallel, loop, orchestrate, cascade — each general and unaware of any
particular job. This package holds finished workflows built out of them, each
solving one job end to end. Nothing that belongs to all seven ever lands here.
Nothing here talks to the database or holds a provider key. run() is handed
its clients and its ToolBus; where a workflow needs the server it goes through
@spendgraph/sdk.
Layout
src/
index.ts the public surface
noesis/
route.ts the thresholds, and nothing else
stage.ts pull, ask through {question}, force the schema
question.ts askThrough — the unmigrated-prompt floor
router/ producers/ gates/ repair/ one directory per stage
execute/ draft/ verify/ the code stages, and what they render
run/ the ledger, the routes, and the gate loop
tests/ excluded from dist
GAPS.md what is wrong, what was fixed, what needs a decisionNot yet met a real prompt
Every test runs against a fake PromptClient and Llm, which is what made
building this cheap. Two assumptions carry the design and neither has been
exercised against the server:
- The stored prompts render
{question}. Where one does not,askThroughoverwrites the box and warns once on stderr. The run still succeeds and quietly answers whatever was saved on the prompt, so a warning per stage on the first live run is the signal, and publishing is the fix. - 6C reads the artifact keys back, so a citation names the stage that produced its evidence.
examples/ runs each route against the real prompts for a few pennies. Start
with 05-standard.mjs: it is the cheapest that looks anything up, and it exercises triage, the
dispatcher, the ledger and 6C. The routes that look something up need one of
the two lookups configured — LOCUSGRAPH_AGENT_SECRET for deep_recall, or
PERPLEXITY_API_KEY for web_search — and say so rather than half-running.
License
Apache-2.0
