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 npm configured for the private @todayai-labs package registry before installing the SDK packages. Internal CI configures this with AWS CodeArtifact; for local development, ask the platform team for the current npm registry setup. Do not commit registry auth tokens to a project repository.
The 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.
Templates use the same runtime and bundle contract. Their tooling profile only controls whether the generated project includes the optional Playwright-based snapshot command.
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 interactive widget (stateful, interactive, fixed-size tile).
│ └── hello/widget.tck.tsx — starter feed card (read-only, time-snapshot, fill-auto).
├── README.md — setup, commands, and documentation entry points.
├── GUIDELINES_<TYPE>.md — template-specific content, lifecycle, and SDK guidance.
├── STYLE_GUIDE.md — optional, preset-specific visual direction.
└── .gitignoreDaily Brief projects include GUIDELINES_FEEDS.md, on-demand Feed projects include
GUIDELINES_ON_DEMAND_FEEDS.md, and pinned Feed projects include
GUIDELINES_PINNED_FEEDS.md. Interactive Widget projects include
GUIDELINES_INTERACTIVE_WIDGET.md; the mixed starter includes
GUIDELINES_STARTER_FEEDS.md and the Interactive Widget guidance. These files cover stable local
contracts, while task-specific authoring instructions come from the service. The current Daily
Brief, on-demand Feed, and pinned Feed presets use the minimal tooling profile; the starter and
interactive widget presets include snapshot tooling.
Dev loop
pnpm devBoots tck-preview on http://localhost:5173 by default; if that port is occupied, Vite uses the next free port. 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 { defineManifest, useFieldState, type JsonValue } from '@todayai-labs/tck'
import type { FC } from 'react'
interface State {
readonly count: number
}
export const manifest = defineManifest({
cardType: 'widget',
layoutEngine: {
type: 'grid',
supportedSizes: ['1x1', '2x1', '2x2'],
defaultSize: '1x1',
},
id: 'com.example.counter', // reverse-DNS; identifies this widget across hosts
name: 'Counter',
version: '0.1.0',
schemaVersion: 1,
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— define it withdefineManifest(...)and an explicitcardType.cardType: 'feed'selects the small stateless Feed profile;cardType: 'widget'exposes the full Interactive Widget state, layout, permission, and migration surface. The bundler validates it, applies defaults, and bakes the resolvedWidgetManifestinto the.tckb, which is what the host registry reads at load time. For live widgets,idis the globally unique stable identity used for persisted state and future edits. For feed cards,idis best-effort metadata: generated snapshots are not edited after creation, so hosts should rely on the built bundle hash for uniqueness. A stateful tile like this counter declares a gridlayoutEngineanddefaultState; a stateless feed card declareslayoutEngine: 'auto-height', omitsdefaultState, and may add a same-languagesummaryas the final manifest field.default— the React component the host renders inside its tile chrome.
Optional named exports:
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 points to the matching guidance file for its content,
lifecycle, manifest, layout, state, and SDK rules. A selected preset may also include
STYLE_GUIDE.md as its visual reference. Task-specific authoring instructions stay in the
service. The Interactive 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. - For interactive widgets,
state.defaultis JSON-serialisable andstate.versionreflects the current shape. - If
schemaVersionbumped, amigrationsarray goes forward from the previous version. - Interactive-widget
manifest.idis globally unique and stable; feed-card ids are not durable state keys. - 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. -
manifest.layoutEnginematches the placement intent — grid widgets use{ type: 'grid', supportedSizes, defaultSize }; feed cards currently use'auto-height'. - For a read-only snapshot, no
new Date()/setInterval/ polling at render time — bake values in at write time.
See also
- Interactive 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.