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
| Repo | Role | TCK packages consumed |
|---|---|---|
| today-tck | SDK, bundler, host runtime, wire-format spec | — |
| today-runtime | AgentCore-ready Docker image where agents run pnpm build | None at image level — agents resolve @todayai-labs/create-widget + @todayai-labs/tck-bundler on demand via pnpm (the image carries the toolchain) |
| today-cloud | Scenario execution, persistence, public BFF | None as JS deps — cloud reads .tckb envelopes byte-wise via fflate and treats widget.mjs as opaque bytes (mirrors the wire format) |
| today-platform-web | Browser-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 widgetPhase 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 (001…999), 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'sminimumReleaseAgewould 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:
- 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). normalizeBundle— defensive layer over pre-cutover bundler quirks. Ifformat.jsonis absent (pre-Task-#57 bundle), inject{ tckbFormat: 2 }; ifmanifest.sizescarries 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 strictunpackTckb({ verifyHash })from rejecting historical agent output.- Compute
bundleHash = SHA-384(widget.mjs bytes).base64url. Hashes the innerwidget.mjs, not the zip envelope (Platform ABI § hash format). packWidgetSourcepackswidgets/<NNN>_<slug>/**into atar.gzfor next-batch preload (single widget ≤128 KB; overflow degrades that widget'ssource_object_keyto null, doesn't block commit).- Emit a
WidgetCommitSpecforcommitWidgetBatch.
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 streambackend-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 onlymorning-brief/evening-brief. Used by the single-batch path param. Other kinds return400 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.cssThe bundle BFF (apps/web/src/app/api/widgets/v1/[hash]/widget.mjs/route.ts):
resolveCloudBearer(request)— auth via Bearer header /embed_bearercookie / better-auth session exchange.resolveBundleForHash(hash, { fixtureOnly: mock, bearer })— fixture-first lookup on disk, fallback to cloud proxy.unpackTckb(bytes, { verifyHash: hash })— URL-borne hash claim verified against actualSHA-384(widget.mjs). Mismatch → 422. This is the load-bearing integrity check;integrity.jsonin-bundle is audit-only when (3) applies (see Platform ABI § integrity).- Returns inner
widget.mjsbytes asapplication/javascriptwithCache-Control: public, max-age=31536000, immutable.
Why server-side unpack instead of streaming the whole .tckb to the browser:
- The canvas doesn't ship
fflate(~15 KB) just for bundle unwrapping. - The unpacked
.mjsis content-addressed by its hash → safe to markimmutablefor the CDN; the.tckbarchive is 30–40 % larger. - 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:
- The Next.js page tree (Opal chrome, navigation, auth) — Webpack-bundled, ships its own React.
- 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 bytck-shared-depsbuildPlatform()with the canonicalDEFAULT_GROUPS.style.css— copied from@todayai-labs/tck-host/style.cssso widgets can resolvevar(--color-*)even on a minimal embed shell.boot.mjs(for/embed/today-page/v2) andboot-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 resolveD.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:
- Attaches an open shadow root to its
<article>. - Adopts
[sharedReset, instance.widgetSheet]viashadow.adoptedStyleSheets. - Injects
widget.properties.cssintodocument.head(light DOM, deduped by property name). - Builds a
portalTargetinside the shadow — full-bleed,pointer-events: noneon self,autoon children. - Calls
buildCtx(portalTarget)to materialiseWidgetCtx. Feed cards are presentation-only —applyPatchthrows,agentis null,getDocreturnsdefaultState+ version 0. - 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.
| Invariant | Truth source | Mirror site(s) | Drift report |
|---|---|---|---|
.tckb format version (tckbFormat) | tck-bundle-format's SUPPORTED_TCKB_FORMAT = 2 | cloud scan-widget-tree.ts inline constant SUPPORTED_TCKB_FORMAT = 2 | apps/web BFF unpackTckb 422 + cloud unit test normalises a format-1 input to SUPPORTED_TCKB_FORMAT |
TCK_EXTERNAL_SPECIFIERS whitelist | @todayai-labs/tck/specifiers | apps/web bootstrap-feed.ts#IMPORT_MAP; tck-shared-deps DEFAULT_GROUPS | bundler metafile parity check + tck-shared-deps manifest subset-relation property test |
WidgetSize enum | @todayai-labs/tck's types.ts | cloud manifest-compat.ts#rewriteLegacyAutoSize (transitional compat for auto → fill-auto) | apps/web validateManifest failure boots canvas with InvalidManifestError |
| Manifest cloud-extractable subset | Platform ABI § cloud-extractable fields | cloud parseManifest + agent-service widgets.manifest_json jsonb | invariant: cloud never re-opens .tckb after step-2 manifest extract |
| SHA-384 base64url hash (algo + 64 chars) | tck-bundle-format/src/hash.ts | cloud 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 format | cloud scanner; apps/web BFF unpackTckb({ verifyHash }) | apps/web 422 on mismatch |
<html data-theme> co-ownership | tck-host/mount.ts refcount design | boot-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:
- Authoring — see Building widgets (human dev path) and Agent authoring (LLM agent path with the
<NNN>_<slug>convention). - Commit — owned by today-cloud spec
docs/specs/2026-05-11-today-page-v2-cloud-api.md(this site does not re-prescribe the cloud's internal pipeline). - Distribution — see Platform ABI § Host-side ABI contract for the mount contract every distribution surface obeys.
- Render — see render-mode contract (transport taxonomy), container contract (size/theme offer), visual container contract (frosted frame).
See also
- today-cloud spec:
docs/specs/2026-05-11-today-page-v2-cloud-api.md— cloud-side authoritative spec; this page references its behaviour and links the BFF endpoints. - today-platform-web:
apps/web/src/canvas-boot/— the canvas-boot directory is the reference implementation of Phase D. - Platform ABI — the wire contract every consumer obeys.
Theme-injection contract
Theme is externally injected by the consumer. SDK code does not consult prefers-color-scheme. The one sanctioned read lives at mountHost's auto mode.
SDK packages
Today Canvas Kit publishes nine packages across four runtime categories. Index page — pick the page that matches what you're building.