Toolbench

Authoring a tool

The walkthrough for a first tool, then the manifest, inputs, results and fixtures in full.

Writing, testing and shipping a tool, start to finish. Read architecture.md if you want to know why any of this is shaped the way it is.

1. What a tool is

A directory with four files:

Text
tools/base64/  tool.json      what it is, what it takes, what it can return  index.ts       the function  cases.json     inputs with known answers  README.md      help text, shown next to the tool

The function is the whole tool:

TypeScript
run(input, ctx) -> Output

Three rules, and none of them are style preferences:

  1. No DOM. run executes in Node during tests, in a worker where no DOM exists, and possibly during a site build. Reaching for document breaks all three.
  2. A function of its inputs. No clock, no unseeded randomness, no network, no files. This is what makes a fixture mean anything. Randomness is fine if it comes from a seeded generator whose seed is an input; tools/queue-explorer does exactly that.
  3. Cancellable if it can be slow. Any loop whose length comes from an input checks ctx.signal.

2. Walkthrough: build one

Shell
pnpm new-tool base64

writes tools/base64/ with the four files, already green under pnpm check: one input, one field of output, one fixture, and an error return with a fixture for it. Those last two are the parts people skip, which is why the template includes them rather than leaving them as an exercise.

The rest of this section is what you replace that starter with. A Base64 decoder is a good first tool because it has real errors, and it wants two result shapes at once. This is where it ends up:

The Base64 decoder this walkthrough builds, running.

Step 1: the manifest

JSON
// tools/base64/tool.json{  "sdk": 1,  "id": "base64",  "name": "Base64 decoder",  "blurb": "Decodes Base64 and shows what the bytes actually are, including where it breaks.",  "version": "1.0.0",  "capabilities": ["pure"],  "runtime": { "entry": "index.ts", "thread": "main" },  "kinds": ["group", "fields", "table", "error"],  "card": "live",  "cardFields": 3,  "inputs": [    {      "id": "source",      "type": "textarea",      "label": "Base64",      "description": "Standard or URL-safe alphabet. Padding optional.",      "primary": true,      "dir": "ltr",      "rows": 3,      "default": "SGVsbG8sIHdvcmxkIQ=="    },    {      "id": "view",      "type": "select",      "label": "Show bytes as",      "default": "hex",      "options": [        { "value": "hex", "label": "Hex" },        { "value": "ascii", "label": "ASCII" }      ]    }  ]}

sdk is 1 on purpose, because a v1 manifest still being valid is the compatibility promise made concrete: the current contract is 6 (§3), and declaring 1 opts out of everything added since (bytes in v2, samples in v3, through select presets and highlighted textareas in v6).

Declare the version of the newest feature you use, not the newest one there is. Validation refuses a manifest that uses a key, input type or kind newer than its sdk, and the fixture harness refuses a case or a sample whose result does, with the field, the version that added it and the sdk to declare: a runtime of the version you declared would load the tool and quietly ignore what it does not know.

dir: "ltr" matters more than it looks. Base64 is not prose, and inside a right-to-left page it would otherwise be laid out backwards.

Step 2: the function

TypeScript
// tools/base64/index.tsimport type { Output, Tool } from "@toolbench/sdk"; // A type alias, not an interface. An interface does not satisfy the SDK's index-signature// constraint, and the error you get for that is not obvious.type Input = { source: string; view: string }; const ALPHABET = /^[A-Za-z0-9+/\-_]*={0,2}$/; export default {  run({ source, view }): Output {    const trimmed = source.replace(/\s+/g, "");    if (trimmed.length === 0) {      return { kind: "error", message: "Nothing to decode.", input: "source" };    }    if (!ALPHABET.test(trimmed)) {      const at = trimmed.search(/[^A-Za-z0-9+/\-_=]/);      return {        kind: "error",        message: `"${trimmed[at]}" is not a Base64 character.`,        input: "source",        at,        len: 1,      };    }     const bytes = decode(trimmed);    return {      kind: "group",      parts: [        {          kind: "fields",          fields: [            { label: "Bytes", value: String(bytes.length) },            { label: "Padding", value: trimmed.endsWith("=") ? "present" : "absent" },            { label: "Alphabet", value: /[-_]/.test(trimmed) ? "URL-safe" : "standard" },          ],        },        {          kind: "table",          caption: "Sixteen bytes per row",          columns: [            { label: "Offset", align: "end" },            { label: view === "hex" ? "Hex" : "ASCII", mono: true },          ],          rows: rowsOf(bytes, view),        },      ],    };  },} satisfies Tool<Input>;

