Build your own tool
A tool is a plain object with one function. You declare what it takes; the runtime builds the interface.
Every tool on this site is built from the contract on this page, and the validator documented here is the one the build runs over all of them. A definition @duckytools/sdk accepts is one the real runtime will mount — there is no second, laxer standard for tools you write.
- Package
npm install @duckytools/sdk- Dependencies
- None
- Runs in
- Node 20+ and every current browser
- Testable without a browser
- Yes, for
text,statsandform - Export format
- One self-contained HTML file, no build step
- License
- MIT
A complete tool
This is not an excerpt. It is everything needed for a working page — name, inputs, and the function that does the work.
import { defineTool } from '@duckytools/sdk';
export const slugify = defineTool({
slug: 'slugify',
name: 'Slug Generator',
cat: 'web',
kind: 'text',
blurb: 'Turn a title into a clean URL slug.',
kw: ['slug generator', 'url slug', 'permalink'],
opts: [
{ k: 'sep', label: 'Separator', type: 'select', def: '-',
options: [{ v: '-', t: 'Hyphen' }, { v: '_', t: 'Underscore' }] },
],
fn: (s, o) =>
s.toLowerCase().replace(/[^a-z0-9]+/g, o.sep).replace(/^-|-$/g, ''),
});
defineTool validates and returns the object, throwing on anything that would not mount. Use validateTool instead if you would rather have { ok, errors, warnings } than an exception.
The six kinds
The kind decides what interface you get and what your function is handed. Three of them are pure, which is what makes them testable in Node with a plain assertion.
| kind | signature | input | headless | what it is for |
|---|---|---|---|---|
text | fn(input: string, opts: object) => Result | A textarea. | Yes | Text in, text out. The result updates as you type. |
stats | fn(input: string, opts: object) => Result | A textarea. | Yes | Analysis. Returns figures rather than replacement text. |
form | fn(opts: object) => Result | The fields declared in `fields`. | Yes | Named fields in, a computed answer out. Calculators and converters. |
image | fn({ img, o, canvas }) => HTMLCanvasElement | Result | An image dropzone. | No | Pixel work. You are handed a decoded image and a canvas factory. |
files | fn({ files, o, pdfLib, pdfJs }) => Result | Result[] | A file dropzone. | No | Whole files. PDFs, archives, batches. Heavy libraries load on demand. |
custom | mount(root: HTMLElement, kit: Kit) => void | Whatever you render. | No | You build the interface. For interactive canvases and multi-step flows. |
Declaring inputs
A form tool puts its inputs in fields, because for a calculator the fields are the input. Every other kind uses opts for options shown beside the main input. Both take the same shape: { k, label, type, def }.
| type | def is a | also takes | |
|---|---|---|---|
text | string | placeholder | A single-line text box. |
textarea | string | placeholder | A multi-line text box. |
number | number | min, max, step, unit | A numeric input. |
range | number | min (required), max (required), min, max, step, unit | A slider. Shows its current value. |
checkbox | boolean | — | A toggle. |
select | string | options (required) | A dropdown. |
color | string | — | A color picker. Values are `#rrggbb`. |
date | string | min, max | A date picker. Values are `YYYY-MM-DD`. |
time | string | — | A time picker. Values are `HH:MM`. |
blob | — | accept | A secondary file input, for tools that need a second file. |
The validator checks that a default is actually reachable — inside its own min and max, and present among a select's options. A default outside its own control is a bug that only appears when somebody resets the form, which is a bad time to find it.
Every key a tool may declare
| key | type | what it does | |
|---|---|---|---|
slug | Required | string | URL segment. Lowercase, digits and hyphens only. |
name | Required | string | Display name, in title case. |
cat | Required | string | Category id. |
kind | Required | string | One of the six kinds. |
blurb | Required | string | One sentence. Shown in listings and search. |
kw | Required | string[] | What people would type to find it. Drives search and the "also known as" line. |
fn | unless custom | function | The implementation. See the kind for its signature. |
mount | when custom | function | Renders the interface. Receives the root element and the kit. |
opts | Optional | field[] | Options shown beside the input. For every kind except `form`. |
fields | Optional | field[] | The inputs, for `form` tools. |
about | Optional | string | A paragraph explaining how it works or what it will not do. |
sample | Optional | string | Example input, offered as a one-click fill. |
action | Optional | string | Label for the run button on `files` tools. |
accept | Optional | string | An `accept` attribute for the dropzone. |
multiple | Optional | boolean | Whether a `files` tool takes more than one file. |
minFiles | Optional | number | Refuse to run below this many files. |
placeholder | Optional | string | Placeholder for the main input. |
out | Optional | string | Output language hint, for syntax-aware result panes. |
units | Optional | object | Unit table for converters. |
baseUnit | Optional | string | The unit every factor in `units` is relative to. |
isFormulaBased | Optional | boolean | Set when conversion is not a simple ratio — temperature, for instance. |
What your function returns
Any combination of these. Anything not listed is ignored rather than an error, so a result can carry extra data of your own.
| key | |
|---|---|
text | A string, shown in the output pane with a copy and download button. |
results | An array of `{ label, value, big?, hint? }` shown as stat cards. |
table | An object of `{ cols: string[], rows: (string|number)[][] }`. |
note | A message shown above the result. |
noteKind | One of 'info', 'warn' or 'err'. Defaults to 'info'. |
filename | Suggested name for the download. |
swatches | An array of `{ value, label? }` rendered as color chips. |
bytes | A Uint8Array, offered as a download. For `files` tools. |
name | Download name to accompany `bytes`. |
html | Pre-escaped markup. Only use it when you have escaped the input yourself. |
Two shorthands: a bare string means { text }, and a bare array means { results }. normalizeResult expands both if you would rather handle one shape.
Testing without a browser
import { runTool } from '@duckytools/sdk';
import assert from 'node:assert';
assert.equal(runTool(slugify, 'Hello, World!').text, 'hello-world');
assert.equal(runTool(slugify, 'A B C', { sep: '_' }).text, 'a_b_c');
runTool fills in every declared default first, so you only pass what you are varying. It refuses image, files and custom tools with an explanation rather than a confusing crash — those genuinely need a browser.
Export as one HTML file
import { toStandaloneHtml } from '@duckytools/sdk';
writeFileSync('slugify.html', toStandaloneHtml(slugify));
One file. No build step, no network, no dependencies — open it from a disk, email it, or drop it on any static host.
There is one real caveat, and the SDK enforces it rather than leaving you to find it. fn.toString() gives you the function's source, not its closure, so a tool whose body calls a helper defined beside it would serialize happily and then fail in the exported page. Export runs the function in an isolated scope first and turns that into a proper error:
Cannot export "kebab-case-converter": its `fn` calls `perLine`, which is
defined outside the function and so is not carried over. Either inline it,
or pass it in: toStandaloneHtml(tool, { deps: { perLine } }).
Standalone export covers the three pure kinds. The other three need parts of the full runtime that will not fit in a single file worth having.
The rule
Everything runs on the visitor's device. There is no upload step and no server, which is the entire point — a tool built this way cannot leak a file, because there is nowhere for the file to go.
If your idea needs a large model or a full media pipeline, it does not fit here. Saying so is better than shipping something that quietly uploads.