How to build a tool
A practical guide to building a tenwhy tool — the unit of work a specialist runs. If you're an agent picking this up cold: read this top to bottom, then build. The rules a tool must obey live in CONTRACT.md (normative); what a tool is and why lives in design §10 and §11. This page is the how.
prerequisite Tools are built on
@tenwhy/agent-cli-kit + the starter template. They don't exist yet —
build them first (§0). This guide doubles as their spec: the kit
implements the contract once so a tool author writes only tool-specific logic.
0 · Build the kit first
Everything below assumes @tenwhy/agent-cli-kit and the
create-tenwhy-tool template already exist. They don't yet — build them
once, before any tool. The code examples in this guide are the kit's
intended interface, so building the kit means making them real. Until it exists, a tool's
import { defineCommand } from '@tenwhy/agent-cli-kit' resolves to nothing.
One kit, built once; then every tool is built on it. What the kit must provide:
| Surface | What it does |
|---|---|
defineCommand + z | The command API (Zod re-exported). A tool author touches only this — never Stricli directly. |
| The runner | Wraps Stricli: parses args, validates input, emits the {ok,data,error,meta} envelope on --json, maps errors → exit codes, bounds output, and provides the universal flags (--json, --describe, --dry-run, --fields, --limit…). The whole contract, implemented once. |
--describe generator | Emits the machine schema (CONTRACT §9) from the Zod definitions — the surface Maestro routes on. |
ctx (run context) | Helpers handed to every run: error() (enumerated), cost(), idem(), applyPlan(), run-dir read/write, and egress-allow-listed clients for the LLM gateway + scraping service. (Tool-specific libs like Lighthouse are the tool's own deps, not the kit's.) |
verify | The conformance checker run in CI (§9) — asserts envelope, verbs, errors, exit codes, bounded output, and --describe ↔ schema. |
create-tenwhy-tool | The scaffolder + template that stamps out a conforming tool repo (§3). |
order Kit → template → tools. If you're an agent told to "build a tool" and the kit isn't published yet, build the kit first using the table above as its spec, then come back to §3.
1 · The stack (settled)
Every tool uses the same stack. No per-tool variation — uniformity is the whole point.
| Choice | Decision & why |
|---|---|
| Language | TypeScript, end to end. The ecosystem tools need (Exa, Anthropic/Vercel SDKs, cheerio, Lighthouse) is decisively TS/Node. |
| Runtime | Node 20 in the µVM (S13 — Node-only, unamended). Tools are bundled to a single JS file with esbuild/tsup and run on plain node. |
| Bun | Optional dev accelerator only (fast installs/tests). Not the runtime; nothing ships as a bun --compile binary. Lighthouse/Playwright-class libs are first-class on Node and still hit edges on Bun. |
| CLI framework | Stricli — TS-first, explicit, typed. But you never import it directly; the kit wraps it. Swappable internal detail. |
| Schemas | Zod — one schema drives input validation, the result shape, and the generated --describe. |
| Repo | One repo per tool (tenwhy-tools/<name>, decision #23), depending on the published kit. The kit lives in its own monorepo. |
2 · Three layers — you only write the top one
Consistency is enforced, not hoped for. It's split across three layers so nothing drifts:
| Layer | What it is | Who owns it |
|---|---|---|
| Contract (versioned) | The rules: envelope, canonical verbs, exit codes, plan/apply, --describe, bounded output. | platform |
@tenwhy/agent-cli-kit | Implements the contract once: TTY-detecting output, error→exit mapping, --describe from Zod, bounded lists, plus a verify command. | platform |
| Starter template | Thin wiring only — framework setup + kit import + a SKILL.md stub. Nothing behavioral, so nothing drifts. | platform |
Your job as a tool author: the tool-specific command logic + the result
schema + a two-line SKILL.md. That's it. The kit handles every contract detail,
so a new tool is agent-ready on day one with zero per-tool integration.
3 · Scaffold & repo layout
# one command stamps out a conforming tool repo
npx create-tenwhy-tool seo --agent max
You get:
tenwhy-tools/seo/ ├── spec.yaml # name, agent persona, contract_version, result schemas ├── SKILL.md # two-line purpose + when-to-use (the agent reads this) ├── src/ │ ├── index.ts # kit entrypoint — registers commands, nothing else │ ├── commands/ # one file per command (audit.ts, plan.ts, apply.ts…) │ └── schemas/ # Zod result schemas (seo.audit@1, …) ├── test/ # CLI tests + the failure-path matrix └── package.json # depends on @tenwhy/agent-cli-kit; build = esbuild bundle
# spec.yaml name: seo agent: max # the specialist persona that runs this tool contract_version: 0 result_schemas: [seo.audit@1, seo.plan@1]
4 · Write a command
A command is a Zod input schema + a handler that returns data. The kit does the
rest — parses args, validates input, emits the {ok,data,error,meta} envelope on
--json, maps errors to exit codes, bounds output, and generates
--describe. You never touch any of that.
// src/commands/audit.ts import { defineCommand, z } from '@tenwhy/agent-cli-kit' export default defineCommand({ verb: 'audit', // canonical or declared stage verb rw: 'read', // read | write — surfaced in --describe input: z.object({ url: z.string().url(), severity: z.enum(['low', 'medium', 'high']).optional(), }), result: 'seo.audit@1', // registered result schema async run({ input, ctx }) { const report = await ctx.lighthouse(input.url) // kit helpers via ctx return { data: { issues: report.issues }, outcome: { unit: 'count', projected: report.issues.length, cost_usd: ctx.cost() }, } }, })
errors Throw ctx.error('INVALID_ENUM', …) with
the allowed set — the kit renders the enumerated, teaching error and the right exit code. Never
console.log on stdout; stdout is the envelope, stderr is diagnostics.
5 · Read tools — the pipeline pattern
Research/audit tools are a chain of idempotent stages that read and write
JSON artifacts in the run directory (scratch — it dies with the µVM; durable
state is the platform's job). A stage is side-effect-free and replayable, not "pure" — an LLM
call may vary; the write is dedup-keyed, and you record model + prompt + input
versions so replay-as-regression-test is meaningful.
// scrape → extract → analyze → synthesize, each its own subcommand seo crawl --url basketball.com # → run-dir/crawl.json seo audit # reads crawl.json → run-dir/audit.json seo synthesize # reads audit.json → the result + a brain doc
Scraping and browsers never run in the µVM — call the scraping service (Scrapling/Firecrawl) over HTTP. The µVM stays Node-only and small (stack §3).
6 · Write tools — plan / apply
Nothing mutates an external system except through plan → gate → apply. The µVM only proposes; the platform's gate validates and its broker executes (design §11, CONTRACT §8).
// plan: emit an enforceable changeset — the µVM proposes, never executes export default defineCommand({ verb: 'plan', rw: 'read', result: 'seo.plan@1', async run({ ctx }) { return { data: { plan: { items: [{ id: 'h1-homepage', action: 'set-title', params: { page: '/', title: '…' }, idempotency_key: ctx.idem('set-title', '/'), preconditions: { current_title: '…' }, // re-checked at apply }] } } } }, })
// apply: takes ONLY an approved plan ref; the platform broker runs it export default defineCommand({ verb: 'apply', rw: 'write', input: z.object({ plan: z.string() }), // r2 ref + checksum, not free-form args async run({ input, ctx }) { return ctx.applyPlan(input.plan) // per-item, idempotent, partial-success aware }, })
rules Never put projected_impact the gate can
trust — the gate recomputes it from raw params + a live read-back.
apply refuses a checksum mismatch or an expired plan. Read-only tools simply have
no write verbs — same architecture, different classification.
7 · Result schema & the outcome spine
Register a versioned result schema (tool.kind@version) for the structured output —
the platform validates data against it at ingest and rejects on mismatch. Every
result also populates the fixed cross-fleet outcome object so self-improvement
can compare across tools (CONTRACT §11):
meta.outcome = { unit, projected, realized, cost_usd, confidence }
// unit ∈ usd · rank · pct · count · ms ; realized filled at reconciliation
8 · SKILL.md — keep it to two lines
The fleet contract is referenced, never repeated. The Skill is just purpose + when-to-use +
the command list. Maestro routes on --describe, not on prose.
# seo — SEO audit & on-page fixes (for Max)
Use when the customer needs an SEO audit or to apply on-page improvements.
Commands: `seo audit`, `seo plan`, `seo apply` — all follow the tenwhy CLI contract.
Run `seo <cmd> --describe` for the machine spec.
9 · Verify & ship
- Run the conformance gate
agent-cli-kit verifyasserts the envelope, canonical verbs, enumerated errors, exit codes, bounded output, and that--describematches the schema. It's a quality gate, not a security one. - Prove the failure pathsFor write tools, test the matrix from build §2: retry, duplicate/lost webhook, crash before/after apply, projection rebuild. Idempotency is asserted by the ledger, not hoped for.
- CI on every pushThe repo's GitHub Action runs
verify+ Skill lint + CLI tests + the harness-seam check. Green is the gate to publish. - Publish to the catalogA passing tool is published into the versioned Tool Catalog; Maestro can then load it into a specialist. The factory's Reviewer is the automated owner of this gate.
10 · Definition of done
- TypeScript, bundled to JS, runs on Node 20 — no Bun runtime dependency.
- Built on
@tenwhy/agent-cli-kit; the framework is never imported directly. - Canonical verbs only (tier-1); any stage verbs declared in
--describe. - Every command: Zod input, returns
data+meta.outcome; envelope handled by the kit. - Mutations go through
plan→ gate →apply;applytakes only an approved plan ref. - No secrets stored; reads via injected scoped creds; external writes proposed, not executed in the µVM.
- Registered, versioned result schema; validates at ingest.
- Two-line
SKILL.md;spec.yamldeclares agent + contract version. agent-cli-kit verifygreen in CI; failure-path tests pass.