Today Canvas Kit
SDK packages

@todayai-labs/tck-preview

react-email-style dev shell for widgets. Boots Vite, auto-discovers widgets, renders them with theme/size/state controls.

A dev-only preview shell, modelled on react-email. Auto-discovers widgets in widgets/*/widget.tck.tsx, mounts each in a real TodayHost, and gives you theme + size + state controls without writing any host wiring yourself.

tck-preview is what pnpm dev invokes inside a scaffold produced by create-widget. The preview-side iframe runs each widget against the same WidgetLoader + import-map machinery production hosts use — tck-preview is not a sandbox toy, it's the production contract running locally.

CLI

# Boot the preview shell on the current directory
pnpm exec tck-preview

# Open an existing batch directly in Canvas Feed
pnpm dlx --package @todayai-labs/tck-preview tck-preview --feed ./dist --open
pnpm dlx --package @todayai-labs/tck-preview tck-preview --feed './eval/widget-batch/dist/*.tckb' --feed-label 'Eval run'
pnpm dlx --package @todayai-labs/tck-preview tck-preview \
  --feed './one/dist/*.tckb' --feed-label 'One' \
  --feed './one/dist/*.tckb,./two/dist/*.tckb' --feed-label 'One + Two'
pnpm dlx --package @todayai-labs/tck-preview tck-preview \
  --feed './eval/widget-batch/dist/*.tckb' \
  --feed-label 'Eval run' \
  --feed-notes $'ran_at=2026-07-09T14:00Z\nmodel=gpt-5\nrounds=18'

npx @todayai-labs/tck-preview --feed ./dist --open
npx @todayai-labs/tck-preview --feed './eval/widget-batch/dist/*.tckb' --feed-label 'Eval run'

# Or programmatically
import { startPreview } from '@todayai-labs/tck-preview'
const handle = await startPreview({
  cwd: process.cwd(),
  feedSources: [
    {
      glob: '/abs/batch-one/dist/*.tckb',
      group: 'single',
      id: 'batch-one',
      notes: 'ran_at=2026-07-09T14:00Z\nmodel=gpt-5\nrounds=18',
    },
    {
      globs: ['/abs/batch-one/dist/*.tckb', '/abs/batch-two/dist/*.tckb'],
      group: 'single',
      id: 'combined',
      notes: 'Combined comparison batch',
    },
  ],
})

For batch-only previews, --feed is enough. Use --cwd only when the preview also needs a specific widget project root for interactive widgets or config discovery. Repeat --feed to preview multiple batches in one server and switch between them in the Canvas Feed sidebar. Use a comma-separated --feed value to merge multiple directories/globs into one batch view. Use --feed-notes to attach developer notes to each batch, such as run time, environment, user, parameters, model, or iteration count. Notes render only on the Canvas surface, above the Actions panel in the right rail.

CLI flags:

FlagDefaultDescription
--port5173Preferred dev-server port; Vite uses the next free port if occupied.
--hostlocalhostBind address. Pass 0.0.0.0 to expose on LAN.
--openfalseOpen the default browser on start. With --feed, opens Canvas Feed.
--cwdcwdProject directory used for config discovery and workspace widgets.
--configautoExplicit tck.preview.config.{ts,mts,js,mjs} path.
--feedOne-off Canvas Feed source from a directory, .tckb glob, or comma-separated set of directories/globs. Repeat for multiple Sidebar entries.
--feed-labelbasenameLabel for a --feed Canvas entry. Repeat in the same order as --feed.
--feed-notesDeveloper notes for a --feed Canvas entry. Repeat in the same order as --feed.

Config model

tck.preview.config.ts has two orthogonal sections — they map 1:1 to the shell's two views. Everything is optional; zero-config Just Works on the implicit defaults.

import { defineConfig } from '@todayai-labs/tck-preview'

export default defineConfig({
  scan: {
    bundled: [
      '../../widgets/*/dist/*.tckb',
      {
        glob: '../../widgets/interactive-widgets-template-v1/dist/*.tckb',
        label: 'Interactive Widgets Template V1 (Latest)',
      },
      {
        glob: '../../widgets/daily-brief-template-v1/dist/*.tckb',
        label: 'Daily Brief Template V1',
      },
      {
        glob: '../../widgets/daily-brief-template-v2/dist/*.tckb',
        label: 'Daily Brief Template V2',
      },
      {
        glob: '../../widgets/daily-brief-template-v3/dist/*.tckb',
        label: 'Daily Brief Template V3',
      },
      {
        glob: '../../widgets/daily-brief-template-v4/dist/*.tckb',
        label: 'Daily Brief Template V4',
      },
      {
        glob: '../../widgets/daily-brief-template-v5/dist/*.tckb',
        label: 'Daily Brief Template V5',
      },
      {
        glob: '../../widgets/daily-brief-template-v5-minimal/dist/*.tckb',
        label: 'Daily Brief Template V5 Minimal',
      },
      {
        glob: '../../widgets/daily-brief-template-v6-minimal/dist/*.tckb',
        label: 'Daily Brief Template V6 Minimal (Latest)',
      },
      {
        glob: '../../widgets/pinned-feeds-template-v1/dist/*.tckb',
        label: 'Pinned Feeds Template V1',
      },
    ],
  },
  canvas: {
    feeds: [
      { glob: '/abs/eval/*/widget-batch/dist/*.tckb', group: 'dir' },
      {
        globs: ['/abs/one/dist/*.tckb', '/abs/two/dist/*.tckb'],
        group: 'single',
        id: 'one-two',
        label: 'One + Two',
        notes: 'env=staging\nmodel=gpt-5\nrounds=18',
      },
    ],
    grids: [
      {
        glob: './home/dist/*.tckb',
        group: 'single',
        id: 'home',
        notes: (key) => `live_widget_batch=${key}`,
      },
    ],
  },
})

scan — single-widget inspection (Stage view)

Feeds the Stage picker. Two artifact kinds, each with an implicit default you never restate:

KindImplicit defaultExtend with
live source<cwd>/widgets/*/widget.tck.tsx (HMR)(not extendable — see below)
bundles<cwd>/widgets/*/dist/*.tckb + <cwd>/dist/*.tckbscan.bundled

There is intentionally no scan.unbundled: custom out-of-tree live-source scanning is unsupported. Rendering a .tck.tsx from outside the project can't guarantee a single React / @todayai-labs/tck instance (no resolve.dedupe anchor), and the ambient Tailwind content scan only covers <cwd>/widgets/** — so such a widget would mount with a duplicate React or no utility CSS. The supported path for "preview source from elsewhere" is to build it and let scan.bundled ingest the self-contained .tckb (it ships its own CSS and externalizes React).

scan.bundled accepts raw glob strings or named entries:

type ScanBundledEntry = string | { glob: string; label?: string }

A raw string renders in the Stage picker's Bundled tab under Custom location. A named entry uses label as the Bundled-tab section title, so several external widget projects can stay visually separated while sharing the same picker UI.

canvas — composition (Canvas view)

Feeds the Canvas view's Grid and Feed tabs. Each entry is a CompositionSource: its glob or globs are scanned + watched, and its bundles also surface in the Stage picker (under a canvas:* group) so you can always drill into one card. With no canvas.feeds / canvas.grids, the shell creates default Feed (all bundled) and Grid (all bundled) views that automatically list every ingested .tckb in the matching manifest.cardType family.

interface CompositionSource {
  glob?: string // .tckb glob (resolved vs config dir)
  globs?: string[] // several globs merged into this source; mutually exclusive with glob
  group?:
    | 'dir'
    | 'single' // 'dir' (default) | 'single'
    | ((b: FeedBundleInfo) => string | null) // …or a custom key fn
  id?: string // feed/grid id (used by 'single')
  label?: string | ((key: string) => string) // tab-list label; default = key
  order?: number | ((key: string) => number) // tab-list order; default = numeric-aware on key
  notes?: string | ((key: string) => string | undefined) // developer notes for the right rail
}

interface FeedBundleInfo {
  path?: string // absolute on-disk path
  relPath?: string // path under the glob's static root, e.g. run-3/dist/001_x.tckb
  label: string // .tckb basename (carries the NNN_ prefix)
  manifest: WidgetManifest
}
  • group: 'dir' (default) — one feed/grid per matched directory (the relPath segment before the first /). A flat glob with no directory wildcard collapses to a single feed.
  • group: 'single' — the whole glob, or the whole globs set, is one feed/grid.
  • group: (bundle) => key — custom; bundles sharing a returned key stack together. null drops the bundle into the catch-all Loose feed (nothing is silently lost).

The same bundle may appear in multiple configured views, so you can keep individual feeds (One, Two) alongside combined feeds (One + Two, All) without copying .tckb files.

notes attaches developer-only context to a resolved Canvas batch. Use it for run timestamps, environment, user, input parameters, model, iteration count, or any other free-form details that help interpret the batch. String notes apply to every view produced by the source; a function can return per-key notes for group: 'dir' or custom grouping. Notes are rendered only on the Canvas surface, above the Actions panel in the right rail. The host settings panel remains fixed; the Notes header can be clicked to collapse its body. The Actions panel is collapsible in both Stage and Canvas.

Grid vs Feed is decided by manifest.cardType, not by config — mirroring how the production host composes a page (<TodayFeedStack> takes cardType: 'feed', <TodayWidgetCanvas> takes the rest). The feeds / grids arrays only declare which composition family + grouping a source belongs to; a bundle whose cardType doesn't match its declared family is skipped (a fill-auto feed card can't be laid out in the fixed-footprint grid).

The Grid tab is a four-column fixed-footprint canvas. A two-column card is placed only on a half-grid boundary: columns 0-2 or 2-4, never columns 1-3.

The group / label / order functions are Node closures, so they run server-side; the browser only receives the resolved feed index.

The Canvas Feed sidebar also accepts a dropped set of .tckb files. The shell uploads each bundle, then registers those bundle ids as a temporary in-memory feed batch. The batch exists only for the current dev-server process; it does not mutate tck.preview.config.ts, the scanned watch groups, or the source files.

Canvas navigation is URL-addressable: view=feed opens the Canvas surface, canvasTab=feed|grid chooses the sidebar tab, and canvasView=<id> selects the feed/grid view. The shell keeps those parameters in sync as you click, so copying the current URL reopens the same batch.

Host-global controls can also ride the URL:

ParamValuesScope
themelight, darkShell + widget theme
backgroundgrid, skyStage / Canvas background family
feedWidthnarrow, medium, widefill-auto feed-card column width
mountinline, iframeStage widget mount strategy

If a global parameter is absent, the shell uses its normal default or stored preference. If a parameter is present, it wins on load; once you change that control in the UI, the URL is updated with the selected value.

Escape hatch — viteFinal

Storybook-style last-chance hook. Receives the fully-built InlineConfig (plugins wired, port resolved, server.fs.allow set) and returns the config tck-preview actually starts the server with. Use it for one-line tweaks the SDK doesn't expose directly:

import { defineConfig } from '@todayai-labs/tck-preview'

export default defineConfig({
  // Allow a Cloudflare/ngrok tunnel to reach the dev server.
  viteFinal: (config) => ({
    ...config,
    server: { ...config.server, allowedHosts: ['.trycloudflare.com'] },
  }),
})

Mutate-and-return is also allowed. Async hooks are fine. Programmatic startPreview({ viteFinal }) wins over the config-file value when both are present, matching the precedence of every other field.

Deprecated config fields

watchGlobs and bundledLocations are folded into scan.bundled (with a one-time warning). unbundledLocations is removed (out-of-tree live source — see above); it warns and is ignored. All three are slated for removal in the next major.

Data model & dev protocol

Like CDP, the preview server exposes a small, stable data model over a set of /__tck/* endpoints. The shell is a pure client of it; anything that speaks HTTP can drive the same surface.

Entities

EntityIdentityNotes
Bundleid = server idPathless uploads use bundleHash as id; disk-watched bundles may add a path scope so identical bytes from different files can coexist. Carries bundleHash, manifest, source, label, path?, relPath?.
Feed / Gridid (feed/grid key)A composed view over bundles: an ordered list of bundle ids plus optional developer notes. Derived from canvas.* + the bundle set; in-memory local feed batches are stored only for the current dev-server process.
WatchGroupsource labelA { source, glob } the server scans + fs-watches; the bundle's source is the group it came from.

A Bundle is the unit of content; a Feed/Grid is a composition over bundles; cardType (on the manifest) decides which composition family a bundle is eligible for.

interface PublicBundle {
  id: string
  bundleHash: string // sha384(widget.mjs), base64url (64 chars)
  manifest: WidgetManifest
  source: string // watch-group label, or 'local' / 'remote'
  label: string // .tckb basename
  path?: string // absolute on-disk path (disk-watched only)
  relPath?: string // path under the watch glob's static root
  bundleUrl: string // /__tck/bundle/<id>/widget.mjs
  cssUrl?: string
  propertiesCssUrl?: string
  loadedAt: number
}

interface FeedIndex {
  feeds: { id: string; label: string; notes?: string; bundleIds: string[] }[]
  grids: { id: string; label: string; notes?: string; bundleIds: string[] }[]
}

HTTP / dev protocol

Method + pathBody / paramsReturns
GET /__tck/bundles{ bundles: PublicBundle[] }
GET /__tck/feedsFeedIndex (resolved per the canvas.* grouping, recomputed each request)
POST /__tck/bundle/uploadraw .tckb bytes; x-tck-label headerPublicBundle (source: 'local')
POST /__tck/feed-batches{ id, label, bundles: { id, label }[] }A temporary Feed view registered from already-uploaded local bundle ids; filters to cardType: "feed" and orders cards by the supplied .tckb labels.
POST /__tck/bundle/fetch{ url }PublicBundle (source: 'remote'; server-side fetch avoids CORS)
POST /__tck/rescan{ ok, before, after, removed } — force a fresh disk scan of every watchGlobs group; reconciles both adds and deletions (see Live updates).
DELETE /__tck/bundle/<id>.tckb{ deleted: boolean }
GET /__tck/bundle/<id>/widget.mjsthe unpacked ESM, routed through Vite's transform pipeline (bare specifiers resolved like workspace source)
GET /__tck/bundle/<id>/widget.csstext/css (adopted into the widget's shadow root)
GET /__tck/bundle/<id>/widget.properties.csstext/css (@property registrations, injected into the document head)

Live updates (HMR)

Whenever the in-memory store mutates — an HTTP upload/fetch/delete, or a disk-watcher add/change/unlink — the server pushes a Vite custom WS event:

event: tck:bundles-changed   (empty payload)

The shell re-fetches GET /__tck/bundles and GET /__tck/feeds on receipt, so the picker, the Feed/Grid tabs, and the open feed all re-partition live as batches land on disk. A bundle whose .tckb is rebuilt gets a new content hash → a new id → a fresh mount.

macOS deep-nested caveat. Vite's chokidar uses fsevents on macOS, which is known to drop add events for rapid mkdir+write sequences against a freshly-created nested directory tree (e.g. an eval driver that writes to mkdtemp/<id>/<date>/<kind>/widget-batch/dist/*.tckb — every intermediate dir is brand new). Files present at server boot are caught by the initial tinyglobby scan; files written into newly-minted nested dirs afterward may not be. The Canvas Feed/Grid tabs' refresh button hits POST /__tck/rescan (force a fresh tinyglobby walk), and the server broadcasts tck:bundles-changed after the rescan, so the UI re-hydrates. store.ingest is idempotent on store id, so a re-add is a cheap no-op when nothing changed; the rescan also reconciles deletions, pruning any disk-watched entry whose .tckb has since been removed (the same fsevents miss can drop an unlink), so a stale bundle can't linger in the list.

Programmatic surface

ExportWhat it does
startPreview(options): Promise<PreviewHandle>Boot the Vite server + preview shell. Returns a handle with close() and the resolved URL.
defineConfig(config)Type-safe config helper for tck.preview.config.ts.
PreviewHandle, PreviewOptions, PreviewConfigTypes for the boot API.
ScanBundledEntry, ScanBundledSourceTypes for scan.bundled raw glob and named glob entries.
CompositionSource, FeedBundleInfoTypes for canvas.feeds / canvas.grids entries + the grouping function.

Source

On this page