decode and rowsOf are left out; they are ordinary functions in the same file with nothing Toolbench-specific about them.

Four decisions worth copying:

Step 3: fixtures

JSON
// tools/base64/cases.json[  {    "name": "decodes the canonical example",    "input": { "source": "SGVsbG8sIHdvcmxkIQ==", "view": "ascii" },    "expect": {      "kind": "group",      "parts": [        {          "kind": "fields",          "fields": [            { "label": "Bytes", "value": "13" },            { "label": "Padding", "value": "present" },            { "label": "Alphabet", "value": "standard" }          ]        },        {          "kind": "table",          "caption": "Sixteen bytes per row",          "columns": [            { "label": "Offset", "align": "end" },            { "label": "ASCII", "mono": true }          ],          "rows": [["0", "Hello, world!"]]        }      ]    }  },  {    "name": "rejects a character outside the alphabet, and says where",    "input": { "source": "SGVs*bG8=", "view": "hex" },    "expect": { "kind": "error", "message": "\"*\" is not a Base64 character.", "input": "source", "at": 4, "len": 1 }  },  {    "name": "empty input is an error, not an empty result",    "input": { "source": "   ", "view": "hex" },    "expect": { "kind": "error", "message": "Nothing to decode.", "input": "source" }  }]

Exact matching by default is the point. If the tool starts emitting a field twice, or reorders them, or returns something plausible alongside a correct error, the fixture fails. A "contains what I expected" check passes all three.

Step 4: run it

Shell
node --test tools/cases.test.ts

That file walks every tool directory, so a new tool is picked up with no registration. It checks the manifest validates, that id equals the directory name, that cases.json exists and is not empty, that every case's kind is declared in kinds, and then runs the cases and every sample the manifest declares.

On your own site, the same suite is three lines, because the walk is exported:

TypeScript
import { describe, it } from "node:test";import { checkToolDirectory } from "@toolbench/sdk/fixtures"; checkToolDirectory(new URL("../src/tools/", import.meta.url), { describe, it });

describe and it are passed in rather than imported, so the SDK keeps its zero dependencies and the same call works under Vitest. It lives on the /fixtures subpath because it reads the filesystem, and the main entry deliberately does not: @toolbench/runtime imports the SDK and runs in a browser.

Step 5: see it

Shell
pnpm bench

