Your AI app, live as you type. Declare widgets, write on(event, handler) functions — done. The
same demo.tsx runs entirely in the browser (mount) or on a stateless Node server (serve); change
the last line to swap. The browser owns all state — the server keeps none, so there are no sessions.
knobkit.dev — 30-second tour + a live playground (nothing to install).
🛠️ Building with an AI agent? The knobkit-skills Agent Skill is the recommended way to scaffold and build a knobkit app fast — works in Claude Code or any Agent Skills–compatible agent.
import { knobkit, mic, output } from "knobkit";
import { pipeline } from "@huggingface/transformers";
const transcriber = await pipeline("automatic-speech-recognition", "onnx-community/whisper-base.en");
const recorder = mic();
const transcript = output();
const app = knobkit({ title: "Transcribe", widgets: [recorder, transcript] });
app.on(recorder.clip, async (samples) => {
const { text } = (await transcriber(samples)) as { text: string };
transcript.set(text.trim() || "(silence)");
});
app.serve(); // runs Whisper on Node — change to app.mount("#root") to run it in the browser via WebGPUSee examples/ — chatbots, image captioning,
live transcription, webcam filters; each a single demo.tsx.
npm create knobkit@latest my-app # prompts mount (browser) vs serve (node); or pass --mount / --serve
cd my-app && npm install && npm run devAlready have a project? npm install knobkit. Requires Node ≥ 22.
knobkit dev # dev server — auto-detects the tier from mount()/serve() in the entry
knobkit build # build a mount app to static files in dist/
knobkit serve # run a serve app
knobkit playground # split-pane REPL: editor + live preview, file picker, edits round-trip to diskEntry = your package.json "main" (override with knobkit dev other.tsx). --mount / --serve force
the tier; --port <n> sets the port (playground default 4317).
A handler is a plain on(event, async fn). Inside it you do exactly three things:
-
read widget state with async getters —
await box.value(),await convo.history()(a real round-trip on serve); -
write with structured setters —
out.set(v),convo.say(m),log.push(line); -
produce by
returning an event from the handler (re-emitted, like a user action).
setup(fn) runs once per session for async startup (load weights, fetch data). widget.busy(fn) wraps
a handler in a transient working span (a bar; drops the widget's input while running); disable() /
enable() is the persistent version. Widget methods only work inside a handler or setup.
mount("#root") |
serve() |
|
|---|---|---|
on(...) handlers |
run in the browser | run on a stateless Node server |
| transport | local call | socket.io |
| use when | fits client-side (incl. WebGPU models) | needs the server (large models, secrets, native deps) |
mount builds to static files you can host anywhere; serve adds no session state. Widgets, handlers,
and methods are identical across both — only the last line changes.
Value inputs all share one shape: a changed event whose payload is the value, plus
await w.value() and w.set(v). (No .submitted/.uploaded — listen on changed, or use a
button's .clicked and read await input.value().)
| Factory (defaults) |
changed value |
Notes |
|---|---|---|
text({ placeholder?, lines? }) |
string |
lines = textarea rows (default 1) |
number({ value?, min?, max? }) |
number |
numeric stepper (init 0) |
slider({ value?, min?, max?, step? }) |
number |
min 0, max 100, step 1 |
dropdown({ choices, value? }) |
string |
choices: string[]; value defaults to choices[0]
|
checkbox({ label?, value? }) |
boolean |
single toggle |
checkboxGroup({ choices, value? }) |
string[] |
multi-select |
radio({ choices, value? }) |
string |
single-select |
upload({ accept? }) |
string | null |
value is a data URL; accept default image/*
|
Other inputs:
| Factory (defaults) | Events | Methods |
|---|---|---|
button({ label }) |
clicked |
set({ label }) |
mic({ every?, control?, hold? }) |
clip (Float32Array), toggled
|
start(), stop(), await toggle(), await live(). every ms emits a clip every N ms (0 = hold/toggle only) |
webcam({ every?, control?, preview?, facing? }) |
frame (data URL), toggled
|
same controls. every ms emits a frame every N ms (0 = preview only); facing "user"/"environment"
|
chat({ placeholder?, voice?, images?, markdown? }) |
sent ({ text, image? }), recorded
|
await history(), say(msg), append(token). markdown renders assistant replies; images/voice add attach/talk buttons |
Outputs (write-only; set(...) replaces the value):
| Factory (defaults) | Write / methods | Notes |
|---|---|---|
output({ format? }) |
set(text) |
format: "markdown" renders GFM |
json() |
set(value) |
pretty-printed JSON |
log() |
push(line), await all()
|
append-only lines |
label() |
set(string | { label?, confidences? }) |
classifier result; confidences: { label, score }[] → bars |
html({ value? }) |
set(markup) |
raw HTML |
image() |
set(urlOrDataUrl) |
one image |
gallery() |
set(items), add(item)
|
item: { src, caption? } |
audio({ autoplay? }) / video({ autoplay?, loop? })
|
set(src) |
URL or data URL |
progress({ label? }) |
set(value, label?) |
value is 0..1 |
file() |
set({ name?, url } | url) |
offer a download |
annotatedImage() |
set(src, annotations?, colorMap?) |
Annotation: { label, box?: [x0,y0,x1,y1], mask? } |
highlightedText() |
set(spans, colorMap?) |
span: { text, label? } (label omitted = plain) |
chart({ x, y, kind?, data?, maxHeight? }) |
await data(), setData(rows), push(point)
|
x = category key; y = key or string[]; kind bar/line/area |
frame({ src?, doc?, sandbox?, title? }) |
load(url), show(doc), clear()
|
iframe; event loaded
|
Editable or read-only:
| Factory (defaults) | Events | Methods |
|---|---|---|
code({ value?, language?, editable?, wrap? }) |
changed (string) |
await value(), set(src), setLanguage(lang). editable: false = viewer; wrap soft-wraps |
table({ columns?, rows?, editable?, maxHeight? }) |
edited ({ row, key, value }) |
await data(), setRows, setColumns, addRow, setCell. Column: { key, label?, type?, width? }
|
Custom: widget({ state, view, fold?, behavior? }) builds a widget from scratch — state is its
data, view(state, emit) renders it, fold applies events to state.
widgets is a tree of widget objects (no keys/strings). An array is an implicit col:
knobkit({ widgets: col(photo, row(size, go), caption) });
grid([a, b, c, d], { cols: 2 });
tabs([{ label: "One", content: a }, { label: "Two", content: b }]);
accordion({ label: "Advanced", open: false }, x, y);Containers are widgets whose state is their arrangement, so a handler can restructure the UI at
runtime — panel.add(chart), await panel.remove(sidebar).
Set on knobkit({ … }), or flip at runtime with setTheme / setDensity:
-
theme—"system"(default) |"light"|"dark". -
density—"xs" | "sm" | "md" | "lg" | "xl"(defaultmd) — spacing, control sizes, radii, type. -
fill: true— full-bleed shell that fills the viewport (for split panes / dashboards) instead of the centered card.
Everything renders from CSS custom properties (--pu-bg, --pu-accent, --pu-gap, the --pu-series-*
chart palette, …); theme/density just remap them, so one switch restyles the whole kit (including the
code editor, table, and chart). The attributes inherit, so you can scope them to one container; to
rebrand, override the tokens in your CSS (e.g. :root { --pu-accent: rebeccapurple }).
pnpm install
pnpm -F knobkit build # library + browser client bundle
pnpm -F knobkit test # vitest
pnpm typecheck # all packagesSee CLAUDE.md for the architecture and how to add a widget.
