Today Canvas Kit

Cross-repo lifecycle

A widget's full life — from agent authoring in the today-runtime sandbox, through today-cloud commit and storage, to today-platform-web canvas mount in the user's browser.

A TCK widget's full lifecycle spans four repositories — today-tck (this SDK), today-runtime (the sandbox image agents author inside), today-cloud (commit + storage + read API), today-platform-web (browser-side canvas + BFF). This page is the integration map: who calls what, who owns which constants, which protocols cross which boundary, and where the SDK's invariants surface in each consumer.

Companion to Platform ABI (the wire contract every consumer obeys) and .tckb wire format (the envelope every consumer reads and writes).

Roles

RepoRoleTCK packages consumed
today-tckSDK, bundler, host runtime, wire-format spec
today-runtimeAgentCore-ready Docker image where agents run pnpm buildNone at image level — agents resolve @todayai-labs/create-widget + @todayai-labs/tck-bundler on demand via pnpm (the image carries the toolchain)
today-cloudScenario execution, persistence, public BFFNone as JS deps — cloud reads .tckb envelopes byte-wise via fflate and treats widget.mjs as opaque bytes (mirrors the wire format)
today-platform-webBrowser-side canvas + same-origin BFF that unpacks .tckb@todayai-labs/tck (types), @todayai-labs/tck-host (TodayHost, WidgetMount, mountHost), @todayai-labs/tck-bundle-format (unpackTckb on BFF), @todayai-labs/tck-shared-deps (importmap-resolved ESM islands)

Four phases

[A — Authoring]              [B — Commit]                 [C — Distribution]            [D — Render]
─────────────────            ────────────────             ──────────────────            ────────────────
agent in today-runtime  →    task-agent-service       →   today-cloud public API    →   today-platform-web
sandbox:                     scenario boundary:           (backend-service):            apps/web canvas-boot:
  pnpm create                  scanWidgetTree             /v2/today-pages*              importmap → mountHost
  pnpm build                   commitWidgetBatch          /bundles/{hash}.tckb            → TodayHost + mountWidget
  → <NNN>_<slug>.tckb          → S3 + PG                                                  → WidgetMount (shadow DOM)
                                                                                          → user-visible widget

Phase A — Authoring (today-runtime sandbox)

The agent runs in an AgentCore sandbox image that pre-installs Node 24.14.1 + pnpm 10.27.0 + the Today scope. The agent calls generic tools (cloudFs, Bash) — there is no widget-specific agent tool.

The system-prompt section taught at scenario time:

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 — the agent's order survives unchanged through the wire because today-cloud's scanner parses the prefix instead of relying on mtime.
  • <slug> matches ^[a-z][a-z0-9-]*$.
  • The image's pnpm shim auto-injects --config.minimum-release-age-exclude=@todayai-labs/* so the agent can pick up SDK patches the day they ship (pnpm 11's minimumReleaseAge would otherwise silently downgrade @latest).

V2 widgets are stateless. The SDK forbids localStorage / sessionStorage / IndexedDB / cookie / BroadcastChannel; the agent prompt also forbids useFieldState / usePersistState / migrations / Expanded / schemaVersion > 1. Read paths land at render time only; there is no write-back-to-agent loop in V2 MVP. (useFieldState + the agent channel are reserved for V3+.)

The bundler is the only authority for "is this widget valid?" — if tck-bundle accepts the source, the widget passes. The agent never touches a TCK-specific tool; it just writes files and runs the build.

Phase B — Commit (today-cloud / task-agent-service)

Once the LLM loop terminates, executeWidgetBatchTask scenario takes over. Two stages:

B.1 Scan — scanWidgetTree

