Today Canvas Kit

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 livesSurvives acrossReset byRead with
manifest.defaultStateForever (compiled into widget.mjs)Re-publishing a new bundleImplicit — the host seeds new instances with this
Doc store (per instanceId)Mount → unmount → remounthost.resetWidget(instanceId)useFieldState(path, fallback) / useDoc()
React local state (useState)Render onlyEvery remountDon'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 widget

The bundler is the only authority for "is this widget valid?":

  • If tck-bundle accepts 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:

  1. Local workspace (the widget lives in this monorepo as a package) — edit widgets/<name>/src/widget.tck.tsx, run pnpm build to rebuild.
  2. manifest.source back-pointer — the bundle carries a pointer to the source repo + path. Fetch, edit, rebuild, re-publish. See WidgetSource in @todayai-labs/tck types.
  3. Inline source (source.type: 'inline') — the bundle ships its own source inside the .tckb under source/, reachable via tckb://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 component

Three invariants the loader enforces:

  1. validateManifest — the manifest must shape-check against the schema (id reverse-DNS, sizes non-empty, defaultSize ∈ sizes, etc.). Bad manifest → WidgetLoadError.
  2. ABI label matchmanifest.engines.abi (when declared) must match the loader's DEFAULT_HOST_ABI. Mismatch → WidgetAbiError. Bundles built without engines.abi are treated as implicit 'v1' with a one-time deprecation warning.
  3. Expanded pairing — a bundle that declares manifest.expandable === true MUST also export an Expanded component; 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:

  1. host.docStore.init(instanceId, seedState) — write the seed state under the new instanceId (UUID).
  2. host.dispatcher learns about the new instance — incoming patches against this id will apply against the seeded state.
  3. 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([...]):

  1. The hook constructs an RFC-6902 patch ({ op: 'replace', path: '/items', value: [...] }).
  2. It wraps the patch in a PatchEnvelope { patchId, instanceId, baseVersion, patch, clientAt }.
  3. It calls ctx.applyPatch(envelope) — the widget side of the boundary.
  4. host.dispatcher validates baseVersion, applies the patch to the doc store, bumps the version, and re-broadcasts to subscribers.
  5. The hook's useSyncExternalStore fires 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 from storage (e.g., idb-keyval).
  • If storedSchemaVersion < manifest.schemaVersion, the migrations ladder runs forward.
  • The instance keeps its original instanceId so 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.css sheet 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:

AxisWhereBumped onMigration mechanism
manifest.versionWidget code (semver)Any new bundle buildNone — re-publish replaces previous bytes
manifest.schemaVersionWidget state shape (positive int)defaultState shape changesmigrations: Array<{ to: number, run(doc) }>
manifest.engines.abiRuntime contract label (v1)The host-side ABI changes incompatiblyCoordinated 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.

On this page