knobkit
v0.0.7
Published
Your AI app, live as you type — declare widgets, write event handlers, done. The same file runs in the browser or on a stateless Node server.
Maintainers
Readme
knobkit
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.
Quick start
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.
CLI
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).
How it works
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.
Widgets
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.
Layout
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).
Theming
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 }).
Develop
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.
