Manifest
Generated from packages/sdk/src/types.ts
A tool is a directory with three files: tool.json, the manifest; index.ts, the module whose run computes the result; and cases.json, the known answers its fixtures check.
Manifest attributes
The keys of tool.json.
| Attribute | Type | Required | Default | Since | Notes |
|---|---|---|---|---|---|
sdk | number | yes | v1 | The contract version this tool was written against. | |
id | string | yes | v1 | Stable, lowercase, hyphenated. Also the directory name and the URL segment. | |
name | string | yes | v1 | The tool's title, shown as its heading. | |
blurb | string | yes | v1 | One sentence. Shown on cards and used as a page description. | |
version | string | yes | v1 | The tool's own version, independent of sdk. | |
capabilities | Capability[] | yes | v1 | What the tool is allowed to do. At least one; pure is the only capability today. | |
inputs | InputSpec[] | yes | v1 | The form, one control per entry, in order. At least one. | |
kinds | OutputKind[] | yes | v1 | Every kind run can return, so a host can refuse a tool it cannot draw. | |
runtime | object | yes | v1 | How the runtime loads and runs the tool. | |
status | "live" | "deprecated" | "retired" | no | v1 | Lifecycle. Default live. deprecated still runs, with a visible marker a host can style, and is refused as a live card. retired does not run: the element explains and renders links, so a bookmarked URL is not a 404. | |
card | "live" | "info" | "none" | no | v1 | How the tool appears in a compact slot: runnable, a summary that links out, or not at all. | |
cardFields | number | no | v1 | How many fields a card shows before it stops. Errors are never truncated. | |
autoRun | boolean | no | v1 | Re-run as the reader types, instead of waiting for the Run button. Off by default, and that default is deliberate. A tool that runs on every keystroke does work nobody asked for, makes the Run button look broken (the answer is already there), and takes the decision away from the reader. Opt in only for a tool that is genuinely instant and reads better live, a unit converter say. Anything with a worker or a heavy loop should leave it alone. | |
reset | boolean | no | true | v8 | false hides the runtime's Reset control, which puts every input back to the values the tool opened with: for a tool with one input, where retyping it is the reset. |
timeoutMs | number | no | v1 | Worker mode only. A tool that declares this on the main thread is rejected: nothing there can be stopped. | |
help | string | no | v1 | Markdown file, relative to the tool's directory, rendered as the tool's help. | |
tags | string[] | no | v1 | Free-form labels a host can filter or group tools by. | |
links | Link[] | no | v1 | Anything the tool wants to point at: a spec it implements, a post explaining it, a reference decoder. Deliberately generic: a host site's own content relations are the host's business. | |
samples | Sample[] | no | v3 | Example inputs, rendered as a row of buttons under the form. Clicking one fills the form. It is an input change like any other, so the result goes stale and waits for Run, or re-runs if the tool set autoRun. A compact card does not show samples at all, because it 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. |
runtime
| Attribute | Type | Required | Since | Notes |
|---|---|---|---|---|
entry | string | yes | v1 | Entry file, relative to the tool's directory. |
thread | "main" | "worker" | no | v1 | main is the default and costs nothing. worker is for a tool whose running time depends on its input. It is the only mode where a timeout can actually stop anything. |
{ "runtime": {"entry": "index.ts", "thread": "worker"}, "timeoutMs": 2000}From tools/regex-explainer/tool.json.
Link
Each entry of links.
| Attribute | Type | Required | Since | Notes |
|---|---|---|---|---|
label | string | yes | v1 | The link text. |
href | string | yes | v1 | An http, https or mailto URL, or a relative path. Anything else is refused. |
{ "links": [ { "label": "UTF-8 (RFC 3629)", "href": "https://www.rfc-editor.org/rfc/rfc3629" } ]}From tools/utf8-bytes/tool.json.
Sample
Each entry of samples, a button under the form that fills the inputs.
| Attribute | Type | Required | Since | Notes |
|---|---|---|---|---|
label | string | yes | v1 | What the button says. Short: it sits in a row with the others. |
input | InputValues | yes | v1 | Values keyed by input id. Partial on purpose: a sample may set one field and leave the rest as the reader left them. |
{ "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"} } ]}From tools/percentiles/tool.json, the first two samples.
Capabilities
| Capability | Meaning |
|---|---|
pure | No network, no storage, no clock, no randomness that is not seeded: the same input always gives the same result |
Example
{ "sdk": 3, "id": "histogram", "name": "Histogram", "blurb": "Bins a list of numbers and draws the counts as bars, with the running total on a second axis.", "version": "1.0.0", "capabilities": ["pure"], "runtime": { "entry": "index.ts", "thread": "main" }, "kinds": ["series", "error"], "card": "live", "autoRun": true, "inputs": [ { "id": "values", "type": "textarea", "label": "Measurements", "description": "Numbers separated by spaces, commas or newlines. Units are yours; the tool does not care.", "dir": "ltr", "rows": 4, "maxLength": 20000, "default": "2 3 3 4 4 4 5 5 5 5 6 6 6 7 7 8 9 11 14 18" }, { "id": "bins", "type": "number", "label": "Bins", "description": "Equal-width buckets between the smallest and largest value. More bins show finer shape and more empty buckets.", "unit": "bins", "default": 8, "min": 2, "max": 30, "step": 1 } ], "samples": [ { "label": "Even spread", "input": { "values": "1 2 2 3 3 3 4 4 5", "bins": 4 } }, { "label": "One spike", "input": { "values": "1 1 1 1 1 1 1 1 1 20" } }, { "label": "All the same", "input": { "values": "7 7 7 7 7" } }, { "label": "Not a number", "input": { "values": "2 3 4 nope 6" } } ], "help": "README.md", "tags": ["statistics", "chart"]}tools/histogram/tool.json.
The tool module
index.ts default-exports an object with run.
import type { Tool } from "@toolbench/sdk"; type Input = { values: string; bins: number }; // a type, not an interface export default { run({ values, bins }, ctx) { return { kind: "fields", fields: [{ label: "bins", value: String(bins) }] }; },} satisfies Tool<Input>;Declare the input type with type, not interface: an interface does not satisfy InputValues, and TypeScript's error does not say why.
Ctx
The second argument to run.
| Attribute | Type | Required | Since | Notes |
|---|---|---|---|---|
signal | AbortSignal | yes | v1 | Aborted when the reader cancels, when the input changes mid-run, or when a worker-mode timeout fires. A loop whose length depends on input MUST check it: on the main thread it is the only way to stop. |
progress(fraction: number, partial?: Output) | void | yes | v1 | Progress, and optionally a partial result. A runtime renders partial at most once per frame, which is what lets a simulation show itself converging instead of hiding behind a bar. |
A simulation that stops when the reader cancels, and shows its running mean as it goes:
const simulated = simulate(lambda, mu, samples, seed, (done, meanSoFar) => { if (ctx.signal.aborted) return; ctx.progress(done / samples, { kind: "fields", fields: [ { label: "samples so far", value: done.toLocaleString() }, { label: "simulated mean", value: `${fmt(meanSoFar)} ms` }, { label: "formula says", value: `${fmt(a.w)} ms` }, ], });});From tools/queue-explorer/index.ts.
Fixtures
cases.json is an array of cases. pnpm check runs each and compares the result.
| Attribute | Type | Required | Since | Notes |
|---|---|---|---|---|
name | string | yes | v1 | What the case checks, shown when it fails. |
input | InputValues | yes | v1 | The values run receives, keyed by input id, passed exactly as given. |
expect | Output | yes | v1 | The result run must return. |
match | "exact" | "subset" | no | v1 | Exact deep equality by default, because that is what catches a duplicated field, a reordered one, or garbage emitted beside a correct error. subset is an opt-in escape for a tool whose output is genuinely open-ended, and it demands two things in return: a why, and a fieldCount, so a dropped field is still a failure. |
why | string | no | v1 | Why this case needs subset. Required with it. |
fieldCount | number | no | v1 | How many fields the result has. Required with subset, so a dropped field still fails. |
{ "name": "empty input is an error, not an empty chart", "input": {"values": " ", "bins": 8}, "expect": { "kind": "error", "message": "No numbers yet. Paste measurements separated by spaces, commas or newlines.", "input": "values" }}An exact case for bad input, from tools/histogram/cases.json.
{ "name": "linear interpolation returns a point between measurements, not one of them", "input": {"values": "1 2 3 4 5 6 7 8 9 10", "method": "linear"}, "match": "subset", "why": "this case is about the two summary percentiles only; the full group is asserted exactly by the case above", "fieldCount": 6, "expect": { "kind": "fields", "fields": [ {"label": "p50", "value": "5.50"}, {"label": "p99", "value": "9.91"} ] }}A subset case, with the why and fieldCount it requires, from tools/percentiles/cases.json.
Validation rules
validateManifest checks these before anything runs. Rules for inputs are on the Inputs page.
| Rule | Message |
|---|---|
sdk is a contract version this SDK reads | sdk: this tool needs contract version 99, but this runtime only speaks up to 9. Upgrade @toolbench/runtime, or lower the tool's sdk if it does not use the newer features. |
id is lowercase letters, digits and hyphens | id: "My Tool" must be lowercase letters, digits and hyphens: it is also a directory name and a URL segment |
blurb is under 200 characters | blurb: is 240 characters; keep it under 200 so it works as a card line and a page description |
| At least one capability | capabilities: must declare at least one of pure |
kinds is not empty | kinds: declare every output kind run can return, so a host can refuse a tool it cannot draw |
kinds includes error | kinds: must include "error": every tool can be given bad input, and saying so is part of working correctly |
cardFields is a positive number | cardFields: must be a positive number of fields |
timeoutMs is 100 to 30000 | timeoutMs: must be between 100 and 30000 ms |
samples is not empty when present | samples: is empty; omit the key rather than declaring that there are no examples |
A link is http, https, mailto or relative | links[0].href: must be an http, https or mailto URL, or a relative path, with no spaces or control characters |