The bench globs tools/*, so the new tool appears in the gallery on the front page and at /tool.html?id=base64. Click through all three modes. Every visual bug worth mentioning in this repository was found by looking at the thing, not by reasoning about it.

3. The manifest, field by field

FieldRequiredNotes
sdkyesContract version. 3 today. Do not raise it to get a feature that does not exist yet.
idyesLowercase, digits, hyphens. Must equal the directory name; a test enforces that. It is also a URL segment.
nameyesSentence case. It is a heading, not a title.
blurbyesOne sentence, under 200 characters. It is the card line and the page's meta description. Say what the tool does, not that it is useful.
versionyesThe tool's own version. Semver by convention. Nothing branches on it.
capabilitiesyes["pure"]. Still the only value at contract version 3.
runtime.entryyesRelative to the tool directory.
runtime.threadno"main" (default) or "worker". See §7.
inputsyesAt least one. A tool with none is a constant.
samplesnoLabelled example inputs, { label, input }, drawn as a row of buttons under the form. input is keyed by input id and may be partial. See §4b.
kindsyesEvery kind run can return, "error" included. Both halves are checked by tests.
cardno"info" (default) shows the blurb only, "live" makes it runnable, "none" keeps it off cards. "live" requires ["pure"], and is refused when status is not "live".
cardFieldsnoHow many fields a compact result shows before "+N more". Default 4.
autoRunnoRun as the reader types. Default off. Refused for worker tools. Only for genuinely instant tools.
timeoutMsno100 to 30000. Worker only; declaring it on the main thread is an error, because there is nothing there to terminate. Default 5000.
statusno"live" (default), "deprecated", "retired". See §3b.
helpnoPath to Markdown, relative to the tool directory.
tagsnoFree-form strings for the host's own grouping.
linksno{ label, href }. Specs, source, further reading. For a retired tool, this is the way onward. href must be http, https, mailto or a relative path, with no spaces.

3b. Retiring a tool

Prefer retired over deleting. A URL someone has bookmarked, or that is linked from a post, should explain what happened rather than return 404.

deprecated still runs. The element shows a visible marker. A host styles it from outside the shadow root: data-status="deprecated" is set on <tool-host>, and the marker colours read --tb-mark-fg and --tb-mark-bg. It is not a live card. A landing page must not present a tool on the way out as current. The page and embed still work, so a reader who already has the URL can finish what they were doing.

retired does not run. The element refuses to activate: no form, no Run, and no tool code is fetched. It explains, and it renders links as the way onward. If there is a replacement, put it in links. That is what a bookmarked URL is for: this moved, and here is where to go, rather than a silent 404 or a tool that claims to be gone and then runs anyway.

JSON
{  "status": "retired",  "links": [    { "label": "Use the new converter", "href": "/tools/new-converter/" }  ]}

Do not keep card: "live" on either. Validation refuses it. A compact slot that opens a retired tool, or that presents a deprecated one as current, is a field that lies.

See it live: Deprecated and retired tools

4. Inputs

Ten types. Each generates a labelled control with its description wired to aria-describedby.

JSON
{ "id": "text",  "type": "text",     "label": "Pattern", "default": "^a.*z$", "maxLength": 200 }{ "id": "body",  "type": "textarea", "label": "Input",   "default": "",       "rows": 6 }{ "id": "n",     "type": "number",   "label": "Samples", "default": 1000, "min": 1, "max": 100000, "step": 1, "unit": "samples" }{ "id": "mode",  "type": "select",   "label": "Mode",    "default": "fast",  "options": [{ "value": "fast", "label": "Fast" }, { "value": "exact", "label": "Exact" }] }{ "id": "trace", "type": "toggle",   "label": "Show trace", "default": false }{ "id": "day",   "type": "date",     "label": "Day",     "default": "2026-10-25", "min": "2020-01-01" }{ "id": "tint",  "type": "color",    "label": "Tint",    "default": "#1f5aa8" }{ "id": "key",   "type": "secret",   "label": "API key", "minLength": 16 }{ "id": "types", "type": "multiselect", "label": "Record types", "default": ["A", "MX"], "min": 1,  "options": [{ "value": "A", "label": "A" }, { "value": "MX", "label": "MX" }, { "value": "TXT", "label": "TXT" }] }{ "id": "limits", "type": "rows", "label": "Limits", "maxRows": 8, "minRows": 1,  "columns": [{ "id": "path", "type": "text", "label": "Path" }, { "id": "limit", "type": "number", "label": "Limit", "min": 1, "max": 10000, "unit": "req/s" }],  "default": [{ "path": "/api", "limit": 500 }] }

Things the validator will hold you to, and why:

4b. Sample inputs

Nobody arrives at a tool knowing what to put into it, and the defaults can only demonstrate one thing. samples is a list of labelled example inputs. The runtime draws them as a row of small buttons under the form, introduced by a visible Try:, and a click fills the form with that sample's values.

JSON
// tools/percentiles/tool.json, three of the four samples it ships"samples": [  {    "label": "Two clusters",    "input": { "values": "2 2 3 3 3 4 4 4 5 5 5 6 6 7 7 180 190 195 210 240 260 300 340 900" }  },  {    "label": "Definitions disagree",    "input": { "values": "3 4 4 5 6 7 9 14 40 260", "method": "linear" }  },  {    "label": "Not a number",    "input": { "values": "12, 14, 15, 18ms, 21, 24, 31, 44" }  }]

input is keyed by input id, and it is partial on purpose. Two of the three above set only values and say nothing about method, so whichever definition the reader had chosen stays chosen. That is the usual shape and usually the right one: change the one thing the example is about, and leave everything else where the reader put it. "Definitions disagree" sets both, because the point it makes needs both. Under the linear definition those ten measurements give a p90 of 62.0, and nearest rank gives 40 for the same data; a sample that quietly reset the definition to the default would put half of that comparison out of reach.

Choosing them

Picking the examples is an authoring skill rather than a step in a procedure, so it deserves more thought than the JSON does.

Ship the malformed one. A sample whose input the tool rejects is the most useful example a tool can offer. It is the one a reader will never type by hand, because nobody sits down to invent a broken input, and it is the only way they see how the tool behaves when something is wrong before it happens to them for real. It also says something about the tool: that its failure path was designed and is being shown on purpose, rather than discovered by accident at the least convenient moment. percentiles ships "Not a number", where a stray 18ms sits in the middle of an otherwise clean list. A tool with no such sample hides its failure path until a reader stumbles into it.

Three or four, not eight. A row of eight buttons stops being a set of examples and becomes a menu, and a menu is one more thing to read before the tool can be used. Each sample should show something none of the others do: the canonical case, one that exposes a distinction the tool exists to explain, an edge, and the one that fails. If two samples make the same point, keep the clearer one and delete the other.

A label says what the reader is about to see, in two or three words. It sits on a small button in a row, so there is no room for a sentence, and "Two clusters" or "Definitions disagree" tell a reader what they are choosing. "Example 2" and "Test data" tell them nothing. Two samples may not share a label: identical buttons that do different things are not something anyone can choose between. They may not share an input either: two labels for the same values are two buttons that do the same thing.

What a click does

A click fills the form and stops there, for the same reason typing does not run the tool: Run is the trigger, and a sample is an input change like any other. An existing result goes stale, dims, and waits, which is what lets a reader hold the answer they already have beside the example they just loaded. A tool that set autoRun: true re-runs instead, again exactly as it would for typing. Samples are not an exception to the rule about Run, they follow it.

Two details are deliberate rather than incidental. Values are written into the live controls rather than by rebuilding the form, so focus stays on the button that was pressed and a reader can move along the row from the keyboard. And the status region announces that the form was filled, naming the sample, and that Run is next: for somebody who cannot see the form change, that announcement is the only evidence the click did anything at all.

A card shows no samples. A compact card has room for one input and a Run button, and a row of buttons there would crowd out the result the card exists to show. Samples appear in page and embed mode only, so do not design a tool whose form makes no sense without them.

A host page can still put its own example buttons next to a card, or prefill a deep link, without those examples living in the manifest. <tool-host> has a values setter and a run() method for that. values is partial and does not run the tool. Ship samples for examples that belong to the tool. Leave page-specific ones to the host.

What validation will hold you to

validateManifest refuses the manifest, naming the field, when:

⚠️ The runtime marks a reader's out-of-range number, and validation rejects an author's. A reader who types 9999 into a field that stops at 1000 keeps 9999 on screen, marked with "must be between 1 and 1000, got 9999." under it, and Run refuses with that sentence, so run keeps its guarantee that values are in range. A shared link carries the 9999 and opens marked the same way. It used to clamp, which ran and shared 1000 while the box still said 9999 (#262). A host's values write is still clamped. An author's sample is static data that CI reads before any reader sees the tool, so it can be a build error naming the field, fixed once. Clamping it instead would ship a button whose label promises one thing and whose value quietly does another, and nobody would ever notice.

maxLength is the sharpest case. It reaches the browser as the maxlength attribute, which constrains typing and nothing else. Filling a control from a sample assigns its value directly and walks straight past it, so an over-long sample would be the one route to a value the field itself says is impossible.

An input's own default is not checked against maxLength. That is deliberate. Closing the gap would narrow a rule existing tools already pass, and this SDK has no warning path. maxLength bounds typing and samples. It does not bound default. Keep the default inside the bound yourself if you set both.

What the build will hold you to

checkToolDirectory runs every sample a tool declares, so a declared example that crashes the tool fails the build with the sample's label in the message. An { kind: "error" } result is a pass: the malformed sample is the one that earns the feature, and rejecting bad input is the tool working. A sample that overruns its budget is reported as slow rather than as a crash, so the two diagnoses stay apart.

See it live: Sample inputs

4c. Presets on a select

When the right values for several inputs depend on one earlier choice, make that choice a select whose options carry sets. Choosing an option writes those values into the form, and the reader edits from there. Contract version 6, so declare "sdk": 6.

JSON
{  "id": "stack", "type": "select", "label": "Library", "default": "a",  "options": [    { "value": "a", "label": "Library A", "sets": { "read": 30, "retries": 2 } },    { "value": "b", "label": "Library B", "sets": { "read": 60, "retries": 3 } },    { "value": "custom", "label": "Custom" }  ]}

A preset or a sample? A sample answers "show me an interesting case" and sits under the form. A preset answers "start from what I actually run" and sits where the choice is made. If the reader picks it before anything else, it is a preset.

What a choice does

Exactly what a sample click does: the named inputs are written, the others keep what the reader left in them, an existing result goes stale (or re-runs, under autoRun), and the status line names the option. run receives the select's own value too, so a tool can branch on it.

The select follows the form

The select never names a preset the values have left. After any change to an input a preset names, by typing, a sample or the host, it shows the option whose sets all match the form, else the first option with no sets, else it stays where it was. So ship a "Custom" option: without one, an edited form keeps saying "Library B" over values that are not Library B's. Editing an input no preset names never moves the select, and when it does move, the status line says so, because a screen-reader user has no other way to hear that a control they did not touch changed.

A host writing the select through values is taken literally: { stack: "b" } selects Library B and fills nothing. A host that wants the values writes them too.

On a card, a primary preset select fills inputs the card may not draw. The select's label then describes what Run will use, which is why this is allowed where a sample row is not.

What validation will hold you to

sets is checked with the same rules as a sample's input: every key names a declared input, every value has that input's type, numbers sit inside min..max, strings inside maxLength, select values are options. Clamping is not on offer, for the same reason. On top of those:

See it live: A select that fills other inputs

5. Results

TypeScript
{ kind: "fields", fields: [    { label: "p50", value: "120", unit: "ms" },    { label: "p99", value: "1450", unit: "ms", tone: "warn", note: "tail is 12x the median" },    { label: "Arrival rate", value: "800", group: "Inputs" },]}

tone is "good" | "warn" | "bad", for when a number's meaning is not obvious from the number, or "muted" (below). note is the sentence a reader would otherwise have to work out. group puts fields under a heading, which is how one result holds two comparable sets: tools/queue-explorer uses "Formula" and "Simulation" side by side.

A field's value is set in the monospace face, which is right for an address, a hex string or a header value the reader compares character by character. mono: false sets it, and its note, in the text face instead, for a sentence or a plain number with a unit, which in monospace reads like terminal output. One list can hold both: { label: "Mapped address", value: "192.0.2.10:54321" } beside { label: "Integrity", value: "No integrity attribute present", mono: false }. Contract version 8.

A table column can say how its text wraps, so the table survives a phone: wrap: "none" for a short token kept on one line (a hash, a date, a status word, a row's name), wrap: "prose" for sentences that need a reading width. A cell's own wrap overrides its column's. Say nothing and nothing is claimed, as before. It is rendered as data-wrap on the header and every cell. Contract version 8.

rowHeader: true on a column makes its cells each row's name: they render as <th scope="row">, so a screen reader announces the name with every cell of the row, and on a narrow tool the column stays in view while the rest scrolls. Mark the column that names the row, usually the first. Contract version 8.

With wrap on its columns, a table has a narrow layout built in: on a tool narrower than 34rem it keeps its columns and scrolls, with the tokens on one line and the prose at a reading width.

TypeScript
{ kind: "table",  columns: [{ key: "n", label: "Bucket", align: "end" }, { key: "hits", label: "Hits", align: "end", mono: true }],  rows: [[{ value: "0-10" }, { value: "812" }], [{ value: "10-20" }, { value: "39", tone: "warn" }]],  caption: "Latency distribution" } { kind: "text", text: "Converged after 4 iterations.", mono: false } { kind: "code", lang: "json", source: '{"ok":true}' } `code` is readable preformatted text with `data-lang` set. The runtime does not highlight it: ahighlighter would roughly double `@toolbench/runtime`, and a host usually already has one. Pass`highlight?: (source: string, lang: string) => Node` to `defineToolHost` to replace the text nodeinside the block. The return type is a `Node` on purpose. A string would need `innerHTML`, which[SECURITY.md](../SECURITY.md) forbids. Omit the hook and the source stays plain text. { kind: "bytes",  bytes: [72, 195, 169, 108],  offset: 0,                    // what to LABEL the first byte, for a window into something larger  caption: "Highlighted runs are one character each",  highlight: [{ at: 1, len: 2, label: "U+00E9 é", tone: "normal" }] } { kind: "group", parts: [ /* any of the above */ ] }

