DuckyTools
HomeSDK

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, stats and form
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.

kindsignatureinputheadlesswhat it is for
textfn(input: string, opts: object) => ResultA textarea.YesText in, text out. The result updates as you type.
statsfn(input: string, opts: object) => ResultA textarea.YesAnalysis. Returns figures rather than replacement text.
formfn(opts: object) => ResultThe fields declared in `fields`.YesNamed fields in, a computed answer out. Calculators and converters.
imagefn({ img, o, canvas }) => HTMLCanvasElement | ResultAn image dropzone.NoPixel work. You are handed a decoded image and a canvas factory.
filesfn({ files, o, pdfLib, pdfJs }) => Result | Result[]A file dropzone.NoWhole files. PDFs, archives, batches. Heavy libraries load on demand.
custommount(root: HTMLElement, kit: Kit) => voidWhatever you render.NoYou 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 }.

typedef is aalso takes
textstringplaceholderA single-line text box.
textareastringplaceholderA multi-line text box.
numbernumbermin, max, step, unitA numeric input.
rangenumbermin (required), max (required), min, max, step, unitA slider. Shows its current value.
checkboxbooleanA toggle.
selectstringoptions (required)A dropdown.
colorstringA color picker. Values are `#rrggbb`.
datestringmin, maxA date picker. Values are `YYYY-MM-DD`.
timestringA time picker. Values are `HH:MM`.
blobacceptA 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

keytypewhat it does
slugRequiredstringURL segment. Lowercase, digits and hyphens only.
nameRequiredstringDisplay name, in title case.
catRequiredstringCategory id.
kindRequiredstringOne of the six kinds.
blurbRequiredstringOne sentence. Shown in listings and search.
kwRequiredstring[]What people would type to find it. Drives search and the "also known as" line.
fnunless customfunctionThe implementation. See the kind for its signature.
mountwhen customfunctionRenders the interface. Receives the root element and the kit.
optsOptionalfield[]Options shown beside the input. For every kind except `form`.
fieldsOptionalfield[]The inputs, for `form` tools.
aboutOptionalstringA paragraph explaining how it works or what it will not do.
sampleOptionalstringExample input, offered as a one-click fill.
actionOptionalstringLabel for the run button on `files` tools.
acceptOptionalstringAn `accept` attribute for the dropzone.
multipleOptionalbooleanWhether a `files` tool takes more than one file.
minFilesOptionalnumberRefuse to run below this many files.
placeholderOptionalstringPlaceholder for the main input.
outOptionalstringOutput language hint, for syntax-aware result panes.
unitsOptionalobjectUnit table for converters.
baseUnitOptionalstringThe unit every factor in `units` is relative to.
isFormulaBasedOptionalbooleanSet 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
textA string, shown in the output pane with a copy and download button.
resultsAn array of `{ label, value, big?, hint? }` shown as stat cards.
tableAn object of `{ cols: string[], rows: (string|number)[][] }`.
noteA message shown above the result.
noteKindOne of 'info', 'warn' or 'err'. Defaults to 'info'.
filenameSuggested name for the download.
swatchesAn array of `{ value, label? }` rendered as color chips.
bytesA Uint8Array, offered as a download. For `files` tools.
nameDownload name to accompany `bytes`.
htmlPre-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.