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.
There are two authoring profiles. The default/full profile exposes the existing manifest and SDK for interactive widgets and advanced cases. --template daily-brief selects the minimal Feed profile: standard React with content written directly in JSX, plus defineManifest, Action, and mobile typography guidance in its generated docs. Both build the same bundle and use the same renderer.
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 — toolchain + shared render environment.
├── GUIDELINES_*.md — template-specific authoring guide files.
├── STYLE_GUIDE.md — optional, preset-specific visual direction.
└── .gitignoreThe default starter template includes both GUIDELINES_INTERACTIVE_WIDGET.md and
GUIDELINES_FEED.md because it ships one interactive widget and one feed card. Feed-only
templates, such as historical Daily Brief presets, ship only GUIDELINES_FEED.md; interactive-widget
templates ship only GUIDELINES_INTERACTIVE_WIDGET.md. See Interactive widget vs feed card for the comparison and pick the matching starter before authoring.
The current daily-brief preset also replaces the general README with a short Feed-only README and excludes dependencies its examples do not use.
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 covers the shared render environment (chrome ownership, theme, browser-storage prohibition, no module-level side effects). The template-specific guideline file sits next to it: GUIDELINES_FEED.md for feed-card templates, GUIDELINES_INTERACTIVE_WIDGET.md for interactive-widget templates, or both in the default starter. Read the README first, then the guideline file that matches what you're building. If the selected preset ships STYLE_GUIDE.md, read it next for that preset's visual language. 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.