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 on 5173, or the next free port — 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 state.version in source (or manifest.schemaVersion for legacy explicit manifests) 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'

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

Host-side ambient services (focus timer, media controller, weather, calendar, …) are not framework primitives and have no dedicated hooks. State flows through the doc plane (host writes service state into the widget's doc; widgets read via usePersistState); actions go through ctx.invokeHost(target, action, args?) — a generic widget → host RPC where target and action are opaque strings the host's product layer routes by. A typed product-side wrapper (e.g. @today/widget-services) lives outside this SDK.

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 — the V2 pipeline forbids doc-store use; V2 cards are stateless (the hooks exist in the SDK, but this pipeline must not call them).
  • migrations — no schema evolution path for stateless cards.
  • state.version > 1 or 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 local constants in the same module, and the widget is a pure render. Do not create a document payload or defaultState for stateless Feed cards. 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 resolved manifest 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 = defineManifest(...) with type and layout.

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

The manifest contract is one zod schema (packages/tck/src/manifest/schema.ts) — the single source of truth. It drives the TypeScript types (z.infer), the runtime validator (validateManifest), and the published manifest.schema.json (generated via z.toJSONSchema). The schema exposes two types:

  • WidgetManifestInput — the low-level validator input shape. sizes, defaultSize, and defaultState are optional. Prefer defineManifest(...) for new source so authors write layout instead of duplicating resolved size fields.
  • WidgetManifest — the resolved shape (defaults applied), what the host / bundler / runtime consume. The bundler bakes the resolved manifest into the .tckb, so downstream consumers always see populated sizes / defaultSize / defaultState.
// Annotate the export with the authoring shape.
// Prefer defineManifest(...) for new source. This is the low-level authoring shape.
interface WidgetManifestInput {
  // identity
  id: string // reverse-DNS, e.g. "com.acme.foo"
  name: string // display name
  version: string // semver

  // layout — optional; defaults baked in by the bundler
  sizes?: WidgetSize[] // default ['fill-auto']; see /docs/reference/container-contract
  defaultSize?: WidgetSize // default 'fill-auto'; must be ∈ sizes

  // resolved doc shape — new defineManifest source writes state instead
  schemaVersion: number // monotonic int, bump on state-shape change
  defaultState?: JsonValue // default {}; must be JSON-serializable
  stateSchema?: JsonValue // optional JSON Schema emitted from state.schema

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

  // current card kind; historical bundles may still use cardType
  manifestVersion?: number
  type?: 'widget' | 'feed' // see /docs/reference/feed-batch-contract
  cardType?: 'widget' | 'feed'

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

  // metadata
  summary?: string // optional natural-language card summary
  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 }
}

A stateless feed card sets type: 'feed', writes layout: 'auto-height', and omits defaultState; a stateful interactive widget sets type: 'widget', writes a grid layout, and declares state. defineManifest maps layout to resolved sizes / defaultSize. To print the full contract — fields, defaults, constraints — run tck-bundle schema (see @todayai-labs/tck-bundler).

New defineManifest(...) source does not author sizes, defaultSize, schemaVersion, defaultState, engines, source, icon, or description. Interactive Widgets write state: { version, default, schema? }; when a Zod-like state.schema is present, the helper validates state.default and serializes the schema to the resolved stateSchema JSON field. The helper emits manifestVersion: 2, type, and the legacy cardType field into the resolved manifest so hosts can keep supporting old and new bundles through one validated wire shape.

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, source state.version (or resolved schemaVersion), existing layout, and 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 state.version + add migrations[] if you changed the state document shape. Legacy explicit manifests bump manifest.schemaVersion. 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