Building widgets
Scaffold a TCK widget project, develop with HMR, and build a .tckb ready for any host.
This page walks through scaffolding a widget project, running the dev shell, building a .tckb, and shipping it. Total time from pnpm create to a working bundle: about a minute.
Scaffold a project
pnpm create @todayai-labs/widget my-widgets
cd my-widgets
pnpm installYou'll need a GitHub personal access token with read:packages scope to install the @todayai-labs/* SDK packages — they're published to GitHub Packages, not the public npm registry. Add to ~/.npmrc:
@todayai-labs:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=ghp_yourtokenThe scaffolder produces a self-contained multi-widget project. The SDK versions are pinned at scaffold time to match the version of create-widget you invoked, so a pnpm create @todayai-labs/widget@0.4.2 ships a project locked against the 0.4.2 SDK minor.
my-widgets/
├── package.json — pinned deps on @todayai-labs/{tck,tck-bundler,tck-preview} + react + ts.
├── pnpm-workspace.yaml
├── tsconfig.json
├── widgets/
│ ├── counter/widget.tck.tsx — starter live widget (stateful, interactive, fixed-size tile).
│ └── hello/widget.tck.tsx — starter feed card (read-only, time-snapshot, fill-auto).
├── README.md — toolchain + shared render environment.
├── GUIDELINES_LIVE_WIDGET.md — rules specific to live widgets.
├── GUIDELINES_FEED.md — rules specific to feed cards.
└── .gitignoreThe two starters represent the two business shapes a TCK widget takes; the architecture, SDK, and runtime contract are identical, only the conventions differ. See Live widget vs feed card for the comparison and pick the matching starter before authoring.
Dev loop
pnpm devBoots tck-preview on http://localhost:5173. The sidebar lists every widget under widgets/; pick one to render it inside a synthetic WidgetCtx with controls for theme (light/dark), size (1×1 → 4×4), and doc state (a live JSON editor against useFieldState writes). HMR routes through Vite's React Refresh, so editing a widget.tck.tsx re-renders the preview in place.
The shell itself lives inside @todayai-labs/tck-preview — the scaffold owns no vite.config.ts, no index.html, no preview source. Don't add them; they'd compete with the shell.
Build
pnpm buildBundles every widgets/*/widget.tck.tsx into dist/<name>.tckb (relative to the cwd). Under the hood this is tck-bundle --all; pass --widgets <dir> or --out-dir <dir> to override paths.
The output .tckb is the canonical wire format the Today host accepts. Drop the file URL into a host's bundle store, or upload to a content-hash registry — the bundle's URL-addressable id is its widget.mjs SHA-384.
Anatomy of a widget
The full counter source — every TCK widget has roughly this shape:
import { useFieldState, type JsonValue, type WidgetManifest } from '@todayai-labs/tck'
import type { FC } from 'react'
interface State {
readonly count: number
}
export const manifest: WidgetManifest = {
id: 'com.example.counter', // reverse-DNS; identifies this widget across hosts
name: 'Counter',
version: '0.1.0',
schemaVersion: 1,
sizes: ['1x1', '2x1', '2x2'],
defaultSize: '1x1',
defaultState: { count: 0 } satisfies State as unknown as JsonValue,
description: 'Click to count. Demonstrates persistent state via the doc store.',
}
const Counter: FC = () => {
const [count, setCount] = useFieldState<number>('/count', 0)
return (
<button
type='button'
onClick={() => setCount(count + 1)}
aria-label='Counter'
className='flex size-full cursor-pointer flex-col items-center justify-center gap-1 border-none bg-transparent p-4 font-[inherit]'
>
<span className='text-[11px] uppercase tracking-wider'>Counter</span>
<span className='text-4xl font-semibold leading-none tabular-nums'>{count}</span>
</button>
)
}
export default CounterTwo exports, no exceptions:
manifest— theWidgetManifestobject the bundler validates at build time and the host registry uses at load time.idis the stable identity;defaultStatemust be JSON-serialisable.default— the React component the host renders inside its tile chrome.
Optional named exports:
Expanded— used whenmanifest.expandable === true. The host rejects bundles that claimexpandablebut ship noExpanded.migrations— a forward-onlyArray<{ to: number; run(doc) }>the host runs when a persisted state'sschemaVersionis behind the bundle's. Migration failures clear the doc back todefaultState.
The widget contract
Three rules that bite if you forget them — the bundler doesn't catch them, the host's behaviour is what tells you something's off.
1. The host owns chrome and layout. Your outer container is always size-full plus the standard p-4 inset. No border-radius, no border, no box-shadow on the root — the host paints all of that. Doubled corner radii are the most common bug here.
2. The host decides theme. Read it via useTheme() or Tailwind's dark: variant. Don't reach for prefers-color-scheme — the host's theme is not always the OS's.
3. No browser storage. localStorage, sessionStorage, IndexedDB, document.cookie, BroadcastChannel — all forbidden. They're inaccessible in sandboxed iframes, partitioned per webview, and leak between instances. Persistence flows through useFieldState (or its sibling usePersistState) only.
The scaffolded project's README.md covers the shared render environment (chrome ownership, theme, browser-storage prohibition, no module-level side effects). The two shape-specific rulesets — GUIDELINES_LIVE_WIDGET.md and GUIDELINES_FEED.md — sit next to it. Read the README first, then the guideline file that matches what you're building. The Live widget vs feed card page is the authoring-time comparison; the full architectural ruleset lives in the widget guidelines.
Pre-flight checklist
Before bundling for production, sanity-check:
- Outer container is
size-full(no fixedwidthorheight). - No
border-radius,border, orbox-shadowon the outer container. - Background is intentional — transparent or theme-aware.
- Every declared
sizeactually renders well. - Theme branches via
useTheme()ordark:, notprefers-color-scheme. - No
localStorage/sessionStorage/IndexedDB/ cookies anywhere. - No
window.parent, no module-level side effects, no DOM queries outside the render tree. - Every
useEffectcleans up (timers cleared, observers disconnected, fetches aborted). - Async work checks a mounted flag before
setState. -
defaultStateis JSON-serialisable;schemaVersionreflects the current shape. - If
schemaVersionbumped, amigrationsarray goes forward from the previous version. - Layout wrappers declare
flex/grid(not browser-defaultblock). - Tailwind classes are literal in source — no
bg-${color}-500runtime strings. -
manifest.cardTypematches the artifact's intent —'widget'for stateful tiles,'feed'for read-only snapshots. - If
cardType: 'feed', nonew Date()/setInterval/ polling at render time — bake values in at write time.
See also
- Live widget vs feed card — the two business shapes a TCK widget takes, and the conventions that pick between them.
- Widget guidelines — the canonical widget-author rulebook.
- Widget lifecycle — source → bundle → mounted React tree.
- Platform ABI — the wire-level contract.
@todayai-labs/tck— the widget-author SDK surface.@todayai-labs/tck-preview— the dev shellpnpm devboots.@todayai-labs/tck-bundler— whatpnpm buildinvokes.