Four notes on bytes, added in contract version 2 for wire formats. Every highlight needs a label, because colour alone tells a reader something is interesting without saying what, and tells a screen reader nothing at all: the labels become a legend under the dump. at is always relative to bytes, never to offset, which only changes what the first row is labelled. And tone is for real severity only: the default palette resolves warn and accent to the same colour, so a tone used decoratively implies a distinction the reader cannot see. And you do not need a tone to tell ranges apart: a range without one is drawn in the next chart series colour, --tb-s1 to --tb-s6 in order, coming round to the first again after six, and its legend swatch matches. A toned range keeps its tone and takes no series colour, so the next untoned range takes the next one. Past six ranges two share a colour, which is one more reason the labels carry the identity rather than the colour.

Two notes on group. Order matters: a card renders only the first part, so put the part that works alone at the front. And declare every kind you use, "group" included.

Contract version 5 ("sdk": 5) adds three things a host can use. None changes how anything looks on a host that styles nothing.

The names a host may select by, and what counts as breaking them, are in STYLING.md.

6. Errors

Two different situations, and a reader can tell them apart only if you keep them apart.

Bad input. Return an error. The tool worked.

TypeScript
return {  kind: "error",  message: `"${token}" is not a number.`,  input: "values",   // marks that control invalid, attaches the message to it  at: offset,        // character offset, if you know it  len: token.length,};

The input named by input is marked invalid and the message is shown under it, after the field's name: "Measurements: No measurements yet. Paste some numbers." So write the message to follow a name and a colon, and do not repeat the field's name in it. For a rows input, row names the row at fault, counted from 0, and the message is shown under that row as "Limits, row 2: ...".

Write the message so it says what is wrong, where, and what would be right. "nope" is not a number beats Invalid input. Never abbreviate an error; a browser test asserts that an error never ends in an ellipsis, because the truncated half is the half that says what to fix.

A bug in the tool. Throw. The runtime says "This tool hit a bug and stopped", puts the stack in the console, and does not pretend the input was at fault. Do not catch your own bugs and report them as input errors; that sends the reader off to fix something that was never broken.

See it live: Failure modes

7. Long-running tools

If the running time depends on an input, declare a worker and honour the signal:

JSON
"runtime": { "entry": "index.ts", "thread": "worker" },"timeoutMs": 8000
TypeScript
run({ samples }, ctx) {  for (let i = 0; i < samples; i++) {    if ((i & 0x3ff) === 0) {      if (ctx.signal.aborted) throw new DOMException("Aborted", "AbortError");      ctx.progress(i / samples);    }    // ... work  }}

See it live: A worker, with a timeout

8. Fixtures

cases.json is an array of cases. Fields:

FieldMeaning
nameWhat the case establishes. "handles empty input" beats "test 2"
inputPartial input. Anything omitted comes from the manifest defaults
expectThe expected Output, compared exactly by default
match"exact" (default) or "subset"
whyRequired with subset. Say what the case is pinning and what it deliberately is not
fieldCountRequired with subset on a fields result, so a dropped field still fails
maxMsOptional per-case budget

Subset matching is for results that are mostly computed numbers:

JSON
{  "name": "simulation lands near the closed form at rho = 0.8",  "input": { "lambda": 8, "mu": 10, "samples": 20000, "seed": 42 },  "match": "subset",  "why": "the simulated numbers are checked for convergence in convergence.test.ts; this case pins the shape and the analytic half",  "fieldCount": 8,  "expect": { "kind": "fields", "fields": [{ "group": "Formula", "label": "Utilisation", "value": "0.800" }] }}

Note where the work is split. The fixture pins the shape and the exactly-known numbers. The claim that the simulation is right lives in a unit test that can say why: it asserts the simulation converges on the closed form, that the same seed gives the same answer, and that Little's law holds. Pinning two hundred computed digits in a fixture would test that nothing changed, which is not the same as testing that it is correct.

Aim for four to six cases: the canonical example, an edge (empty, one element, the maximum), a bad-input error, and anything that was once a bug.

The harness enforces four things a tool cannot enforce about itself: that the manifest is valid and its sdk is one this SDK can read, that cases.json exists and is not empty, that no case expects a kind kinds failed to declare, and that every sample in the manifest runs. The fourth is there because a sample is the first thing a reader clicks, so an example that crashes stays invisible until one of them finds it. See §4b for what counts as running.

9. Charts

TypeScript
{ kind: "series", chart: {    title: "Wait time against utilisation",    x: { label: "Utilisation", min: 0, max: 1, format: "fixed2" },    y: { label: "Wait", unit: "ms", format: "compact" },    series: [      { name: "M/M/1", shape: "line", points: [{ x: 0.1, y: 11 }, { x: 0.5, y: 100 }] },      { name: "Simulated", shape: "line", axis: "right", points: [/* ... */] },    ],    annotations: [{ x: 0.8, label: "knee" }],}}

Heatmaps and pies

Two more chart kinds, from contract version 4, each its own output kind and its own lazily loaded chunk, so a page fetches the code for the kinds it draws and no others. Declare them in kinds.

TypeScript
{ kind: "heatmap", heatmap: {    xLabel: "Hour", yLabel: "Day", label: "Packet loss", unit: "%",    x: ["00:00", "02:00", /* ... */], y: ["Mon", "Tue", /* ... */],    values: [[0.2, 0.3, /* ... */], /* one row per y */],   // null for a cell with no value    scale: "sequential",                                   // or "diverging", with midpoint}} { kind: "pie", pie: {    label: "One voice stream, 48 kb/s", unit: "kb/s",    slices: [{ label: "Opus audio", value: 32 }, { label: "Headers", value: 16 }],    donut: true, total: "48 kb/s",}}

Radar charts, treemaps and Venn diagrams

Three more kinds, each its own output and its own chunk, from contract version 4:

TypeScript
{ kind: "radar", radar: { label, axes: [{ label: "Latency" }, /* ... */], series: [{ label: "Fibre", values: [9, /* ... */] }] } }{ kind: "treemap", treemap: { label, unit, root: { label: "All", children: [{ label: "Media", children: [/* ... */] }] } } }{ kind: "venn", venn: { label, sets: [{ label: "Web", size: 100 }, { label: "Mobile", size: 100 }], overlaps: [{ sets: [0, 1], size: 50 }] } }

See it live: every chart kind

9b. Graphs

Contract version 7 ("sdk": 7) adds graph: you say what is connected, and the runtime decides where everything goes. tools/build-order is the example.

TypeScript
{ kind: "graph", graph: {    label: "Package dependencies",          // the figure's name, and the table's caption    direction: "BT",                        // LR (default), RL, TB or BT    nodes: [{ id: "core", badge: "1" }, { id: "http", badge: "2" }, { id: "app", badge: "3" }],    edges: [{ from: "core", to: "http" }, { from: "http", to: "app", label: "uses" }],    highlight: ["core", "http", "app"],     // a path to emphasise, as node ids in order} }

What you choose, with its default:

What the runtime does for you, so you do not have to:

The structural rules hold in your fixtures as well as on the page: ids are unique, every edge, port, group, lane and highlighted node names something that exists, groups do not nest in a circle, and a group's members share one lane. A case whose output breaks one fails, because the runtime would list that graph rather than draw it. Nothing about size is refused unless you say so: limits: { nodes: 200 } makes a larger graph a table and a sentence saying why.

See it live: graph layouts and families

10. Help text

README.md in the tool directory, referenced by help. The host renders it beside or below the tool. What earns its place:

10b. Seeding

If a host puts your tool on a card, it will compute a seed: your tool run against its manifest defaults, embedded in the page so the card shows a real result before any JavaScript loads.

Two consequences for you as the author, and both are easy to get wrong:

You do not have to do anything to support seeding. It is worth knowing it happens, because it makes your choice of defaults visible to every reader who never presses Run.

11. Checklist

12. Common mistakes

MistakeWhat happensFix
interface Input instead of type InputTool<Input> does not compile, with an unhelpful message about index signaturesUse a type alias. TypeScript gives type aliases an implicit index signature; interfaces do not
Returning a bare fields when the answer is also a tableHalf the answer is missinggroup
Putting the chart first in a groupThe card shows a chart with no contextOrder by what works alone
Forgetting "error" in kindsThe build fails naming the file and kinds, with the toolbench plugin or command; without them every host on the page shows the errorAdd it. Every tool can be given bad input
Catching your own bug and returning errorThe reader goes looking for a mistake they did not makeThrow. Bugs and bad input are different
Unbounded loop on the main threadThe page freezes and nothing can stop itmin/max on the input, and thread: "worker"
timeoutMs with thread: "main"Validation failsMove to a worker or bound the input. A main-thread timeout cannot fire
Ignoring ctx.signalA timeout terminates the worker, so the reader is fine, but every cancelled run costs a full worker restartCheck the signal every 1024 iterations
Math.random() or Date.now() in runFixtures pass locally and fail in CI, or pass every second timeTake a seed as an input. See tools/queue-explorer
A subset case with no whyThe fixture runner refuses itSay what is pinned and what is not, or make it exact
Two samples with the same values and different labelsTwo buttons that do the same thingChange one payload or delete the copy. Validation refuses the second
A default longer than maxLengthThe form loads with more characters than the field says it acceptsShorten the default. Validation does not catch this: maxLength bounds typing and samples only