Write the UI in TypeScript on the server. Clay renders it in the browser as a React + ShadCN app. You do not write React for screens.
Inspired by NiceGUI: imperative ui.* calls, per-tab sessions, patches over WebSocket.
The usual way to run an app is the clay CLI — a file or a folder of pages, no Vite app of your own.
Docs: docs/README.md · Agents: llms.txt
clay hello.ts or clay ./pages --app is a running app. From there:
-
Pages and shell — routes, sidebar nav from
pageMeta, optional dashboard chrome (--app) -
Forms — inputs, validation,
ui.draftthat survives reconnect - Data — DataTable (sort, filter, grouping, remote paging), charts, DuckDB / ClickHouse / Kibana helpers
- Auth — signed cookies, roles, login limiter, audit log
-
Files and state — upload/download, clipboard,
ui.storage(tab / user / app) - Richer widgets — dialogs, sheets, kanban, gantt, flow diagrams, editor, markdown, AI visual primitives (no model runtime)
Public APIs are camelCase (onClick, bindValue, setText).
Prefer a default export. The CLI registers it as / and starts the server (opens the browser).
// hello.ts
import { ui } from '@close-by/clay';
export default function () {
let count = 0;
const label = ui.label(`Count: ${count}`);
ui.row(() => {
ui.button('-', {
onClick: () => {
count--;
label.setText(`Count: ${count}`);
},
});
ui.button('+', {
onClick: () => {
count++;
label.setText(`Count: ${count}`);
},
});
});
}bun add @close-by/clay-cli @close-by/clay
bunx clay hello.ts # http://localhost:3000A folder of pages (each file calls ui.page or exports default) plus a dashboard shell:
bunx clay ./pages --app --title "My App"| Flag | Meaning |
|---|---|
-p, --port |
Port (default 3000) |
-t, --title |
HTML / shell title |
--app |
Shell + nav from discovered pageMeta
|
--reload |
Restart on file changes |
--no-open |
Do not open the browser |
ui.page(...) without ui.run is the same idea — the CLI starts the server. ui.run() still works if you want to boot it yourself (library mode). More in Getting started.
A tasks list in six steps: input, refreshable list, then two pages with a sidebar. Full walkthrough: docs/tutorial.md.
1. Project
mkdir clay-tasks && cd clay-tasks
bun init -y
bun add @close-by/clay-cli @close-by/clay2. Page the CLI can run — default export becomes /.
// tasks.ts
import { ui } from '@close-by/clay';
export default function () {
ui.label('Tasks').classes('text-2xl font-semibold');
}3. Run it (leave --reload on while you edit):
bunx clay tasks.ts --reload --title Tasks4. Input — reactive + bindValue. 5. List — wrap changing rows in ui.refreshable and call .refresh() (not setText). 6. Shell — ui.page + pageMeta in a folder, then bunx clay ./pages --app --reload.
Copy-paste for steps 4–5:
// tasks.ts
import { ui, reactive } from '@close-by/clay';
type Task = { id: string; text: string; done: boolean };
export default function () {
const tasks: Task[] = [];
const draft = reactive({ text: '' });
let list: ReturnType<typeof ui.refreshable>;
ui.label('Tasks').classes('text-2xl font-semibold');
ui.row(() => {
ui.input({ placeholder: 'What needs doing?' }).classes('flex-1').bindValue(draft, 'text');
ui.button('Add', {
onClick: () => {
const text = draft.text.trim();
if (!text) return;
tasks.push({ id: String(Date.now()), text, done: false });
draft.text = '';
list.refresh();
},
});
}, { gap: 2 });
list = ui.refreshable(() => {
if (tasks.length === 0) {
ui.label('Nothing yet.').classes('text-sm text-muted-foreground');
return;
}
ui.column(() => {
for (const task of tasks) {
ui.row(() => {
ui.checkbox({
checked: task.done,
label: task.text,
onChange: (checked) => {
task.done = Boolean(checked);
list.refresh();
},
}).classes(task.done ? 'flex-1 line-through opacity-60' : 'flex-1');
ui.button('Delete', {
variant: 'ghost',
size: 'sm',
onClick: () => {
const i = tasks.indexOf(task);
if (i >= 0) tasks.splice(i, 1);
list.refresh();
},
});
}, { gap: 2 }).classes('items-center');
}
}, { gap: 2 });
});
}Two pages and nav: tutorial step 6.
Needs Bun 1.1+. Build the client once (copied into the workspace CLI), then use clay the same way:
bun install
bun run build:client
bun run clay hello.ts # http://localhost:3000
bun run clay ./pages --app # multi-page shell + navTo browse the catalog (counter, todo, dashboard, auth, charts, kanban, …):
bun run demo # http://localhost:4000
# or the same examples through the CLI:
bun run demo:cliclay hello.ts / clay ./pages --app
→ per-tab element tree on the server
→ WebSocket: hello / mount / patch / event
→ React client (shipped with the CLI) → ShadCN
You mutate elements (setText, refreshable, bindings). Clay sends patches. The client is a renderer, not your app. The CLI ships that client prebuilt — no Vite step after bun add.
| Guide | Description |
|---|---|
| Getting started | Install, CLI, first app |
| Sample app | Step-by-step tasks list |
| Concepts | Sessions, elements, refreshable, bindings |
| API reference |
ui.* and Element |
| Elements | Wire types and client mapping |
| Examples | Demo routes and patterns |
| Architecture | Packages and data flow |
| WebSocket protocol | Message formats |
| AI UI |
ui.ai.* primitives |
| DuckDB / ClickHouse / Kibana | Data clients |
| Package | Role |
|---|---|
@close-by/clay-cli |
clay binary — how you run a file or page directory |
@close-by/clay |
App-facing ui facade (what you import) |
@close-by/clay-core |
Element tree, session, reactive, protocol, storage |
@close-by/clay-components |
Element factories |
@close-by/clay-server |
Bun HTTP + WebSocket |
@close-by/clay-client |
React + ShadCN renderer (private; build is copied into the CLI) |
@close-by/clay-auth |
Password hash, login limiter, guards, audit |
@close-by/clay-compiler |
Optional compile-time reactive let
|
@close-by/clay-persistence-file / @close-by/clay-persistence-redis
|
Storage adapters |
@close-by/clay-duckdb / @close-by/clay-clickhouse / @close-by/clay-kibana
|
Data clients |
| Command | Description |
|---|---|
bun run build:client |
Build the React client (+ copy into @close-by/clay-cli) |
bun run clay … |
Workspace clay CLI |
bun run demo |
Demo server at :4000 |
bun run demo:cli |
Same examples via clay … --app
|
bun run dev |
Build client, then demo |
bun test |
Package tests |
bun run pack:publishable |
Pack runtime packages into dist-pack/
|
bun run publish:dry / publish:npm
|
Validate / publish (maintainers) |
Publish order and pack details: Getting started.
MIT. The root workspace is private; runtime packages (@close-by/clay-cli, @close-by/clay, …) are publishable. @close-by/clay-client stays private — its build ships inside @close-by/clay-cli.