Today Canvas Kit

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 install

You'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.
└── .gitignore

The 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 dev

Boots 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 build

Bundles 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 Counter

Two exports, no exceptions:

  • manifest — define it with defineManifest(...) and an explicit cardType. 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 resolved WidgetManifest into the .tckb, which is what the host registry reads at load time. For live widgets, id is the globally unique stable identity used for persisted state and future edits. For feed cards, id is 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 grid layoutEngine and defaultState; a stateless feed card declares layoutEngine: 'auto-height', omits defaultState, and may add a same-language summary as the final manifest field.
  • default — the React component the host renders inside its tile chrome.

Optional named exports:

  • migrations — a forward-only Array<{ to: number; run(doc) }> the host runs when a persisted state's schemaVersion is behind the bundle's. Migration failures clear the doc back to defaultState.

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 fixed width or height).
  • No border-radius, border, or box-shadow on the outer container.
  • Background is intentional — transparent or theme-aware.
  • Every declared size actually renders well.
  • Theme branches via useTheme() or dark:, not prefers-color-scheme.
  • No localStorage / sessionStorage / IndexedDB / cookies anywhere.
  • No window.parent, no module-level side effects, no DOM queries outside the render tree.
  • Every useEffect cleans up (timers cleared, observers disconnected, fetches aborted).
  • Async work checks a mounted flag before setState.
  • For interactive widgets, state.default is JSON-serialisable and state.version reflects the current shape.
  • If schemaVersion bumped, a migrations array goes forward from the previous version.
  • Interactive-widget manifest.id is globally unique and stable; feed-card ids are not durable state keys.
  • Layout wrappers declare flex / grid (not browser-default block).
  • Tailwind classes are literal in source — no bg-${color}-500 runtime strings.
  • manifest.cardType matches the artifact's intent — 'widget' for stateful tiles, 'feed' for read-only snapshots.
  • manifest.layoutEngine matches 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

On this page