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.
A TCK widget is a single .tck.tsx file inside a workspace package. This guide walks the chain that turns the file into a mounted, live React tree, and pins where each kind of state lives along the way.
If you're integrating across multiple repos (today-cloud, today-platform-web), see also cross-repo lifecycle — that page covers the full agent-authoring → render flow. This page is the single-repo author view.
The core principle — source is canonical
+--------+
| author | human or agent
+---+----+
│ writes / edits source
▼
+--------------------+
| widget source dir | e.g. widgets/todo-list/
| *.tck.tsx, |
| package.json, |
| tsconfig.json, |
| manifest fields |
+---------+----------+
│ pnpm exec tck-bundle (or pnpm build)
▼
+--------------------+
| .tckb artifact | zip: format.json + manifest.json + widget.mjs
| (built bundle) | + optional widget.css + optional widget.properties.css
| | + optional integrity.json
+---------+----------+
│ registry publish (CI / agent / dev hot-mount)
▼
+--------------------+
| registry | serves bundles by manifestId+version, hash-addressed
+---------+----------+
│ WidgetLoader.load(url)
▼
+--------------------+
| client (host) | workbench / preview / web embed / native shell
+--------------------+The source is the canonical artifact. Bundles (.tckb) are derivative; they're how the source travels to clients. An agent that updates a widget edits the source and re-bundles. The bundle includes a back-pointer to the source (manifest.source) so an agent that only sees the bundle can fetch the source and round-trip the edit.
Three storage shapes
| Where state lives | Survives across | Reset by | Read with |
|---|---|---|---|
manifest.defaultState | Forever (compiled into widget.mjs) | Re-publishing a new bundle | Implicit — the host seeds new instances with this |
Doc store (per instanceId) | Mount → unmount → remount | host.resetWidget(instanceId) | useFieldState(path, fallback) / useDoc() |
React local state (useState) | Render only | Every remount | Don't use for anything you want to keep |
The widget code only ever sees the doc store. The host owns persistence; the widget reads + writes through the hooks.
Author loop
# 1. Scaffold a workspace from the template:
pnpm create @todayai-labs/widget my-widgets
cd my-widgets
# 2. Iterate with HMR against the preview shell:
pnpm dev # tck-preview at http://localhost:5173
# 3. Build verifiable .tckb artifacts:
pnpm build # → dist/<slug>.tckb per widgetThe bundler is the only authority for "is this widget valid?":
- If
tck-bundleaccepts the source, the widget is structurally valid. - If it rejects, the error message names the specific contract violation (externals whitelist, manifest schema, bundle size cap).
You never need to ask the host whether a widget is OK to publish — the bundler is the gate.
Modify an existing widget
The agent (or human) has access to the source via three paths, in order of preference:
- Local workspace (the widget lives in this monorepo as a package) — edit
widgets/<name>/src/widget.tck.tsx, runpnpm buildto rebuild. manifest.sourceback-pointer — the bundle carries a pointer to the source repo + path. Fetch, edit, rebuild, re-publish. SeeWidgetSourcein@todayai-labs/tcktypes.- Inline source (
source.type: 'inline') — the bundle ships its own source inside the.tckbundersource/, reachable viatckb://self/<path>. Useful for ephemeral / generated widgets that aren't checked into any repo.
After editing source, three steps:
pnpm --filter @acme/widget-todo-rich typecheck # catches contract drift early
pnpm --filter @acme/widget-todo-rich build # emits a fresh .tckb
# (publish to a registry — CI, the agent bus, or a workspace mount)If manifest.schemaVersion is bumped (e.g., you renamed a defaultState field), add a migration so existing persisted instances forward-migrate:
export const migrations = [
{
to: 2,
run(doc: unknown) {
const old = doc as { count: number }
return { count: old.count, lastTickedAt: 0 } // shape v2
},
},
]The host runs migrations forward when it restores a persisted instance whose stored schema is behind the manifest. Migration failures clear the doc back to defaultState and log a warning — see migrateDoc for the contract.
How the host loads a widget
The host's WidgetLoader is responsible for one operation: take a bundle URL and return a WidgetModule { default, manifest, Expanded?, migrations? }.
import { WidgetLoader } from '@todayai-labs/tck-host'
const loader = new WidgetLoader({
loadModule: (url) => import(url), // production path
// or: loadModule: () => Promise.resolve(devModule), // HMR path
})
const widget = await loader.load(bundleUrl)
// widget.manifest is now the validated WidgetManifest
// widget.default is the React componentThree invariants the loader enforces:
validateManifest— the manifest must shape-check against the schema (idreverse-DNS,sizesnon-empty,defaultSize ∈ sizes, etc.). Bad manifest →WidgetLoadError.- ABI label match —
manifest.engines.abi(when declared) must match the loader'sDEFAULT_HOST_ABI. Mismatch →WidgetAbiError. Bundles built withoutengines.abiare treated as implicit'v1'with a one-time deprecation warning. Expandedpairing — a bundle that declaresmanifest.expandable === trueMUST also export anExpandedcomponent; otherwise the loader rejects.
Loaders cache by URL, so the same bundle imported N times for N instances costs one network round-trip and one parse.
Mount a widget instance
The host then mounts each instance with host.mountWidget:
const inst = await host.mountWidget({
manifestId: 'com.acme.todo', // must match a registered widget
position: { col: 0, row: 0 }, // grid position (canvas tier only)
size: '2x1', // optional; defaults to manifest.defaultSize
seedState: { items: [...] }, // optional; defaults to manifest.defaultState
})Three storage operations follow:
host.docStore.init(instanceId, seedState)— write the seed state under the newinstanceId(UUID).host.dispatcherlearns about the new instance — incoming patches against this id will apply against the seeded state.- The instance lands in
host.list()—host.subscribeLayout(...)fires.
The host then renders an <article> with <WidgetMount mode='inline' instance> (see Platform ABI § mount the widget DOM for the Shadow DOM mechanics).
Patch round-trip — read + write the doc store
Inside the widget:
import { useFieldState } from '@todayai-labs/tck/hooks'
function TodoList() {
const [items, setItems] = useFieldState<TodoItem[]>('/items', [])
return <button onClick={() => setItems([...items, newItem()])}>Add ({items.length})</button>
}What happens on setItems([...]):
- The hook constructs an RFC-6902 patch (
{ op: 'replace', path: '/items', value: [...] }). - It wraps the patch in a
PatchEnvelope { patchId, instanceId, baseVersion, patch, clientAt }. - It calls
ctx.applyPatch(envelope)— the widget side of the boundary. host.dispatchervalidatesbaseVersion, applies the patch to the doc store, bumps the version, and re-broadcasts to subscribers.- The hook's
useSyncExternalStorefires and the widget re-renders with the new state.
If the host rejects the patch (e.g., baseVersion is stale because another writer landed first), the hook re-reads the latest doc + version and the next setItems rebases on top. See Platform ABI § patch protocol for the sequencer rules.
Restore a persisted instance
On cold start (browser reload, native app relaunch), the host calls host.restoreWidget instead of host.mountWidget:
const inst = await host.restoreWidget({
manifestId: 'com.acme.todo',
instanceId: persistedId,
position,
storedSchemaVersion: 1, // what the persisted doc was written against
})Differences vs mountWidget:
- No
seedState— the doc is loaded fromstorage(e.g.,idb-keyval). - If
storedSchemaVersion < manifest.schemaVersion, the migrations ladder runs forward. - The instance keeps its original
instanceIdso subscribers and other widgets that referenced it still resolve.
The host's storage parameter (a DocStorage interface) is what makes restore work — see tck-host for the contract; ephemeralStorage is the in-memory default.
Unmount
host.removeWidget(instanceId)- Unmounts the React tree (the shadow root is detached).
- Releases the parsed
widget.csssheet via the host's CSS cache (refcounted — actual eviction happens when the last instance of the bundle unmounts). - Removes the doc from
host.docStore(per-instance). - Drops dispatcher subscriptions.
- Layout subscribers see the instance leave
host.list().
A widget that throws renders the host's error boundary fallback with a "Reload widget" action — a second crash within 5 s offers "Reset to defaults" which drops the doc.
Version vs schema vs ABI
Three independent version axes a widget carries:
| Axis | Where | Bumped on | Migration mechanism |
|---|---|---|---|
manifest.version | Widget code (semver) | Any new bundle build | None — re-publish replaces previous bytes |
manifest.schemaVersion | Widget state shape (positive int) | defaultState shape changes | migrations: Array<{ to: number, run(doc) }> |
manifest.engines.abi | Runtime contract label (v1) | The host-side ABI changes incompatibly | Coordinated cutover — see Platform ABI § ABI label vs SDK version |
They move independently — bumping version does not require a schemaVersion bump and vice versa.
See also
- Building widgets — the scaffold + dev loop quickstart.
- Widget guidelines — what the host owns vs what the widget owns, with the boundary rules.
- Agent authoring — the LLM-agent authoring path with the
<NNN>_<slug>directory convention. - Cross-repo lifecycle — the full agent → commit → render flow across today-runtime / today-cloud / today-platform-web.
- Platform ABI — the wire contract every step of this chain obeys.
Widget guidelines
How to build a widget that behaves well across every Today Canvas Kit host — what the host owns, what the widget owns, and the boundary in between.
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.