tenwhy · building a tool

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:

SurfaceWhat it does
defineCommand + zThe command API (Zod re-exported). A tool author touches only this — never Stricli directly.
The runnerWraps 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 generatorEmits 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.)
verifyThe conformance checker run in CI (§9) — asserts envelope, verbs, errors, exit codes, bounded output, and --describe ↔ schema.
create-tenwhy-toolThe 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.

ChoiceDecision & why
LanguageTypeScript, end to end. The ecosystem tools need (Exa, Anthropic/Vercel SDKs, cheerio, Lighthouse) is decisively TS/Node.
RuntimeNode 20 in the µVM (S13 — Node-only, unamended). Tools are bundled to a single JS file with esbuild/tsup and run on plain node.
BunOptional 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 frameworkStricli — TS-first, explicit, typed. But you never import it directly; the kit wraps it. Swappable internal detail.
SchemasZod — one schema drives input validation, the result shape, and the generated --describe.
RepoOne 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:

LayerWhat it isWho owns it
Contract (versioned)The rules: envelope, canonical verbs, exit codes, plan/apply, --describe, bounded output.platform
@tenwhy/agent-cli-kitImplements the contract once: TTY-detecting output, error→exit mapping, --describe from Zod, bounded lists, plus a verify command.platform
Starter templateThin 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

  1. Run the conformance gateagent-cli-kit verify asserts the envelope, canonical verbs, enumerated errors, exit codes, bounded output, and that --describe matches the schema. It's a quality gate, not a security one.
  2. 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.
  3. CI on every pushThe repo's GitHub Action runs verify + Skill lint + CLI tests + the harness-seam check. Green is the gate to publish.
  4. 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

The how. The rules are in CONTRACT.md (normative, versioned); the what/why in design §10–11; the vendor stack in the stack. Build @tenwhy/agent-cli-kit + the template first; everything here is built on them.