Finds the rounds a set of packages can be built in, and draws the dependency graph with its longest chain marked.
{}tools/build-order/tool.json
{"sdk": 7,"id": "build-order","name": "Build order","blurb": "Finds the rounds a set of packages can be built in, and draws the dependency graph with its longest chain marked.","version": "1.0.0","capabilities": ["pure"],"runtime": { "entry": "index.ts", "thread": "main" },"kinds": ["group", "fields", "graph", "error"],"card": "live","cardFields": 2,"autoRun": true,"inputs": [ {"id": "deps","type": "textarea","label": "Packages","description": "One per line, as name: dependency, dependency. A name with no colon has no dependencies.","primary": true,"dir": "ltr","rows": 8,"maxLength": 4000,"default": "app: api-client, ui-kit\nadmin: api-client, ui-kit, auth\napi-client: http, schema\nui-kit: icons\nauth: http\nhttp: core\nschema: core\nicons: core" } ],"samples": [ {"label": "Diamond","input": { "deps": "app: left, right\nleft: base\nright: base" } }, {"label": "A cycle","input": { "deps": "a: b\nb: c\nc: a" } }, {"label": "Not a name","input": { "deps": "app: api client" } } ],"help": "README.md","tags": ["dependencies", "graph"]}
TStools/build-order/index.ts
/** * Build order: which packages can be built in which round, drawn as the dependency graph they come from. * * The tool exists so the `graph` output kind added in contract version 7 has a real tool behind it, and it * is a real tool rather than a stub because a renderer nobody uses is a renderer nobody has looked at. It * covers what a graph result needs to get right: a layered graph laid out by the runtime, a highlighted path * (the longest chain, which bounds how fast a build can be), badges, a graph as one part of a group beside * fields, and the case where the answer is that there is no answer, a cycle, drawn with the cycle marked. */importtype { Graph, Output, Tool } from"@toolbench/sdk";// A type alias, not an interface: an interface does not satisfy the SDK's index-signature constraint.type Input = { deps: string };type Parsed = { order: string[]; needs: Map<string, string[]> } | { error: string; at: number; len: number };/** * One package per line, `name: dependency, dependency`. A name with no colon is a package with no * dependencies; a dependency never declared on a line of its own is one too. Blank lines are skipped. */exportfunction parse(text: string): Parsed {const order: string[] = [];const needs = new Map<string, string[]>();const add = (name: string) => {if (!needs.has(name)) (needs.set(name, []), order.push(name)); };const name = /^[A-Za-z0-9@._/-]+$/;let at = 0;for (const line of text.split("\n")) {const start = at; at += line.length + 1;if (!line.trim()) continue;const colon = line.indexOf(":");const head = (colon < 0 ? line : line.slice(0, colon)).trim();if (!name.test(head)) return { error: `"${head || line.trim()}" is not a package name. Use letters, digits and . _ - / @.`, at: start + line.indexOf(head || line.trim()), len: (head || line.trim()).length }; add(head);if (colon < 0) continue;const list = line.slice(colon + 1);letfrom = start + colon + 1;for (const part of list.split(",")) {const dep = part.trim();const offset = from + part.indexOf(dep);from += part.length + 1;if (!dep) continue;if (!name.test(dep)) return { error: `"${dep}" is not a package name.`, at: offset, len: dep.length };if (dep === head) return { error: `"${head}" depends on itself.`, at: offset, len: dep.length };const mine = needs.get(head)!;if (!mine.includes(dep)) mine.push(dep); } }for (const deps of [...needs.values()]) for (const dep of deps) add(dep);return { order, needs };}/** The first cycle met walking the packages in the order they were written, as names, closed. */function cycleIn(order: string[], needs: Map<string, string[]>): string[] | undefined {const state = new Map<string, number>();const path: string[] = [];const walk = (n: string): string[] | undefined => { state.set(n, 1); path.push(n);for (const d of needs.get(n)!) {if (state.get(d) === 1) return [...path.slice(path.indexOf(d)), d];if (!state.has(d)) {const found = walk(d);if (found) return found; } } path.pop(); state.set(n, 2);returnundefined; };for (const n of order) {if (state.has(n)) continue;const found = walk(n);if (found) return found; }returnundefined;}function graphOf(order: string[], needs: Map<string, string[]>, round: Map<string, number> | undefined, highlight: string[]): Graph {return { label: "Package dependencies",// Dependencies below what needs them, so the build reads upwards, round by round. direction: "BT",// The badge is the round a package builds in. nodes: order.map((id) => (round ? { id, badge: String(round.get(id)) } : { id })),// From a dependency to what needs it: the arrow says "is built before". edges: order.flatMap((pkg) => needs.get(pkg)!.map((dep) => ({ from: dep, to: pkg }))), highlight, };}exportdefault { run({ deps }): Output {const parsed = parse(deps);if ("error"in parsed) return { kind: "error", message: parsed.error, input: "deps", at: parsed.at, len: parsed.len };const { order, needs } = parsed;if (order.length === 0) return { kind: "error", message: "No packages. Write one per line, as name: dependency, dependency.", input: "deps" };const cycle = cycleIn(order, needs);if (cycle) {// Drawn against the direction of the edges, so the highlighted path runs the way the arrows do.const path = [...cycle].reverse();return { kind: "group", parts: [ { kind: "fields", fields: [{ label: "Build order", value: "none", note: "a cycle", tone: "bad", role: "verdict" }, { label: "Cycle", value: cycle.join(" → ") }] }, { kind: "graph", graph: graphOf(order, needs, undefined, path) }, ], }; }// The round each package can be built in: one more than its latest dependency.const round = new Map<string, number>();const level = (n: string): number => {const known = round.get(n);if (known !== undefined) return known;const r = 1 + Math.max(0, ...needs.get(n)!.map(level)); round.set(n, r);return r; };for (const n of order) level(n);const rounds = Math.max(...round.values());const built = [...order].sort((a, b) => round.get(a)! - round.get(b)! || order.indexOf(a) - order.indexOf(b));const widest = Math.max(...Array.from({ length: rounds }, (_, r) => built.filter((n) => round.get(n) === r + 1).length));// The longest chain, from the first package in the last round down through a dependency a round lower.const chain = [built.find((n) => round.get(n) === rounds)!];while (round.get(chain.at(-1)!)! > 1) {const top = chain.at(-1)!; chain.push(needs.get(top)!.find((d) => round.get(d) === round.get(top)! - 1)!); }const up = [...chain].reverse();return { kind: "group", parts: [ { kind: "fields", fields: [ { label: "Build order", value: built.join(", "), role: "verdict" }, { label: "Rounds", value: String(rounds), note: `up to ${widest} at once` }, { label: "Longest chain", value: up.join(" → "), note: `${up.length} package${up.length === 1 ? "" : "s"}` }, ], }, { kind: "graph", graph: graphOf(order, needs, round, up) }, ], }; },} satisfies Tool<Input>;
Bins a list of numbers and draws the counts as bars, with the running total on a second axis.
{}tools/histogram/tool.json
{"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"]}
TStools/histogram/index.ts
/** * A histogram of pasted numbers. * * The tool exists so the runtime's `bar` shape and a `series`-only result (no group around the chart) * have a real tool behind them. queue-explorer already draws two line series on two axes, so the * second axis here is not new; bars are, and so is a chart that is the whole output. A card of this * tool also marks no input `primary`, which is the documented fallback: the card shows the first * input and has to cope. * * Equal-width bins, counts on the left, running percent on the right. The reader picks the bin * count. Automatic width rules (Sturges, Freedman-Diaconis) are a different tool. * * ⚠️ This is the tool that carries `autoRun`, and it earned it by being boring. * * `autoRun` is for tools that are genuinely instant, and "instant" has to hold for the worst input the * manifest permits, not the default one. Here that is 20,000 characters of `values` against 30 bins: a * linear scan for numbers, one pass to bin them, and two small maps. Measured at that bound, 0.3 ms * warm and 1.4 ms cold. There is no input a reader can type that changes the shape of that work, which * is the property `autoRun` actually requires and the reason regex-explainer cannot have it. */importtype { Chart, Output, Tool } from"@toolbench/sdk";// A type alias, not an interface: an interface does not satisfy the SDK's index-signature constraint.type Input = { values: string; bins: number };exporttype ParsedValues = { values: number[] } | { error: string; at: number; len: number };/** * Parse a list of numbers. * * A comma sitting between two digits is refused. The tokenizer also splits on commas, so `1,204` * would otherwise become 1 and 204 and every figure on the chart would look reasonable and all of * them would be wrong. */exportfunction parseValues(text: string): ParsedValues {const grouped = /(\d),(\d)/.exec(text);if (grouped && grouped.index !== undefined) {return { error: "A comma between digits is not a thousands separator. Separate numbers with spaces, or with a comma and a space.", at: grouped.index + 1, len: 1, }; }const values: number[] = [];const token = /[^\s,;]+/g;let match = token.exec(text);while (match !== null) {const value = Number(match[0]);if (!Number.isFinite(value)) {return { error: `"${match[0]}" is not a number.`, at: match.index, len: match[0].length }; } values.push(value); match = token.exec(text); }return { values };}/** Kill binary-float crumbs so a midpoint of 2.5 stays 2.5 in fixtures. */exportfunction cleanNumber(value: number): number {const rounded = Math.round(value * 1e10) / 1e10;return Object.is(rounded, -0) ? 0 : rounded;}exportfunction percent(part: number, whole: number): number {return cleanNumber(Math.round((part / whole) * 1000) / 10);}exporttype Bins = { x: number[]; counts: number[]; cumulative: number[] };/** * Equal-width bins over [min, max]. * * The last bin is closed on the right so `max` is not pushed into a bin that does not exist. When * every value is the same there is no width to split, so the result is one bar at that value. */exportfunction binValues(values: number[], bins: number): Bins {const n = values.length;const min = Math.min(...values);const max = Math.max(...values);if (min === max) {return { x: [cleanNumber(min)], counts: [n], cumulative: [100] }; }const width = (max - min) / bins;const counts = Array.from({ length: bins }, () => 0);for (const value of values) {const index = value === max ? bins - 1 : Math.min(bins - 1, Math.floor((value - min) / width)); counts[index] = (counts[index] as number) + 1; }const x = counts.map((_, i) => cleanNumber(min + (i + 0.5) * width));const cumulative: number[] = [];let running = 0;for (const count of counts) { running += count; cumulative.push(percent(running, n)); }return { x, counts, cumulative };}function chartOf(bins: Bins): Chart {return { xLabel: "Bin midpoint", yLabel: "Count", yLabelRight: "Cumulative", x: bins.x, series: [ { label: "Count", points: bins.counts, shape: "bar" }, { label: "Cumulative", points: bins.cumulative, unit: "%", shape: "line", axis: "right" }, ], };}const tool: Tool<Input> = { run({ values, bins }) {const parsed = parseValues(values);if ("error"in parsed) {return { kind: "error", message: parsed.error, input: "values", at: parsed.at, len: parsed.len }; }if (parsed.values.length === 0) {return { kind: "error", message: "No numbers yet. Paste measurements separated by spaces, commas or newlines.", input: "values" }; }return { kind: "series", chart: chartOf(binValues(parsed.values, bins)) }; },};exportdefault tool;
Paste latencies; get p50 to p99.9, and see why the two common definitions disagree.
{}tools/percentiles/tool.json
{"sdk": 3,"id": "percentiles","name": "Percentiles","blurb": "Paste latencies; get p50 to p99.9, and see why the two common definitions disagree.","version": "1.3.0","capabilities": ["pure"],"runtime": { "entry": "index.ts", "thread": "main" },"card": "live","cardFields": 4,"kinds": ["group", "fields", "table", "error"],"help": "README.md","tags": ["statistics", "performance"],"inputs": [ {"id": "values","type": "textarea","label": "Measurements","description": "Numbers separated by spaces, commas, semicolons or newlines. A comma between digits is refused, because 1,204 could be one number or two: write 1204, or put a space after each comma. Units are yours; the tool does not care.","primary": true,"rows": 4,"dir": "ltr","maxLength": 200000,"default": "12 14 15 15 16 18 19 21 24 28 31 35 42 51 68 91 140 220 480 1200" }, {"id": "method","type": "select","label": "Definition","description": "Nearest-rank picks a measurement you actually observed. Linear interpolates between two.","default": "nearest","options": [ { "value": "nearest", "label": "Nearest rank" }, { "value": "linear", "label": "Linear interpolation" } ] } ],"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": "Definitions agree","input": { "values": "98 99 99 100 100 100 101 101 101 102 102 103", "method": "nearest" } }, {"label": "Not a number","input": { "values": "12, 14, 15, 18ms, 21, 24, 31, 44" } } ],"links": [ { "label": "Why you cannot average percentiles", "href": "https://www.solarwinds.com/blog/why-percentiles-dont-work-the-way-you-think" } ]}
TStools/percentiles/index.ts
/** * Percentiles, and the two definitions of them that disagree. * * The tool exists because of a specific, common mistake: **percentiles do not average.** A dashboard * showing "mean p99 across five hosts" is showing a number that corresponds to no event that ever * happened. This computes the true percentile of the pooled measurements, and shows the naive average * of the per-chunk percentiles beside it, so the size of the lie is visible rather than argued about. * * The second thing it shows is that "the p99" is not one number. Nearest-rank returns a value you * actually measured; linear interpolation returns a point between two measurements. On small or * skewed samples they differ by a lot, and neither is wrong — but a comparison between two systems * using different definitions is meaningless. * * Deliberately pure: no clock, no randomness, no I/O. Which is why every number below is covered by a * fixture with a hand-checked expectation. */importtype { Field, Output, Tool } from"@toolbench/sdk";type Method = "nearest" | "linear";const PERCENTILES = [50, 90, 95, 99, 99.9] asconst;/** A type alias, not an interface: see the note on `Tool` in the SDK. */type Input = { values: string; method: string;};/** * Nearest-rank: the smallest measurement at or above the p-th position. Always a real observation. * Linear: interpolates between the two measurements either side of the position — the definition * numpy and R's type 7 use by default. */exportfunction percentile(sorted: number[], p: number, method: Method): number {if (sorted.length === 0) return Number.NaN;if (sorted.length === 1) return sorted[0] as number;if (method === "nearest") {const rank = Math.ceil((p / 100) * sorted.length);const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));return sorted[index] as number; }const h = ((sorted.length - 1) * p) / 100;const lo = Math.floor(h);const hi = Math.min(sorted.length - 1, lo + 1);const weight = h - lo;return (sorted[lo] as number) * (1 - weight) + (sorted[hi] as number) * weight;}/** * A comma sitting between two digits is a thousands grouping, not a list separator. The tokenizer * splits on commas, so without this check a pasted `1,204 980 1,100 1,340` becomes seven * measurements, min 1 and mean 232.4, and nothing says the input was misread. */function groupingComma(text: string): { error: string; at: number } | null {const hit = /[^\s;]*\d,\d[^\s;]*/.exec(text);if (hit === null) returnnull;/* * Says what was found and how to say it instead. "is not a number" was both untrue, since 1,204 is a * number to every reader, and self-contradicting, since the shared advice told them to separate with * commas while a comma was the thing being refused. */return { error: `"${hit[0]}" has a comma between digits, which could be a thousands grouping. Write digits only, or put a space after each comma`, at: hit.index, };}/** Parses forgivingly, but reports the exact character where it gave up. */function parse(text: string): { values: number[] } | { error: string; at: number } {/* * Before the token scan, so the whole text is judged as text. The cost is precedence: an input holding * both a grouped number and an earlier unparseable token reports the grouping, not the earlier token. * Both are refusals and the reader fixes both, so the offset being the second problem is the lesser * evil against scanning twice. */const grouped = groupingComma(text);if (grouped !== null) return grouped;const values: number[] = [];const token = /[^\s,;]+/g;let match: RegExpExecArray | null = token.exec(text);while (match !== null) {const value = Number(match[0]);if (!Number.isFinite(value)) {return { error: `"${match[0]}" is not a number. Separate measurements with spaces, commas or newlines`, at: match.index }; } values.push(value); match = token.exec(text); }return { values };}function round(value: number): string {if (!Number.isFinite(value)) return"n/a";if (Number.isInteger(value)) return String(value);return value.toFixed(Math.abs(value) < 10 ? 2 : 1);}const tool: Tool<Input> = { run({ values, method }) {const parsed = parse(values);if ("error"in parsed) {return { kind: "error", message: `${parsed.error}.`, input: "values", at: parsed.at, len: 1 }; }if (parsed.values.length === 0) {return { kind: "error", message: "No measurements yet. Paste some numbers.", input: "values" }; }const chosen: Method = method === "linear" ? "linear" : "nearest";const sorted = [...parsed.values].sort((a, b) => a - b);const n = sorted.length;const sum = sorted.reduce((a, b) => a + b, 0);const summary: Field[] = [ { label: "count", value: String(n) }, { label: "p50", value: round(percentile(sorted, 50, chosen)) }, { label: "p99", value: round(percentile(sorted, 99, chosen)) }, { label: "max", value: round(sorted[n - 1] as number) }, { label: "mean", value: round(sum / n), note: "shown for contrast: it is not a percentile" }, { label: "min", value: round(sorted[0] as number) }, ];/* * The comparison that makes the point. Split the measurements into four equal chunks — the way * a real system splits across hosts or minutes — take each chunk's p99, and average those. * That average is what most dashboards report. The true p99 of everything pooled is beside it. */const chunks = splitInto(sorted, 4);const perChunk = chunks.map((chunk) => percentile([...chunk].sort((a, b) => a - b), 99, chosen));const naive = perChunk.reduce((a, b) => a + b, 0) / perChunk.length;const truth = percentile(sorted, 99, chosen);const drift = truth === 0 ? 0 : ((naive - truth) / truth) * 100;const rows = PERCENTILES.map((p) => {const nearest = percentile(sorted, p, "nearest");const linear = percentile(sorted, p, "linear");const gap = nearest === 0 ? 0 : ((linear - nearest) / nearest) * 100;return [ { text: `p${p}`, mono: true }, { text: round(nearest), mono: true }, { text: round(linear), mono: true }, { text: `${gap >= 0 ? "+" : ""}${gap.toFixed(1)}%`, mono: true, tone: Math.abs(gap) > 5 ? ("warn"asconst) : ("normal"asconst) }, ]; });return { kind: "group", parts: [ { kind: "fields", fields: summary }, { kind: "table", caption: "The same data under both definitions. A comparison across systems using different ones means nothing.", columns: [ { label: "", mono: true }, { label: "Nearest rank", align: "end", mono: true }, { label: "Linear", align: "end", mono: true }, { label: "Difference", align: "end", mono: true }, ], rows, }, { kind: "fields", fields: [ { label: "true p99", value: round(truth), group: "Averaging percentiles", note: "of all measurements pooled" }, { label: "averaged p99", value: round(naive), group: "Averaging percentiles", note: "mean of four chunks' p99, which is what a dashboard usually shows" }, { label: "error", value: `${drift >= 0 ? "+" : ""}${drift.toFixed(1)}%`, group: "Averaging percentiles", tone: Math.abs(drift) > 10 ? "bad" : Math.abs(drift) > 2 ? "warn" : "good", note: "how wrong the averaged figure is", }, ], }, ], } satisfies Output; },};function splitInto<T>(items: T[], count: number): T[][] {const size = Math.ceil(items.length / count);const chunks: T[][] = [];for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));return chunks;}exportdefault tool;
Move the arrival rate and watch latency go vertical before capacity runs out, with a simulation checking the formula.
{}tools/queue-explorer/tool.json
{"sdk": 1,"id": "queue-explorer","name": "Queue explorer","blurb": "Move the arrival rate and watch latency go vertical before capacity runs out, with a simulation checking the formula.","version": "1.1.0","capabilities": ["pure"],"runtime": { "entry": "index.ts", "thread": "worker" },"timeoutMs": 8000,"card": "live","cardFields": 3,"kinds": ["group", "series", "fields", "error"],"help": "README.md","tags": ["queueing", "capacity", "simulation"],"inputs": [ {"id": "arrivals","type": "number","label": "Arrival rate","unit": "req/s","description": "How much work shows up per second.","primary": true,"default": 800,"min": 1,"max": 5000,"step": 10 }, {"id": "service","primary": true,"type": "number","label": "Service time","unit": "ms","description": "How long one request takes when nothing is queued. Capacity is 1000 ÷ this.","default": 1,"min": 0.1,"max": 100,"step": 0.1 }, {"id": "samples","type": "number","label": "Simulation samples","unit": "arrivals","description": "More samples narrow the gap between the simulation and the formula. This is the input that makes the tool slow, which is why it runs in a worker.","default": 200000,"min": 1000,"max": 5000000,"step": 1000 }, {"id": "seed","type": "number","label": "Random seed","description": "Fixed, so the same inputs always give the same simulation. A tool nobody can reproduce is a tool nobody can test.","default": 42,"min": 1,"max": 999999,"step": 1 } ],"links": [ { "label": "Little's law", "href": "https://en.wikipedia.org/wiki/Little%27s_law" }, { "label": "M/M/1 queue", "href": "https://en.wikipedia.org/wiki/M/M/1_queue" } ]}
TStools/queue-explorer/index.ts
/** * Queue explorer: why latency goes vertical before a system runs out of capacity. * * The intuition this exists to fix is expensive to lack. At 80% utilisation an M/M/1 queue holds four * jobs; at 95% it holds nineteen. Nobody plans for that curve by reasoning about it, so the tool draws * it, marks where you are on it, and shows the same numbers two independent ways. * * Two things make the code worth reading, and both are the reason this tool is in the example set: * * 1. **It proves its own arithmetic.** The closed-form M/M/1 result and a discrete-event Monte Carlo * simulation are computed side by side. If they disagree beyond sampling error, one of them is * wrong — and the tool says so rather than quietly presenting the formula as truth. * 2. **The randomness is seeded**, so a simulation is a *pure function* of its inputs. That is what * lets a fixture assert a simulated mean, and it is the difference between a demo and something * you can regression-test. * * It declares `thread: "worker"` because its running time is set by an input: at five million samples * it would drop frames on the main thread, and only a worker can be stopped. */importtype { Chart, Field, Output, Tool } from"@toolbench/sdk";type Input = { arrivals: number; service: number; samples: number; seed: number;};/** * mulberry32 — small, fast, and good enough for queueing samples. * * Chosen over `Math.random()` for one reason: reproducibility. A tool whose output changes between * runs cannot have fixtures, and a simulation nobody can pin is a simulation nobody can trust. */function rng(seed: number): () => number {let a = seed >>> 0;return () => { a = (a + 0x6d2b79f5) >>> 0;let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}/** Inverse-transform sampling of an exponential. `1 - u` keeps it defined when u is 0. */const expo = (random: () => number, rate: number) => -Math.log(1 - random()) / rate;exportinterface Analytic { rho: number;/** Mean time in system, ms. */ w: number;/** Mean time waiting, ms. */ wq: number;/** Mean number in system. */ l: number;/** Mean number waiting. */ lq: number;}/** Closed-form M/M/1. λ and μ are per second; the times come back in milliseconds. */exportfunction analytic(lambda: number, mu: number): Analytic {const rho = lambda / mu;const w = 1 / (mu - lambda);return { rho, w: w * 1000, wq: (rho / (mu - lambda)) * 1000, l: rho / (1 - rho), lq: (rho * rho) / (1 - rho), };}/** * Lindley's recursion: the waiting time of the next arrival is the previous one's wait, plus its * service, minus the gap since it arrived — floored at zero, because you cannot wait negative time. * * That single line is the whole simulation, which is part of why it is a good thing to publish. */exportfunction simulate( lambda: number, mu: number, samples: number, seed: number, onProgress?: (done: number, meanSoFar: number) => void,): number {const random = rng(seed);let wait = 0;let total = 0;const step = Math.max(1, Math.floor(samples / 20));for (let i = 0; i < samples; i++) {const service = expo(random, mu);const gap = expo(random, lambda); total += wait + service; wait = Math.max(0, wait + service - gap);if (onProgress && (i + 1) % step === 0) onProgress(i + 1, (total / (i + 1)) * 1000); }return (total / samples) * 1000;}const fmt = (value: number, digits = 2) => Number.isFinite(value) ? value.toFixed(digits) : "n/a";function curve(mu: number, current: number): Chart {/* * The x axis is utilisation rather than arrival rate, because the shape is the point and it is the * same shape for every system. Stopping at 0.98 is deliberate: the formula diverges at 1, and a * chart whose last point is infinity tells you nothing about the interesting part. */const xs: number[] = [];for (let rho = 0.05; rho <= 0.981; rho += 0.05) xs.push(Math.round(rho * 100) / 100);const latency = xs.map((rho) => (1 / (mu - rho * mu)) * 1000);const inSystem = xs.map((rho) => rho / (1 - rho));return { xLabel: "Utilisation", yLabel: "Time in system", yUnit: "ms", xUnit: "ρ", x: xs, series: [ { label: "Time in system", points: latency, unit: "ms", shape: "line" }, { label: "Jobs in system", points: inSystem, unit: "jobs", shape: "line", axis: "right" }, ], annotations: [{ x: Math.round(current * 100) / 100, label: `you are here (ρ ${fmt(current, 2)})` }], yLabelRight: "Jobs in system", };}const tool: Tool<Input> = {async run({ arrivals, service, samples, seed }, ctx) {const lambda = arrivals;const mu = 1000 / service;if (lambda >= mu) {/* * Not a crash, and not a number: at ρ ≥ 1 the queue never drains, so every "mean" is * infinite. Saying that plainly, against the input that caused it, is the correct answer. */return { kind: "error", message:`At ${arrivals} arrivals/s and ${service} ms of service, one server can handle ${fmt(mu, 0)}/s. ` +"The queue never drains, so there is no steady state to report. Lower the arrival rate or speed up service.", input: "arrivals", }; }const a = analytic(lambda, mu);// Partial results, so a long run shows itself converging instead of hiding behind a bar.// #region ctxconst 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` }, ], }); });// #endregionconst error = a.w === 0 ? 0 : ((simulated - a.w) / a.w) * 100;const agrees = Math.abs(error) < 5;const fields: Field[] = [ { label: "utilisation", value: fmt(a.rho, 3), note: "ρ = arrivals ÷ capacity" }, { label: "time in system", value: `${fmt(a.w)} ms`, group: "Formula" }, { label: "time waiting", value: `${fmt(a.wq)} ms`, group: "Formula" }, { label: "jobs in system", value: fmt(a.l), group: "Formula" }, { label: "jobs waiting", value: fmt(a.lq), group: "Formula" }, { label: "time in system", value: `${fmt(simulated)} ms`, group: "Simulation", note: `${samples.toLocaleString()} samples, seed ${seed}` }, { label: "difference", value: `${error >= 0 ? "+" : ""}${fmt(error, 1)}%`, group: "Simulation", tone: agrees ? "good" : "warn", note: agrees ? "the two methods agree" : "more samples would narrow this", }, ];return { kind: "group", parts: [ { kind: "series", chart: curve(mu, a.rho) }, { kind: "fields", fields }, ], } satisfies Output; },};exportdefault tool;
Walks a JavaScript regular expression, shows what each piece means, and tests it against a subject.
{}tools/regex-explainer/tool.json
{"sdk": 3,"id": "regex-explainer","name": "Regex explainer","blurb": "Walks a JavaScript regular expression, shows what each piece means, and tests it against a subject.","version": "1.0.0","capabilities": ["pure"],"runtime": { "entry": "index.ts", "thread": "worker" },"kinds": ["group", "text", "code", "error"],"card": "live","timeoutMs": 2000,"inputs": [ {"id": "pattern","type": "text","label": "Pattern","description": "A JavaScript regular expression, without the surrounding slashes.","primary": true,"dir": "ltr","maxLength": 80,"default": "(\\w+)\\s+(\\d+)" }, {"id": "subject","type": "textarea","label": "Subject","description": "The string to test the pattern against.","dir": "ltr","rows": 3,"maxLength": 200,"default": "user 42 ready" }, {"id": "flags","type": "select","label": "Flags","description": "JavaScript flags only. g returns every match; without it, only the first.","default": "none","options": [ { "value": "none", "label": "None" }, { "value": "i", "label": "Ignore case (i)" }, { "value": "m", "label": "Multiline (m)" }, { "value": "g", "label": "All matches (g)" }, { "value": "gi", "label": "All, ignore case (gi)" }, { "value": "s", "label": "Dotall (s)" } ] } ],"samples": [ {"label": "Word and number","input": { "pattern": "(\\w+)\\s+(\\d+)", "subject": "user 42 ready", "flags": "none" } }, {"label": "Ignore case","input": { "pattern": "hello", "subject": "Hello there", "flags": "i" } }, {"label": "All matches","input": { "pattern": "\\d+", "subject": "a1 b22 c333", "flags": "g" } }, {"label": "Unclosed group","input": { "pattern": "(\\w+" } } ],"help": "README.md","tags": ["regex", "text", "code"],"links": [ { "label": "MDN: Regular expressions", "href": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions" } ]}
TStools/regex-explainer/index.ts
/** * A JavaScript regular-expression explainer. * * The tool exists so the runtime's `text` renderer has a real tool behind it, and so `code` has one * outside the bench's own fixtures. It is a real explainer rather than a stub because a renderer * nobody has looked at is a renderer nobody has tested. * * ⚠️ Worker mode, with a timeout, and no `autoRun`. All three are the same decision. * * This tool compiles a pattern the reader typed and runs it against a subject the reader typed, which * makes it the one tool here that cannot bound its own running time. The refusal in `applyQuantifier` * catches the textbook `(a+)+` shape, and it is worth keeping because the message teaches something. * But it is a denylist, and the shapes it does not name are not rare: `(a|aa)+
Gallery: every feature, live, ToolbenchSkip to content
Every feature of the runtime, live, beside the code that produces it.
nests no repeat at * all, passes the check, and against 45 characters of `a` followed by a `b` it takes **28 seconds**. * Measured, not estimated. At 41 characters it is 4.2 s and at 37 it is 0.6 s, so a reader typing one * character at a time walks up that curve. * * Those figures are V8's. Measured across the browser suite: Chromium and Firefox both grind and reach * the timeout, while WebKit returns almost at once, because JavaScriptCore abandons a runaway backtrack * rather than running it out. Two engines in three will hang, so the rescue is not something to build * on, and an engine that happens to save you is not a bound. * * On the main thread there is nothing to terminate: `ctx.signal` only works for a tool that checks it, * and no tool can check anything while the regex engine is inside a match. See the table in * `runner.ts`, which is why the manifest rejects `timeoutMs` outside worker mode. So the engine gets a * thread of its own and 2 s to finish, and a pathological pattern becomes a timeout the reader can * read rather than a tab they have to close. Dropping `autoRun` is the other half: a tool that can * take 2 s must not start on a keystroke. * * ⚠️ `timeoutMs` is a top-level manifest key, NOT part of `runtime`, even though `thread` is and the * two only make sense together. Writing it inside `runtime` cost a CI round: nothing rejected it, * because validation looks for it at the top level and found nothing there, so the tool ran on the * 5,000 ms default and the browser test failed on a message naming the wrong number. A key in the * wrong object is silently ignored, which is the one way a manifest can lie. */importtype { Output, Tool } from"@toolbench/sdk";// A type alias, not an interface: an interface does not satisfy the SDK's index-signature constraint.type Input = { pattern: string; subject: string; flags: string };exporttype WalkToken = { source: string; meaning: string; unbounded: boolean };const SPECIAL_ESCAPE: Record<string, string> = { d: "a digit", D: "not a digit", w: "a word character", W: "not a word character", s: "a whitespace character", S: "not a whitespace character", n: "a newline", t: "a tab", r: "a carriage return", f: "a form feed", v: "a vertical tab", b: "a word boundary", B: "not a word boundary",};const FLAG_VALUES = new Set(["none", "i", "m", "g", "gi", "s"]);type GroupKind = "capture" | "noncapture" | "lookahead" | "negLookahead" | "lookbehind" | "negLookbehind" | "named";type GroupFrame = { kind: GroupKind; n?: number; name?: string; start: number };class WalkError extends Error { constructor(message: string) { super(message); this.name = "WalkError"; }}/** True when a quantifier can match arbitrarily many times. `{2,5}` is not; `+`, `*` and `{2,}` are. */function isUnbounded(kind: "optional" | "star" | "plus" | "exact" | "min" | "range"): boolean {return kind === "star" || kind === "plus" || kind === "min";}function quantifierPhrase( kind: "optional" | "star" | "plus" | "exact" | "min" | "range", n?: number, m?: number, lazy?: boolean,): string {let phrase = "";if (kind === "optional") phrase = ", optional";elseif (kind === "star") phrase = ", any number";elseif (kind === "plus") phrase = ", one or more";elseif (kind === "exact") phrase = `, exactly ${n}`;elseif (kind === "min") phrase = `, ${n} or more`;else phrase = `, ${n} to ${m}`;if (lazy) phrase += ", as few as possible";return phrase;}function readQuantifier(pattern: string, from: number): { raw: string; kind: "optional" | "star" | "plus" | "exact" | "min" | "range"; n?: number; m?: number; lazy: boolean; next: number } | undefined {const ch = pattern[from];if (ch === "?" || ch === "*" || ch === "+") {const kind = ch === "?" ? "optional" : ch === "*" ? "star" : "plus";const lazy = pattern[from + 1] === "?";return { raw: pattern.slice(from, from + (lazy ? 2 : 1)), kind, lazy, next: from + (lazy ? 2 : 1) }; }if (ch !== "{") returnundefined;const match = /^\{(\d+)(?:,(\d*))?\}(\?)?/.exec(pattern.slice(from));if (!match) returnundefined;const n = Number(match[1]);const lazy = match[3] === "?";if (match[0].includes(",") && match[2] === "") {return { raw: match[0], kind: "min", n, lazy, next: from + match[0].length }; }if (match[0].includes(",")) {const m = Number(match[2]);if (n > m) returnundefined;return { raw: match[0], kind: "range", n, m, lazy, next: from + match[0].length }; }return { raw: match[0], kind: "exact", n, lazy, next: from + match[0].length };}function hexChar(pattern: string, from: number, width: number): { char: string; raw: string; next: number } | undefined {const digits = pattern.slice(from, from + width);if (digits.length < width || !/^[0-9A-Fa-f]+$/.test(digits)) returnundefined;return { char: String.fromCharCode(Number.parseInt(digits, 16)), raw: digits, next: from + width };}function closeMeaning(frame: GroupFrame): string {if (frame.kind === "capture") return`end capture ${frame.n}`;if (frame.kind === "named") return`end named capture "${frame.name}"`;if (frame.kind === "lookahead") return"end positive lookahead";if (frame.kind === "negLookahead") return"end negative lookahead";if (frame.kind === "lookbehind") return"end positive lookbehind";if (frame.kind === "negLookbehind") return"end negative lookbehind";return"end group";}/** * Walk a pattern the JavaScript engine has already accepted. * * The meanings are the contract the fixtures pin. Changing a phrase is a fixture change, so the * wording is kept plain and stable: atom, then a comma, then the repeat. */exportfunction walkPattern(pattern: string, flags: string): WalkToken[] {const tokens: WalkToken[] = [];const stack: GroupFrame[] = [];let capture = 1;let i = 0;const multiline = flags.includes("m");const dotall = flags.includes("s");const push = (source: string, meaning: string, unbounded = false) => { tokens.push({ source, meaning, unbounded }); };const applyQuantifier = (source: string, meaning: string, atomUnbounded: boolean, groupInner?: WalkToken[]) => {const q = readQuantifier(pattern, i);if (!q) { push(source, meaning, atomUnbounded);return; }/* * A denylist of one shape, kept for the explanation rather than for safety: `(a+)+` is what a * reader is most likely to write by accident, and being told why beats being told it timed out. * * ⚠️ It is not the protection. `(a|aa)+
Gallery: every feature, live, ToolbenchSkip to content
Shows the bytes behind a string, and marks every character that needs more than one of them.
{}tools/utf8-bytes/tool.json
{"sdk": 2,"id": "utf8-bytes","name": "UTF-8 bytes","blurb": "Shows the bytes behind a string, and marks every character that needs more than one of them.","version": "1.0.0","capabilities": ["pure"],"runtime": { "entry": "index.ts", "thread": "main" },"kinds": ["group", "fields", "bytes", "error"],"card": "live","cardFields": 3,"inputs": [ {"id": "text","type": "textarea","label": "Text","description": "Any string. Emoji and combining marks are the interesting cases.","primary": true,"rows": 2,"maxLength": 512,"default": "Héllo, 世界 👋" } ],"help": "README.md","tags": ["encoding", "unicode", "bytes"],"links": [ { "label": "UTF-8 (RFC 3629)", "href": "https://www.rfc-editor.org/rfc/rfc3629" } ]}
TStools/utf8-bytes/index.ts
/** * UTF-8 bytes, with the multi-byte characters marked. * * This tool exists to exercise the `bytes` output kind added in contract version 2, and it is a real * tool rather than a stub because a renderer nobody has looked at is a renderer nobody has tested. * It covers the cases that make `bytes` worth having: highlight ranges that start mid-row, a range * longer than one byte, and labels that carry meaning colour cannot. */importtype { ByteRange, Output, Tool } from"@toolbench/sdk";// A type alias, not an interface: an interface does not satisfy the SDK's index-signature constraint.type Input = { text: string };/** Every code point, with its UTF-8 length. Iterating a string yields code points, not UTF-16 units. */function encode(text: string): { bytes: number[]; ranges: ByteRange[]; codePoints: number } {const encoder = new TextEncoder();const bytes: number[] = [];const ranges: ByteRange[] = [];let codePoints = 0;for (const char of text) {const encoded = [...encoder.encode(char)]; codePoints++;/* * Only multi-byte characters get a range. Marking every character would highlight the whole * dump, which says nothing: the point of a highlight is that it is the exception. */if (encoded.length > 1) {const point = char.codePointAt(0) ?? 0;/* * No tone. A four-byte character is information, not a severity, and `--tb-warn` is the same * colour as `--tb-accent` in the default palette anyway, so the distinction would have been * invisible while implying one existed. The label already says how many bytes it is. */ ranges.push({ at: bytes.length, len: encoded.length, label: `U+${point.toString(16).toUpperCase().padStart(4, "0")} ${char}`, }); } bytes.push(...encoded); }return { bytes, ranges, codePoints };}exportdefault { run({ text }): Output {if (text.length === 0) {return { kind: "error", message: "Nothing to encode. Type some text.", input: "text" }; }const { bytes, ranges, codePoints } = encode(text);const ascii = bytes.length === codePoints;return { kind: "group", parts: [ { kind: "fields", fields: [ { label: "Bytes", value: String(bytes.length) }, { label: "Characters", value: String(codePoints), note: "code points, not UTF-16 units" }, { label: "Encoding", value: ascii ? "pure ASCII" : `${ranges.length} multi-byte`,// Only a real tone. "normal" is the default, so emitting it is noise that every// fixture then has to repeat. ...(ascii ? { tone: "good"asconst } : {}), }, { label: "Overhead", value: `${bytes.length - codePoints} byte${bytes.length - codePoints === 1 ? "" : "s"}`, note: "what UTF-8 costs over one byte per character", }, ], }, { kind: "bytes", bytes, caption: ascii ? "Every character is one byte" : "Highlighted runs are one character each", ...(ranges.length > 0 ? { highlight: ranges } : {}), }, ], }; },} satisfies Tool<Input>;
mode="embed" drops the title and takes the page's type, so a tool reads as part of the prose around it.
Bin a set of response times and the long tail shows up at once: most requests are quick, and a few are very slow. Try adding a slower one.
Every bar moved because the bins are equal widths between the smallest and the largest value.
<>Embed
<divclass="article-demo"><p>Bin a set of response times and the long tail shows up at once: most requests are quick, and a few are very slow. Try adding a slower one.</p><tool-hosttool="histogram"mode="embed"></tool-host><p>Every bar moved because the bins are equal widths between the smallest and the largest value.</p></div>
parts="1" shows the first part of a grouped result; more="expand" turns the notice into a button that reveals the rest in place, without running the tool again.
links="all", the default, draws the manifest's links. page draws only a link to the tool's own page, and none draws nothing, for a tool in an article that lists its references already.
"control": "slider" on a number input adds a native range beside the box, for a value read by sweeping it.
{}bench/fixtures/slider/tool.json, one input
{"id": "rtt","type": "number","label": "Round-trip time","unit": "ms","description": "Drag, use the arrow keys, or type an exact value.","primary": true,"default": 80,"min": 10,"max": 500,"step": 10,"control": "slider"}
"control": "segmented" on a select draws its options as one row of native radios, the chosen one filled. When a label would not fit, the row becomes stacked radios. A date input is the browser's own picker, and a value out of range is refused before the tool runs. A color is a hex box beside the system's picker, and a secret a password field with no default that no link carries.
{}bench/fixtures/native-inputs/tool.json, one input
{"id": "order","type": "select","control": "segmented","label": "Byte order","description": "How multi-byte fields are read. {muted|Detect} needs a `0xFEFF` mark in the first two bytes.","default": "little","options": [ {"value": "little","label": "Little-endian (x86)" }, {"value": "big","label": "Big-endian (network)" }, {"value": "auto","label": "Detect from header" } ]}
{}bench/fixtures/native-inputs/tool.json, one input
{"id": "captured","type": "date","mode": "datetime","label": "Captured at","unit": "local time","description": "When the trace was taken, between **2020-01-01** and **2026-10-25**. Arrives as `2026-10-25T14:30`.","default": "2026-10-25T14:30","min": "2020-01-01T00:00","max": "2026-10-25T23:59"}
{}bench/fixtures/native-inputs/tool.json, one input
{"id": "accent","type": "color","label": "Highlight colour","description": "Six hex digits, as `#rrggbb`. The swatch opens the system picker.","default": "#1f5aa8"}
{}bench/fixtures/native-inputs/tool.json, one input
{"id": "token","type": "secret","label": "API token","description": "Used for this run only. {warn|Never put in a shared link.}","minLength": 16,"maxLength": 64}
A multiselect hands run a list. Up to six options are checkboxes and more are chips; min and max bound the list, and Run below min is refused under the input.
{}bench/fixtures/multiselect/tool.json, one input
{"id": "types","type": "multiselect","label": "Record types","description": "The records to ask each resolver for.","default": ["A", "AAAA", "MX"],"min": 1,"options": [ {"value": "A","label": "A" }, {"value": "AAAA","label": "AAAA" }, {"value": "MX","label": "MX" }, {"value": "TXT","label": "TXT" }, {"value": "CNAME","label": "CNAME" }, {"value": "NS","label": "NS" } ]}
A rows input is a list the reader edits, and run gets one object per row. Arrow keys on a handle move a row; maxRows is required. The rate-limits example tool has two columns.
{}bench/fixtures/rows/tool.json, one input
{"id": "hosts","type": "rows","label": "Hosts","description": "One host per row. Arrow keys on a handle move its row.","minRows": 1,"maxRows": 8,"columns": [ {"id": "host","type": "text","label": "Host","maxLength": 253,"dir": "ltr" } ],"default": [ {"host": "example.com" }, {"host": "api.example.com" }, {"host": "status.example.net" } ]}
{}tools/rate-limits/tool.json, one input
{"id": "limits","type": "rows","label": "Limits","description": "A path prefix and its limit. The first matching path wins, so order matters.","minRows": 1,"maxRows": 8,"columns": [ {"id": "path","type": "text","label": "Path","maxLength": 100,"dir": "ltr" }, {"id": "limit","type": "number","label": "Limit","min": 1,"max": 10000,"step": 1,"unit": "req/s" } ],"default": [ {"path": "/api/login","limit": 5 }, {"path": "/api","limit": 500 } ]}
A textarea with a lang is painted by the host's highlighter, the same hook that paints code results. Caret, selection and undo stay the browser's own.
{}bench/fixtures/highlighted-input/tool.json, one input
{"id": "config","type": "textarea","label": "Client config","description": "JSON. Highlighted through the host's highlight hook when one is installed.","lang": "json","rows": 8,"dir": "ltr","default": "{\n \"name\": \"orders-client\",\n \"enabled\": true,\n \"timeouts\": {\n \"connect\": 5,\n \"read\": 30,\n \"total\": null\n },\n \"retry\": {\n \"attempts\": 3,\n \"backoffMs\": 200,\n \"multiplier\": 2.0,\n \"jitter\": false,\n \"on\": [\"502\", \"503\", \"504\"]\n },\n \"pool\": {\n \"maxIdle\": 16,\n \"maxPerHost\": 64,\n \"idleTimeout\": 90\n },\n \"tls\": {\n \"verify\": true,\n \"minVersion\": \"1.2\",\n \"alpn\": [\"h2\", \"http/1.1\"]\n },\n \"headers\": {\n \"user-agent\": \"orders-client/4.1\",\n \"accept\": \"application/json\",\n \"x-request-budget-ms\": 2500\n },\n \"proxy\": null,\n \"logging\": {\n \"level\": \"warn\",\n \"redact\": [\"authorization\", \"cookie\"],\n \"sampleRate\": 0.05\n },\n \"aVeryLongKeyThatForcesTheLineToWrapInsideTheTextareaSoSoftWrapPositionsCanBeCompared\": \"value with spaces that also wraps across the visible width of the field\"\n}"}
TSsite/src/gallery-host.ts
// The runtime asks for a Node, never a string, so it never assigns innerHTML.exportfunction highlight(source: string, lang: string): Node {const out = document.createDocumentFragment();for (const [cls, text] of tokens(source, lang)) {if (!cls) { out.append(text); continue; }const span = document.createElement("span"); span.className = `t-${cls}`; span.textContent = text; out.append(span); }return out;}
A tool can ship the grammar for a notation of its own: keywords, a comment marker, operators and strings. It is painted with no host highlighter at all, and a host highlighter that knows the lang still wins.
{}bench/fixtures/grammar-input/tool.json, one input
{"id": "machine","type": "textarea","label": "Machine","description": "One state or transition per line. # starts a comment.","primary": true,"lang": "doors","grammar": {"keywords": ["initial", "state", "on", "final"],"comment": "#","operators": ["->", ":"],"strings": "\"" },"rows": 9,"maxLength": 4000,"dir": "ltr","default": "# A door that locks\ninitial closed\nstate open\nstate locked \"needs a key\"\non push: closed -> open\non pull: open -> closed\non lock: closed -> locked\non unlock: locked -> closed"}
A description can carry paragraphs, a list, code, bold and a tone for a word, so help that covers several formats reads as a list. Built as elements, never as HTML, and a description without markup is drawn as before.
{}bench/fixtures/input-help/tool.json, one input
{"id": "values","type": "textarea","label": "Latencies","description": "Raw latencies, in the unit chosen below.\n- **Spaces or commas**: `41 38 37 1450`.\n- **One per line**: a column pasted from a spreadsheet.\n\nA negative value is {bad|refused}. Zeros are {good|kept}, and {muted|blank lines are ignored}.","default": "41 38 37 39 36 35 44 52 61 1450","rows": 3,"maxLength": 20000,"dir": "ltr","role": "data","primary": true}
An input's details sit under its one-line hint in a native disclosure, closed until the reader opens it. Same markup as a description, and a screen reader on the control hears only the hint.
{}bench/fixtures/input-help/tool.json, one input
{"id": "slo","type": "number","label": "p99 target","description": "The p99 the service promises.","details": "Compared with the p99 of what you paste, by nearest rank: the smallest value with at least 99% of them at or below it.\n- A p99 equal to the target is a {bad|miss} only when the toggle below is on.\n- With fewer than 100 values, the p99 is the {warn|largest} one.\n\n{muted|A percentile of a few values says little.}","default": 300,"min": 1,"max": 60000,"role": "threshold"}
A run that never ends is stopped at its timeout. A tool that throws is reported as a bug. Bad input is the tool working: the message points at the input. An unknown tool says so.
// Fill the form the way typing would. It does not run the tool.if (host.dataset.values) host.values = JSON.parse(host.dataset.values) as Record<string, unknown>;
A deprecated tool still runs and carries a marker. A retired one refuses to load and points the way onward, so an old link explains rather than breaks.
A tool is checked before anything runs. A manifest that uses a feature newer than the contract it declares is refused, and so is a link that is not http, https, mailto or relative. Each message names the field and says what to do.
A grammar, declaring contract 5
inputs[0].lang: is from contract version 6, but the tool declares "sdk": 5. Declare "sdk": 7.
A link with a tab inside its scheme
links[0].href: must be an http, https or mailto URL, or a relative path, with no spaces or control characters
{"sdk": 5,"id": "grammar-input","name": "Door machine","blurb": "A state machine written in a notation of its own, painted by the grammar the tool ships, with no host highlighter needed.","version": "1.0.0","capabilities": ["pure"],"runtime": {"entry": "index.ts","thread": "main" },"card": "live","kinds": ["fields", "error"],"tags": ["bench"],"inputs": [ {"id": "machine","type": "textarea","label": "Machine","description": "One state or transition per line. # starts a comment.","primary": true,"lang": "doors","grammar": {"keywords": ["initial", "state", "on", "final"],"comment": "#","operators": ["->", ":"],"strings": "\"" },"rows": 9,"maxLength": 4000,"dir": "ltr","default": "# A door that locks\ninitial closed\nstate open\nstate locked \"needs a key\"\non push: closed -> open\non pull: open -> closed\non lock: closed -> locked\non unlock: locked -> closed" }, {"id": "labels","type": "textarea","label": "Button labels","description": "JSON, which the host's highlighter knows, so it paints this one rather than the grammar.","lang": "json","grammar": {"keywords": ["never"] },"rows": 2,"maxLength": 1000,"dir": "ltr","default": "{\"push\": \"Push\", \"pull\": \"Pull\"}" } ]}
{}tools/histogram/tool.json, with a tab inside a link's scheme
{"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"],"links": [ {"label": "Spec","href": "java\tscript:alert(document.cookie)" } ]}
Results
A tool returns data in one of a closed set of shapes, and the runtime draws it. The tool never writes HTML.
A code result is preformatted text with a language. A host that has a highlighter passes it once, and every tool's code is painted with it.
TSsite/src/gallery-host.ts
// The runtime asks for a Node, never a string, so it never assigns innerHTML.exportfunction highlight(source: string, lang: string): Node {const out = document.createDocumentFragment();for (const [cls, text] of tokens(source, lang)) {if (!cls) { out.append(text); continue; }const span = document.createElement("span"); span.className = `t-${cls}`; span.textContent = text; out.append(span); }return out;}
Every code and table result has a Copy button. Code copies exactly as drawn. A table copies as tab-separated rows that paste into a spreadsheet as cells, and a cell that would run as a formula there is copied as text.
{}What run() returns
{"kind": "group","parts": [ {"kind": "fields","fields": [ {"label": "count","value": "20" }, {"label": "p50","value": "28" }, {"label": "p99","value": "1200" }, {"label": "max","value": "1200" }, {"label": "mean","value": "127","note": "shown for contrast: it is not a percentile" }, {"label": "min","value": "12" } ] }, {"kind": "table","caption": "The same data under both definitions. A comparison across systems using different ones means nothing.","columns": [ {"label": "","mono": true }, {"label": "Nearest rank","align": "end","mono": true }, {"label": "Linear","align": "end","mono": true }, {"label": "Difference","align": "end","mono": true } ],"rows": [ [ {"text": "p50","mono": true }, {"text": "28","mono": true }, {"text": "29.5","mono": true }, {"text": "+5.4%","mono": true,"tone": "warn" } ], [ {"text": "p90","mono": true }, {"text": "220","mono": true }, {"text": "246.0","mono": true }, {"text": "+11.8%","mono": true,"tone": "warn" } ], [ {"text": "p95","mono": true }, {"text": "480","mono": true }, {"text": "516.0","mono": true }, {"text": "+7.5%","mono": true,"tone": "warn" } ], [ {"text": "p99","mono": true }, {"text": "1200","mono": true }, {"text": "1063.2","mono": true }, {"text": "-11.4%","mono": true,"tone": "warn" } ], [ {"text": "p99.9","mono": true }, {"text": "1200","mono": true }, {"text": "1186.3","mono": true }, {"text": "-1.1%","mono": true,"tone": "normal" } ] ] }, {"kind": "fields","fields": [ {"label": "true p99","value": "1200","group": "Averaging percentiles","note": "of all measurements pooled" }, {"label": "averaged p99","value": "328","group": "Averaging percentiles","note": "mean of four chunks' p99, which is what a dashboard usually shows" }, {"label": "error","value": "-72.7%","group": "Averaging percentiles","tone": "bad","note": "how wrong the averaged figure is" } ] } ]}
A field's value is monospace, for an address or a hex string read character by character. mono: false sets a sentence or a plain number in the text face, in the same list.
A dozen --tb-* properties set colours, type and radius.
#site/src/gallery.css
/* The same tool in another theme: a dozen custom properties on a wrapper, nothing else. */.theme-demo tool-host {--tb-accent: #0b6b5f;--tb-accent-text: #0b6b5f;--tb-accent-ink: #ffffff;--tb-accent-bg: #e1f0ec;--tb-bg: #fbfdfc;--tb-surface: #eef6f3;--tb-fg: #1d2422;--tb-muted: #4f5b57;--tb-border: #b9dbd4;--tb-radius: 2px;--tb-font: Georgia, "Times New Roman", serif;}
For what a property cannot say, pass CSS to defineToolHost({ styles }), for every tool or one tool by id. Your rules win over the runtime's, whatever their specificity.
A tool can name a part of its result, and host.reveal(id) opens, scrolls to and focuses it.
TSsite/src/gallery.ts
const host = document.querySelector<Host>("#reveal-host");for (const button of document.querySelectorAll<HTMLButtonElement>("[data-reveal]")) {// Opens the part if `parts` hid it, scrolls to it and moves focus there. button.addEventListener("click", () => void host?.reveal(button.dataset.reveal ?? ""));}
<>Embed
<divclass="reveal-demo"><pclass="reveal-buttons"><buttontype="button"class="button"data-reveal="stats">Show the stats</button><buttontype="button"class="button"data-reveal="fields">Show the fields</button></p><tool-hosttool="styled-output"mode="embed"parts="1"more="expand"id="reveal-host"></tool-host></div>
A column says whether it holds prose or a short token with wrap, and rowHeader makes one column each row's name. On a narrow tool the table keeps its columns and scrolls: tokens stay on one line, sentences get a reading width, the row's name stays in view, and a shadow shows there is more. Shown at 390px.
{}What run() returns
{"kind": "table","caption": "Six instances, slowest p99 first","columns": [ {"label": "Instance","wrap": "none","rowHeader": true }, {"label": "Requests","align": "end","mono": true,"wrap": "none" }, {"label": "p50 ms","align": "end","mono": true,"wrap": "none" }, {"label": "p99 ms","align": "end","mono": true,"wrap": "none" }, {"label": "Status","wrap": "none" }, {"label": "Note","wrap": "prose" } ],"rows": [ ["host-4","24,954","41", {"text": "1,450","tone": "bad" }, {"text": "Over SLO","tone": "bad" },"Tail is 35x the median; one slow disk on this host." ], ["host-1","18,301","38", {"text": "612","tone": "warn" }, {"text": "Near SLO","tone": "warn" },"Within budget, but rising since the last deploy." ], ["host-2","17,880","37", {"text": "288","tone": "normal" }, {"text": "OK","tone": "good" }, {"text": "INC-2041","wrap": "none" } ], ["host-3","17,402","39", {"text": "270","tone": "normal" }, {"text": "OK","tone": "good" }, {"text": "Routine.","tone": "muted" } ], ["host-5","9,115","36", {"text": "241","tone": "normal" }, {"text": "OK","tone": "good" },"Half the traffic: drained for maintenance at 14:00." ], ["host-6","212","35", {"text": "n/a","tone": "muted" }, {"text": "Too few","tone": "muted" },"Fewer than 1,000 requests, so p99 is not estimated." ] ]}
Each input's row is named by data-input, data-type and the tool's role, and the controls read four properties of their own. Set one on a row and only that input changes.
{}bench/fixtures/input-help/tool.json, one input
{"id": "slo","type": "number","label": "p99 target","description": "The p99 the service promises.","details": "Compared with the p99 of what you paste, by nearest rank: the smallest value with at least 99% of them at or below it.\n- A p99 equal to the target is a {bad|miss} only when the toggle below is on.\n- With fewer than 100 values, the p99 is the {warn|largest} one.\n\n{muted|A percentile of a few values says little.}","default": 300,"min": 1,"max": 60000,"role": "threshold"}
TSsite/src/gallery-host.ts
// One input by role and one by id: a property set on a row reaches only that row's control.exportconst INPUT_STYLES = /* css */`.tb-field-row[data-role="threshold"] { --tb-input-border: var(--tb-accent); --tb-input-bg: var(--tb-accent-bg); }.tb-field-row[data-input="values"] { --tb-desc-fg: var(--tb-muted); }.tb-field-row[data-role="advanced"] { padding-top: 0.625rem; border-top: 1px dashed var(--tb-border); }`;
The link icon copies this page's address with the tool's inputs in it, and a tool opens with the inputs a link carries. An input marked share: false, the token here, is never put in a link or read from one.
{}bench/fixtures/share-private/tool.json, one input
{"id": "token","type": "text","label": "Token","description": "Never put in a link.","default": "","maxLength": 64,"dir": "ltr","share": false}
tool-input fires after the inputs change and tool-run after a run's result is drawn, for a deep link, analytics or a button of your own. Edit the form or press Run.
No events yet.
TSsite/src/gallery.ts
const log = document.querySelector("#events-log");for (consttypeof ["tool-input", "tool-run"]) {// Both bubble out of the shadow root, so the listener could as well be on `document`. document.querySelector("#events-host")?.addEventListener(type, (event) => {const { detail } = event as CustomEvent<Record<string, unknown>>;if (log) log.textContent = `${type} ${JSON.stringify(detail, null, 1).replace(/\s*\n\s*/g, " ")}`; });}