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 (001…999), 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
.tckbper 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 readyNo <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:
- Workspace path — for first-party widgets the source is directly under
widgets/<name>/. Edit files in-place. - Manifest source pointer —
manifest.sourceresolves to a git URL + entry path or an HTTP URL. Fetch via that pointer. - Inline source — when nothing else is available, open the
.tckb(it's a zip), readsource/widget.tck.tsx, edit, re-bundle.
After editing:
- Bump
manifest.version(semver). Patch for fixes, minor for additive features, major for breaking changes. - If state shape changed, bump
manifest.schemaVersionand add amigrations[]entry from the prior schema to the new one. - Re-bundle. The bundler runs every static check, including the migration-chain check.
- 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/cookieAPIs — 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.
| Code | Meaning | How to fix |
|---|---|---|
tck/manifest-shape | The 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-format | manifest.id is not reverse-DNS (e.g., com.example.thing). | Choose a reverse-DNS id under a namespace you own. |
tck/version-not-semver | manifest.version is not semver. | Use MAJOR.MINOR.PATCH, optionally with a pre-release. |
tck/size-not-declared | A size literal in defaultSize is missing from sizes. | Add it to sizes or change defaultSize. |
tck/migration-chain-broken | migrations[] does not form a continuous from → to ladder ending at manifest.schemaVersion. | Add the missing step or bump schemaVersion to match. |
tck/external-not-allowed | The 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-required | Source uses useAgent but the manifest doesn't declare agent.read or agent.write. | Add the permission or remove the usage. |
tck/bundle-too-big | Output exceeds --max-bytes (default 256 KB on widget.mjs). | Trim deps or split into multiple widgets. |
tck/no-default-export | The compiled module is missing the React-component default export. | Add export default <YourWidget>. |
tck/no-manifest-export | The 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-hostor anyapps/*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.logis OK in dev, but nowindow.foo = ..., nofetchin module body). Do work inuseEffect/ 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;
documentexists 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. Usectx.portalTarget— see Platform ABI § portals.
Updating an existing widget
Step-by-step:
- Locate the source. Try in order:
widgets/<name>/src/widget.tck.tsx(orwidgets/<NNN>_<slug>/widget.tck.tsxin cloud sandbox path) if first-party.manifest.source.url+entryif pointer-typed.source/widget.tck.tsxinside the.tckbif inline.
- Read the current manifest. Note
version,schemaVersion, existingsizes,permissions. - Make your edit. Stay within the externals whitelist + the manifest schema.
- Bump
manifest.version. Always; the bundler emits a fresh.tckbwhose URL is content-addressed bySHA-384(widget.mjs), so a behavioural change without a version bump silently produces an indistinguishable bundle to anyone who pinned by version. - Bump
manifest.schemaVersion+ addmigrations[]if you changed thedefaultStateshape. The bundler's static checktck/migration-chain-brokencatches gaps; the host'srestoreWidgetwill run the migrations forward at next mount. - Re-bundle with
pnpm build(orpnpm exec tck-bundle). Fix anyBundleErrorcodes the bundler reports, in order. - Publish. In the cloud sandbox path the scenario boundary picks up
dist/<NNN>_<slug>.tckbautomatically; in the workspace path push to the registry or hot-mount in the workbench.
See also
- Building widgets — the human-dev scaffold + dev loop.
- Widget guidelines — what the host owns vs the widget, the boundary rules.
- Widget lifecycle — source → bundle → run within a single repo.
- Platform ABI — the wire contract the bundler enforces.
- Cross-repo lifecycle — the cloud sandbox path's full orchestration (agent → commit → render).
Widget lifecycle (source → bundle → run)
How a widget moves from a .tck.tsx source file to a mounted React tree in a host — and where to put work that survives across this chain.
Platform ABI (v1)
Wire-level contract between Today Canvas Kit's bundler, host, and the .tckb envelope. The source of truth for everything else on this site.