services/task-agent-service/src/runtime/scenarios/scan-widget-tree.ts walks {date}/{kind}/widget-batch/dist/. For each <NNN>_<slug>.tckb:

  1. Unzip via fflate.unzipSync (cloud does NOT import @todayai-labs/tck-bundle-format — keeps cloud independent of SDK version cadence; reads the wire format by hand).
  2. normalizeBundle — defensive layer over pre-cutover bundler quirks. If format.json is absent (pre-Task-#57 bundle), inject { tckbFormat: 2 }; if manifest.sizes carries legacy "auto" (pre-Task-#49 rename), rewrite to "fill-auto". Pure renames only, no semantic rewrites — the goal is to keep the apps/web BFF's strict unpackTckb({ verifyHash }) from rejecting historical agent output.
  3. Compute bundleHash = SHA-384(widget.mjs bytes).base64url. Hashes the inner widget.mjs, not the zip envelope (Platform ABI § hash format).
  4. packWidgetSource packs widgets/<NNN>_<slug>/** into a tar.gz for next-batch preload (single widget ≤128 KB; overflow degrades that widget's source_object_key to null, doesn't block commit).
  5. Emit a WidgetCommitSpec for commitWidgetBatch.

The scanner rejects bundles whose name fails the ^(\d{3})_([a-z][a-z0-9-]*)\.tckb$ regex — two distinct warn events (bundle_missing_prefix vs bundle_malformed_name) so log triage knows which authoring error fired.

B.2 Commit — commitWidgetBatch

Pipeline (services/task-agent-service/src/context/commit-widget-batch.ts):

step 1  selectOldKeys (PG read of prior batch's S3 keys for cleanup)
step 2  S3 puts (S3-first; PG still points at old keys until step 3):
          widgets/{userId}/bundles/{hash}.tckb
          widgets/{userId}/manifests/{widgetId}.json   (when ≥32 KB)
          widgets/{userId}/sources/{widgetId}.tar.gz   (best-effort)
step 3  PG flip (single transaction):
          DELETE FROM widgets WHERE (user_id, local_date, kind) = ...;
          INSERT N rows;
step 4  Best-effort delete of step-1's old keys
step 5  Backfill worker_agent_tasks.output_files with one widget_batch entry
step 6  task-notifications FIFO → main agent → 'Open Today' action URL
        https://today.ai/feed/{batchId}

S3-first → PG-flip is the never-half-flip-PG invariant. If S3 fails the PG points at old keys (business continues); if PG fails new S3 keys orphan (cleanup task). Cleanup of old assets is best-effort — rerun is normal in production (a morning brief that fails at 06:00 and retries at 08:00 must not leak the failed batch's S3 objects forever).

Cloud only reads manifest.json once at step 2 and stores its contents in PG (widgets.manifest_json jsonb when less than 32 KB, else as a separate manifests/{widgetId}.json S3 object). After that the .tckb is opaque bytes — cloud never re-opens it. The Platform ABI § cloud-extractable manifest fields table is the stable-at-v1 subset cloud is allowed to depend on.

Phase C — Distribution (today-cloud public API)

Cloud-side endpoints (backend-service)

GET /v2/today-pages?cursor&limit&direction       → batch list (keyset paginated)
GET /v2/today-pages/{batchId}                    → single batch
GET /v2/today-pages/widgets/{id}                 → single widget fallback
GET /v2/today-pages/bundles/{hash}.tckb          → opaque ZIP byte stream

backend-service is a stateless BFF — it proxies to agent-service's internal /v1/internal/today-pages/* HTTP (which is the schema owner for the widgets table). The split is the established cloud pattern: agent-service owns schemas + migrations + internal reads; task-agent-service owns scenario execution + mirror writes; backend-service owns the public surface.

batchId regex axes:

  • BATCH_ID_RESPONSE_REGEX — accepts all 5 kinds (welcome, feature_intro, morning_brief, evening_brief, diary). Used by list responses.
  • BATCH_ID_PATH_REGEX — accepts only morning-brief / evening-brief. Used by the single-batch path param. Other kinds return 400 today_page.invalid_batch_id.

See the feed-batch contract for the 5-on-the-wire / 3-on-the-surface rule.

apps/web BFF endpoints (today-platform-web)

GET /api/today-pages/v2/batches[?cursor&limit&direction&mock]   → proxies cloud /v2/today-pages
GET /api/today-pages/v2/batches/{batchId}                       → proxies cloud /v2/today-pages/{batchId}
GET /api/widgets/v1/{hash}/widget.mjs[?mock=true]               → unpacks .tckb, returns inner widget.mjs
GET /api/widgets/v1/{hash}/widget.css[?mock=true]               → unpacks .tckb, returns inner widget.css

The bundle BFF (apps/web/src/app/api/widgets/v1/[hash]/widget.mjs/route.ts):

  1. resolveCloudBearer(request) — auth via Bearer header / embed_bearer cookie / better-auth session exchange.
  2. resolveBundleForHash(hash, { fixtureOnly: mock, bearer }) — fixture-first lookup on disk, fallback to cloud proxy.
  3. unpackTckb(bytes, { verifyHash: hash })URL-borne hash claim verified against actual SHA-384(widget.mjs). Mismatch → 422. This is the load-bearing integrity check; integrity.json in-bundle is audit-only when (3) applies (see Platform ABI § integrity).
  4. Returns inner widget.mjs bytes as application/javascript with Cache-Control: public, max-age=31536000, immutable.

Why server-side unpack instead of streaming the whole .tckb to the browser:

  1. The canvas doesn't ship fflate (~15 KB) just for bundle unwrapping.
  2. The unpacked .mjs is content-addressed by its hash → safe to mark immutable for the CDN; the .tckb archive is 30–40 % larger.
  3. DevTools' Sources panel shows real widget source, not an opaque zip blob.

?mock=true flips the entire chain to in-process fixtures (apps/web/src/lib/msw/fixtures/tckb/<hash>.tckb) so native WebView teams can integrate the network path before JWT injection lands.

Phase D — Render (today-platform-web in browser)

The render path is two independent React trees in one document:

  1. The Next.js page tree (Opal chrome, navigation, auth) — Webpack-bundled, ships its own React.
  2. The canvas tree (TCK widgets) — ESM-island, resolves React via importmap.

The two share only the DOM; their React fibers are separate by design. The importmap is scoped (scopes: { '/__tck/v1/': {...}, '/api/widgets/v1/': {...} }) so Next.js's own import 'react' does NOT get hijacked.

D.1 Document skeleton (Next.js SSR)

<html data-theme="dark">
  <head>
    <link rel="stylesheet" href="/__tck/v1/style.css" data-tck-host-style />
    <!-- Next emits its own bundled CSS / scripts here too -->
  </head>
  <body>
    <!-- Opal chrome React tree mounts here -->
    <div id="canvas-feed-root" data-batch-id="20260511-morning-brief"></div>
    <script src="/__tck/v1/bootstrap-feed.mjs" type="module" async></script>
  </body>
</html>

apps/web/scripts/build-host-deps.mts builds public/__tck/v1/*:

  • react-runtime.mjs, tck.mjs, tck-host.mjs, tck-host-mount.mjs — emitted by tck-shared-deps buildPlatform() with the canonical DEFAULT_GROUPS.
  • style.css — copied from @todayai-labs/tck-host/style.css so widgets can resolve var(--color-*) even on a minimal embed shell.
  • boot.mjs (for /embed/today-page/v2) and boot-feed.mjs (for /feed/*) — esbuild-bundled canvas-boot entries with every host-dep specifier externalised.
  • bootstrap-feed.mjs — the no-externals two-stage bootstrap for the Next-page case.

D.2 Importmap injection — two paths

/embed/today-page/v2 is a raw HTML route handler (not a Next page); it emits <script type="importmap"> directly in the HTML, the browser parses it before any module-import runs.

/feed/<batchId> is a Next page. React 19's react-dom/server does not hoist <script type="importmap"> (it isn't in React's head-hoist list — only <style>, <title>, <link rel=stylesheet>, <meta>, <script async src> are). So bootstrap-feed.mjs runs the two-stage dance:

// bootstrap-feed.mjs (bundled with external: [], so no bare specifiers)
document.head.appendChild(
  Object.assign(document.createElement('script'), {
    type: 'importmap',
    textContent: JSON.stringify(IMPORT_MAP),
  }),
)

// Yield a task so the browser indexes the importmap before any bare
// specifier in the boot bundle resolves.
await new Promise((r) => setTimeout(r, 0))

await import('/__tck/v1/boot-feed.mjs') // now bare specifiers resolve

D.3 Canvas boot — boot-feed.ts

async function mountFeedCanvas(root: HTMLElement) {
  // (1) Install the document-level host concerns (CSS link, data-theme).
  const mountControl = mountHost(document, {
    theme: resolveInitialMountTheme(),
    hostCssUrl: '/__tck/v1/style.css',
  })
  const unwireTheme = wireThemePostMessage(mountControl)

  // (2) Fetch the batch list (401 → /login; other errors → inline message).
  const batches = await loadBatches({ mock })

  // (3) Construct the TodayHost. V2 widgets are stateless → ephemeral storage.
  const host = new TodayHost({
    storage: ephemeralStorage,
    loaderOptions: { loadModule: (url) => import(url) },
  })

  // (4) Register each unique bundle hash exactly once.
  for (const batch of batches) for (const widget of batch.widgets) {
    if (seen.has(widget.bundle.hash)) continue
    seen.add(widget.bundle.hash)
    host.registerWidget({
      manifest: widget.manifest,
      bundleUrl: `/api/widgets/v1/${widget.bundle.hash}/widget.mjs`,
      cssUrl: `/api/widgets/v1/${widget.bundle.hash}/widget.css`,
    })
  }

  // (5) Mount each widget instance — per-widget try/catch so one bad
  //     widget doesn't blank the whole feed.
  for (const batch of batches) for (const widget of batch.widgets) {
    try {
      await host.mountWidget({ manifestId: widget.manifest.id, position: { col: 0, row: row++ } })
    } catch (err) {
      failedWidgetIds.add(widget.id)
    }
  }

  // (6) Render the canvas React tree into the host div.
  createRoot(root).render(
    <StrictMode>
      <BootedFeedCanvas host={host} batches={batches}
        positionKeyToWidgetId={...} initialFailedWidgetIds={failedWidgetIds}
        retrySpecByWidgetId={...} />
    </StrictMode>,
  )
}

D.4 Per-card render — <FeedCardSlot><WidgetMount>

Each <FeedCardSlot> wraps a single instance in <WidgetMount mode='inline'>. The mount:

  1. Attaches an open shadow root to its <article>.
  2. Adopts [sharedReset, instance.widgetSheet] via shadow.adoptedStyleSheets.
  3. Injects widget.properties.css into document.head (light DOM, deduped by property name).
  4. Builds a portalTarget inside the shadow — full-bleed, pointer-events: none on self, auto on children.
  5. Calls buildCtx(portalTarget) to materialise WidgetCtx. Feed cards are presentation-only — applyPatch throws, agent is null, getDoc returns defaultState + version 0.
  6. Renders <WidgetCtxContext.Provider value={ctx}><instance.module.default /></Provider> into the shadow.

The widget's bare-specifier imports (react, @todayai-labs/tck, etc.) resolve through the document-level importmap to the host-served /__tck/v1/*.mjs bundles. The host and widget therefore share the same React fiber, the same scheduler queue, the same WidgetCtxContext instance — the singleton invariant of Platform ABI § runtime instance semantics.

Cross-repo contract coordination

The following invariants are manually mirrored across repos. Drift in any of them is a known failure class — each row also lists the report mechanism.

InvariantTruth sourceMirror site(s)Drift report
.tckb format version (tckbFormat)tck-bundle-format's SUPPORTED_TCKB_FORMAT = 2cloud scan-widget-tree.ts inline constant SUPPORTED_TCKB_FORMAT = 2apps/web BFF unpackTckb 422 + cloud unit test normalises a format-1 input to SUPPORTED_TCKB_FORMAT
TCK_EXTERNAL_SPECIFIERS whitelist@todayai-labs/tck/specifiersapps/web bootstrap-feed.ts#IMPORT_MAP; tck-shared-deps DEFAULT_GROUPSbundler metafile parity check + tck-shared-deps manifest subset-relation property test
WidgetSize enum@todayai-labs/tck's types.tscloud manifest-compat.ts#rewriteLegacyAutoSize (transitional compat for autofill-auto)apps/web validateManifest failure boots canvas with InvalidManifestError
Manifest cloud-extractable subsetPlatform ABI § cloud-extractable fieldscloud parseManifest + agent-service widgets.manifest_json jsonbinvariant: cloud never re-opens .tckb after step-2 manifest extract
SHA-384 base64url hash (algo + 64 chars)tck-bundle-format/src/hash.tscloud createHash('sha384').digest('base64url'); apps/web BFF HASH_REGEX = /^[A-Za-z0-9_-]{64}$/three implementations byte-equal; spec-anchored
bundleHash = SHA-384(widget.mjs)Platform ABI § hash formatcloud scanner; apps/web BFF unpackTckb({ verifyHash })apps/web 422 on mismatch
<html data-theme> co-ownershiptck-host/mount.ts refcount designboot-feed.ts deliberately does NOT call mountControl.dispose() (next-themes co-owns the attribute on this surface)tracked follow-up: manageTheme: false opt-in on mountHost so consumers can co-own without the workaround

Per-mode reference

Each phase has consumer-facing docs:

See also

On this page