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.

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

Daily 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 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 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 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