Today Canvas Kit

Agent authoring

How an LLM agent writes and rebuilds TCK widgets — the scaffold contract, the bundler-error catalog, the hooks reference, and what the agent must not do.

A widget is a single .tck.tsx file inside a project. The authoring contract is: the bundler is the only authority. If pnpm build accepts the source, the widget is valid. If it rejects, the source has a typed error you must address.

This page is the agent-facing guide. It assumes you are an LLM running inside a sandbox (e.g. today-runtime's AgentCore image) or a human dev working from the same source. The contract is identical; only the surrounding orchestration differs.

For the human-dev quickstart see Building widgets. For the cross-repo orchestration that wraps agent authoring on cloud-side see cross-repo lifecycle.

Two scaffold paths

Cloud sandbox path (today-cloud V2)

The reference flow today-cloud's task-agent-service teaches its agents:

cd /workspace/.tasks/{taskId}/{YYYY-MM-DD}/{kind}/
pnpm create @todayai-labs/widget widget-batch
cd widget-batch
# author widgets/<NNN>_<slug>/widget.tck.tsx — manifest + default FC
pnpm build
# emits widget-batch/dist/<NNN>_<slug>.tckb per widget
  • <NNN> is a 3-digit zero-padded decimal (001999), agent-incremented per widget. This is the authoring-order contract — today-cloud's scanner reads order from this prefix, so the agent's authoring sequence survives unchanged through the wire into the user's feed.
  • <slug> matches ^[a-z][a-z0-9-]*$.
  • One widget per directory; one .tckb per widget after build.
  • Maximum 50 widgets per batch (cloud-side hard cap).

The sandbox image carries the toolchain (Node 24.14.1, pnpm 10.27.0) and pre-cached npm tarballs; the pnpm create resolves @todayai-labs/create-widget@latest against the registry. The pnpm shim auto-injects --config.minimum-release-age-exclude=@todayai-labs/* so freshly-published SDK patches land same-day.

Human dev path

pnpm create @todayai-labs/widget my-widgets
cd my-widgets
# author widgets/<name>/widget.tck.tsx
pnpm dev      # tck-preview at http://localhost:5173 — HMR
pnpm build    # emits dist/<name>.tckb when ready

No <NNN> prefix needed — the human dev path uses plain widgets/<name>/ and there is no batch ordering contract. The .tckb is bundle-named by directory.

Day-N flow (modify existing)

The agent has access to the source via three paths, in order of preference:

  1. Workspace path — for first-party widgets the source is directly under widgets/<name>/. Edit files in-place.
  2. Manifest source pointermanifest.source resolves to a git URL + entry path or an HTTP URL. Fetch via that pointer.
  3. Inline source — when nothing else is available, open the .tckb (it's a zip), read source/widget.tck.tsx, edit, re-bundle.

After editing:

  1. Bump manifest.version (semver). Patch for fixes, minor for additive features, major for breaking changes.
  2. If state shape changed, bump manifest.schemaVersion and add a migrations[] entry from the prior schema to the new one.
  3. Re-bundle. The bundler runs every static check, including the migration-chain check.
  4. Publish (push to registry) or re-mount (workbench).

The host's restoreWidget runs the migration ladder against any existing instance docs at the next mount.

What you can import

@todayai-labs/tck and its subpaths are the only TCK-side imports. The full whitelist (TCK_EXTERNAL_SPECIFIERS) is on the Platform ABI page:

react, react-dom, react-dom/client,
react/jsx-runtime, react/jsx-dev-runtime, scheduler,
@todayai-labs/tck,
@todayai-labs/tck/{manifest,patch,agent,hooks,runtime}

Anything else is bundled into your widget's widget.mjs bytes — lodash, lucide-react, framer-motion, your own helpers — and counts toward the bundle size budget (256 KB soft cap on widget.mjs).

Forbidden imports:

  • @todayai-labs/tck-host — host-only; widgets never import it.
  • Any apps/* package — host-side internal.
  • localStorage / sessionStorage / IndexedDB / cookie APIs — see persistence rules below.

Hooks reference

import {
  // doc state
  useFieldState, // sugar for /pointer paths
  usePersistState, // selector + setter; produces RFC 6902 patch

  // host context
  useWidgetCtx, // raw ctx; rarely needed directly
  useCurrentWidgetSize, // WidgetSize literal — re-renders on resize
  useVisibility, // boolean from IntersectionObserver
  useTheme, // 'light' | 'dark'

  // ambient host services
  useFocusTimer, // FocusTimerSnapshot | null — host-owned focus timer
  useMediaController, // MediaSnapshot | null — host-owned media controller

  // agent
  useAgent, // AgentChannel | null — null when permission absent
  useAgentSubscription, // subscribe to inbound envelopes
} from '@todayai-labs/tck/hooks'

useFieldState is the workhorse:

const [items, setItems] = useFieldState<TodoItem[]>('/items', [])
setItems(next) // → RFC 6902 patch { op: 'replace', path: '/items', value: next }

usePersistState is for non-JSON-pointer shapes:

const [items, setItems] = usePersistState<TodoItem[]>(
  (state) => (state as TodoState).items,
  (next) => [{ op: 'replace', path: '/items', value: next as JsonValue }],
)

Both run through the host's dispatcher: stale baseVersion → host auto-snaps the doc store; idempotent on patchId.

V2 widget constraints (cloud sandbox path)

When authoring inside today-cloud's V2 pipeline, widgets are stateless — the system prompt explicitly forbids:

  • useFieldState / usePersistState — no doc store on V2 feed cards.
  • migrations — no schema evolution path for stateless cards.
  • Expanded / manifest.expandable: true — no expand-to-modal flow yet.
  • manifest.schemaVersion > 1 — no shape evolution.
  • All browser storage (localStorage / sessionStorage / IndexedDB / cookie / BroadcastChannel).

This is the V2 MVP shape: agent writes data directly into JSX (or into defaultState as JSON) and the widget is a pure render. State-bearing widgets land in V3+.

Persistence

// ❌ Forbidden — sandbox-partitioned, inaccessible in native webview
localStorage.setItem('foo', 'bar')
sessionStorage.setItem('foo', 'bar')
indexedDB.open('...')
document.cookie = '...'

// ❌ Forbidden — won't survive a remount
const [x, setX] = useState(initial)

// ✅ Use the doc store (when V2 constraints don't apply)
const [x, setX] = useFieldState('/x', initial)

If a widget legitimately needs a per-widget key/value side store (a draft buffer, a remote-response cache), declare permissions: ['storage.local'] and use the storage hooks the host provides — they route to a per-widget partition the host manages. Direct localStorage is never correct.

Static-check error catalog

Every bundler failure has a stable error code. Match by code; don't parse free-form messages.

CodeMeaningHow to fix
tck/manifest-shapeThe manifest object failed schema validation (see validateManifest rules: id, version, sizes, etc.).Inspect the path + detail. The error names the offending field.
tck/manifest-id-formatmanifest.id is not reverse-DNS (e.g., com.example.thing).Choose a reverse-DNS id under a namespace you own.
tck/version-not-semvermanifest.version is not semver.Use MAJOR.MINOR.PATCH, optionally with a pre-release.
tck/size-not-declaredA size literal in defaultSize is missing from sizes.Add it to sizes or change defaultSize.
tck/migration-chain-brokenmigrations[] does not form a continuous from → to ladder ending at manifest.schemaVersion.Add the missing step or bump schemaVersion to match.
tck/external-not-allowedThe bundle imports a specifier outside TCK_EXTERNAL_SPECIFIERS.Drop the import — if it's a candidate for the host externals list, file a request, don't paper-over.
tck/permission-requiredSource uses useAgent but the manifest doesn't declare agent.read or agent.write.Add the permission or remove the usage.
tck/bundle-too-bigOutput exceeds --max-bytes (default 256 KB on widget.mjs).Trim deps or split into multiple widgets.
tck/no-default-exportThe compiled module is missing the React-component default export.Add export default <YourWidget>.
tck/no-manifest-exportThe compiled module is missing the named manifest export.Add export const manifest: WidgetManifest = { ... }.

When the bundler fails:

class BundleError extends Error {
  readonly code: string // e.g., 'tck/external-not-allowed'
  readonly path?: string // file:line of the offending node
  readonly detail?: Record<string, unknown>
}

Agent recovery: read code, decide which file + which transformation, reapply, re-bundle, repeat.

Manifest field reference

interface WidgetManifest {
  // identity
  id: string // reverse-DNS, e.g. "com.acme.foo"
  name: string // display name
  version: string // semver

  // versioning
  schemaVersion: number // monotonic int, bump on state-shape change

  // layout
  sizes: WidgetSize[] // see /docs/reference/container-contract
  defaultSize: WidgetSize // must be ∈ sizes

  // doc shape
  defaultState: JsonValue // must be JSON-serializable

  // permissions (declare BEFORE you use the corresponding hook)
  permissions?: WidgetPermission[] // 'agent.read' | 'agent.write' | 'storage.local'

  // card kind
  cardType?: 'widget' | 'feed' // see /docs/reference/feed-batch-contract

  // visual chrome opt-out (hello-tier only)
  chrome?: 'host' | 'opaque' // see /docs/reference/visual-container-contract

  // metadata
  description?: string // ≤ 280 chars
  icon?: string // URL or data URI, ≤ 32 KB
  accent?: string // optional #RRGGBB for the accent gradient

  // source addressability — bundler fills this from package.json + git
  source?: WidgetSource

  // runtime ABI label — the bundler emits this; usually 'v1'
  engines?: { abi: string }
}

What you must NOT do

  • Don't import @todayai-labs/tck-host or any apps/* package. The bundler rejects it; the host wouldn't be able to load your widget if it somehow leaked through.
  • Don't perform top-level side effects (console.log is OK in dev, but no window.foo = ..., no fetch in module body). Do work in useEffect / event handlers.
  • Don't share state across widget instances. The doc is your scope; cross-widget signaling goes through the agent bus, never through a shared module-level singleton. (Two mounts of the same widget DO share module-level state — see Platform ABI § runtime instance semantics — but two different widgets cannot, because their inlined deps and modules live in separate evaluations.)
  • Don't read DOM globals at module load. The widget is evaluated inside a Shadow DOM; document exists but its tree isn't the host's tree, and reaching into it is a layering violation.
  • Don't reach for prefers-color-scheme. The host decides the theme — see theme-injection contract.
  • Don't portal to document.body. Use ctx.portalTarget — see Platform ABI § portals.

Updating an existing widget

Step-by-step:

  1. Locate the source. Try in order:
    • widgets/<name>/src/widget.tck.tsx (or widgets/<NNN>_<slug>/widget.tck.tsx in cloud sandbox path) if first-party.
    • manifest.source.url + entry if pointer-typed.
    • source/widget.tck.tsx inside the .tckb if inline.
  2. Read the current manifest. Note version, schemaVersion, existing sizes, permissions.
  3. Make your edit. Stay within the externals whitelist + the manifest schema.
  4. Bump manifest.version. Always; the bundler emits a fresh .tckb whose URL is content-addressed by SHA-384(widget.mjs), so a behavioural change without a version bump silently produces an indistinguishable bundle to anyone who pinned by version.
  5. Bump manifest.schemaVersion + add migrations[] if you changed the defaultState shape. The bundler's static check tck/migration-chain-broken catches gaps; the host's restoreWidget will run the migrations forward at next mount.
  6. Re-bundle with pnpm build (or pnpm exec tck-bundle). Fix any BundleError codes the bundler reports, in order.
  7. Publish. In the cloud sandbox path the scenario boundary picks up dist/<NNN>_<slug>.tckb automatically; in the workspace path push to the registry or hot-mount in the workbench.

See also

On this page