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.
The TCK Platform ABI is the wire-level contract that lets a widget bundle built today by tck-bundler mount inside any host shipped against the same ABI label — months or years later, across different deployments, without recompiling the widget. It pins three surfaces:
- The
.tckbenvelope — zip layout, JSON schemas, integrity scheme. (See .tckb wire format.) - The widget-side runtime contract — the bare specifiers a widget may import, the exports the bundle must surface, the CSS scoping convention.
- The host-side runtime contract — how the host serves shared runtime bundles, wires them into the page via an import map, mounts widget DOM with the right scope class, and propagates theme.
The ABI exists so a .tckb is freestanding and hash-addressable. Producer and consumer compile against the same constants — TCK_EXTERNAL_SPECIFIERS, DEFAULT_GROUPS, manifestIdToScopeSlug, SHA384_BASE64URL_LENGTH — so drift between them is structurally impossible.
The ABI label lives in the URL path the host serves: /__tck/v1/.... Upgrading the ABI means new path alongside the old, never overwriting in place. The v1 path stays live as long as there are widgets pinned to it.
Stable contract surface
Four exports are the load-bearing contract downstream consumers (today-cloud, today-platform-web, native shells) take a hard dependency on. Every other export in the SDK is a convenience helper that may move; these four don't move within an ABI label.
| Export | Package | What it pins |
|---|---|---|
TCK_EXTERNAL_SPECIFIERS | @todayai-labs/tck/manifest | The set of bare specifiers a widget may import. |
manifestIdToScopeSlug | @todayai-labs/tck | The function that derives a widget's CSS scope class from its manifest id. |
SHA384_BASE64URL_LENGTH + sha384Base64Url | @todayai-labs/tck-bundle-format | The wire-format hash: algorithm, encoding, length. |
DEFAULT_GROUPS | @todayai-labs/tck-shared-deps | The externals shipped via the host's import map. |
Search for any of these in your codebase to find where you cross the boundary. If you find yourself reimplementing one of them inline, stop and import.
The .tckb file format
See the dedicated .tckb wire format page for the full envelope spec — zip layout, the four entries (manifest.json, widget.mjs, optional widget.css, optional integrity.json), hash format, integrity rules, and the unpackTckb contract.
Host shared-deps bundle
The host serves a small set of single-file ESM bundles plus an import-map manifest. Widget import "react" calls resolve to those URLs through the host's <script type="importmap">.
Source of truth: @todayai-labs/tck-shared-deps. CLI: tck-shared-deps.
Output directory layout
<outDir>/
├── react-runtime.mjs — react + react/jsx-runtime + react-dom + react-dom/client + scheduler
├── tck.mjs — @todayai-labs/tck
├── tck-host.mjs — @todayai-labs/tck-host
└── manifest.json — { specifier → url }outDir is wiped and recreated on every build (hermetic output). The host mounts this directory under a URL prefix it controls — the canonical convention is /__tck/v1/ (see ABI versioning).
Default groups for ABI v1
From packages/tck-shared-deps/src/groups.ts (DEFAULT_GROUPS):
;[
{
file: 'react-runtime.mjs',
specs: [
'react',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'react-dom',
'react-dom/client',
'scheduler',
],
},
{
file: 'tck.mjs',
// Root + 5 subpaths alias to one bundle. The root barrel
// transitively re-exports every name a subpath exposes, so
// single-bundle aliasing preserves the singleton invariant for
// `WidgetCtxContext` and React fiber state.
specs: [
'@todayai-labs/tck',
'@todayai-labs/tck/manifest',
'@todayai-labs/tck/patch',
'@todayai-labs/tck/agent',
'@todayai-labs/tck/hooks',
'@todayai-labs/tck/runtime',
],
},
{ file: 'tck-host.mjs', specs: ['@todayai-labs/tck-host'] },
]Singleton invariant: every npm package gets exactly one physical output bundle. When a package exposes multiple bare-specifier entry points (react AND react/jsx-runtime), the import map sends both at the same URL — the bundle re-exports the union of names. Splitting a single package across two URLs would duplicate React fiber state and silently break hooks crossing the host/widget boundary.
@todayai-labs/tck-host is a host-only dependency. Widgets do not import it (it's deliberately absent from TCK_EXTERNAL_SPECIFIERS). The host bundle exists so the host's own page chrome can resolve @todayai-labs/tck-host through the same import-map machinery as the widget's deps.
Import-map manifest
manifest.json shape:
{
"react": "/__tck/v1/react-runtime.mjs",
"react/jsx-runtime": "/__tck/v1/react-runtime.mjs",
"react/jsx-dev-runtime": "/__tck/v1/react-runtime.mjs",
"react-dom": "/__tck/v1/react-runtime.mjs",
"react-dom/client": "/__tck/v1/react-runtime.mjs",
"scheduler": "/__tck/v1/react-runtime.mjs",
"@todayai-labs/tck": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/manifest": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/patch": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/agent": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/hooks": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/runtime": "/__tck/v1/tck.mjs",
"@todayai-labs/tck-host": "/__tck/v1/tck-host.mjs",
}The URL prefix is determined by --url-prefix (CLI) or urlPrefix (programmatic). The builder normalises trailing slashes — /__tck/v1 and /__tck/v1/ both produce /__tck/v1/; empty prefix falls back to ./.
The manifest is consumed two ways:
- Embedded as the
importshalf of<script type="importmap">on page boot. - Re-served as JSON for tooling or agents that need to discover the current ABI surface.
ABI versioning (URL paths)
The URL path is the ABI version label. today-platform-web uses /__tck/v1/<filename> for ABI v1; when ABI v2 ships, the host adds /__tck/v2/... alongside the v1 tree. The v1 path stays live as long as there are widgets pinned to it.
tck-shared-deps's --abi flag is informational only — it appears in build logs and is echoed in the result, but the builder itself does not branch on it. The caller chooses --out and --url-prefix such that they encode the ABI label.
Reproducibly building today-cloud's ABI v1 surface:
tck-shared-deps \
--out apps/web/public/__tck/v1 \
--abi v1 \
--url-prefix /__tck/v1Widget-side ABI contract
Externalised bare specifiers
A widget bundle MUST import only these bare specifiers; everything else MUST be inlined by the bundler.
Source: packages/tck/src/manifest/externals.js (TCK_EXTERNAL_SPECIFIERS).
;[
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'scheduler',
'@todayai-labs/tck',
'@todayai-labs/tck/manifest',
'@todayai-labs/tck/patch',
'@todayai-labs/tck/agent',
'@todayai-labs/tck/hooks',
'@todayai-labs/tck/runtime',
]The bundler enforces the whitelist at build time:
BundleError: bundle imports "<spec>", which is not on the TCK externals whitelistThe host's import map must resolve every specifier in this list. Bundler and host pull from the same constant, so they co-evolve. The single-bundle aliasing in DEFAULT_GROUPS is load-bearing for cross-boundary identity: @todayai-labs/tck/hooks consumes WidgetCtxContext from @todayai-labs/tck/runtime, and if subpaths resolved to different physical bundles than the root, every widget would see a different React context provider — silently splitting the host's doc-store identity. Same failure-mode reason react + react-dom + scheduler ship as one bundle.
Notable absences:
@todayai-labs/tck-host— host-only; widgets never import it.lucide-react— widgets bundle their own icon dependencies.- Every UI primitive library.
widget.mjs exports
import type { ComponentType } from 'react'
import type { ExpandedProps, WidgetManifest } from '@todayai-labs/tck'
interface WidgetModule {
readonly default: ComponentType<unknown> // REQUIRED — the widget root
readonly manifest: WidgetManifest // REQUIRED — validated by host
readonly Expanded?: ComponentType<ExpandedProps> // REQUIRED iff manifest.expandable === true
readonly migrations?: ReadonlyArray<{
to: number
run(doc: unknown): unknown | Promise<unknown>
}>
}WidgetLoader.parseWidgetModule runs the full validateManifest on the named export and rejects malformed bundles. The same validated manifest object exists in two places — as the manifest named export inside widget.mjs, and as manifest.json in the zip. The bundler derives the latter from the former before handing it to packTckb, so they cannot diverge.
Tailwind / CSS scoping
The bundler runs Tailwind v4 against the widget's source and emits widget.css. Source: packages/tck-bundler/src/css.ts.
Isolation is the Shadow DOM boundary, not a selector rewrite. Under format 2 (post-Task-#57) the host's <WidgetMount mode='inline'> mounts each widget inside an open shadow root and adopts widget.css via shadow.adoptedStyleSheets, so utility selectors match only inside that tree. The bundler emits plain Tailwind utilities with no scope prefix. (Format-1 bundles wrapped every selector with :where(.tck-widget-<slug>) for light-DOM scoping; that mechanism is retired. See .tckb wire format § widget.css for the format-version split.)
Dark variant. The bundler registers @custom-variant dark bound to :host([data-theme='dark']) (NOT the OS-level prefers-color-scheme). Widget code that writes dark:bg-zinc-950 flips iff the widget's shadow host carries data-theme="dark" — the host's <WidgetMount> mirrors the document's data-theme onto the shadow host per mount.
color is theme, geometry is design — the bundler partitions Tailwind's theme along this boundary:
--color-*is host-provided. The host'stck-host/style.cssdeclares the canonical palette on:root. Custom properties cross the shadow boundary by inheritance, so the host's:rootpalette reaches a Shadow-DOM widget unobstructed; a widget-local re-declaration would shadow it and break light/dark flipping. Widget bundles MUST NOT re-declare--color-*— the bundler drops every--color-*declaration it encounters in@layer theme.- Non-color theme tokens are widget-baked.
--radius-*,--spacing, shadow-geometry, font-property tokens, and any other non-color custom property from@layer themeare emitted on a single:host { … }declaration block, so utilities likerounded-lg,px-2,shadow-mdresolve theirvar(...)references inside the widget's shadow tree — even on a minimal host that doesn't ship Tailwind's default theme on its own:root. A widget'srounded-3xlis those corners in every host; geometry is the widget's design, not the host's theme.
The principle generalises: anything the host should be able to re-theme on a flip (color) stays host-provided; anything the widget should look the same in every host (shape, size, font) bakes at bundle time. Brand-face slots (--font-sans, --font-mono, --font-serif, --font-display) are host-themed and dropped; font-property tokens (--font-weight-*, --font-feature-settings) and all geometry stay widget-baked.
At-rule disposition (raw Tailwind output → emitted widget.css / widget.properties.css):
| At-rule | Disposition |
|---|---|
@layer base | dropped — the host adopts one shared preflight reset sheet into every shadow root, so the widget bundle must not carry its own preflight |
@layer theme | partitioned along the color/geometry boundary above; kept (non-color) tokens emitted on a :host block |
@property (bare or under @layer properties) | extracted to the separate widget.properties.css stream — shadow-local @property is silently ignored on Chromium |
@layer properties' @supports polyfill block | kept in widget.css — only the @property registrations split out |
@layer utilities { .x { … } } | unwrapped, each rule emitted verbatim — no scope prefix; the shadow boundary isolates |
@media / @supports / @container | kept; inner rules emitted verbatim |
@keyframes | kept verbatim — the shadow boundary makes the keyframe name tree-local, so cross-widget name collisions no longer apply |
top-level @layer …; order declarations | dropped (purely advisory; no remaining at-rules use them) |
Widget authors do not ship their own Tailwind config — the bundler's Tailwind closure is canonical. The widget source's directory is the only @source path scanned (no consumer-repo walk).
Portals
Widget code that uses createPortal — directly or via libraries like Radix UI, Headless UI, Floating UI, shadcn/ui that internally portal to document.body — MUST route portals to the host-provided target on the widget ctx:
import { createPortal } from 'react-dom'
import { useWidgetCtx } from '@todayai-labs/tck/hooks'
function MyMenu() {
const ctx = useWidgetCtx()
// `ctx.portalTarget` is typed `HTMLElement | undefined` — a
// non-`<WidgetMount>` host could omit it — but every widget mounted
// through the canonical primitive does get one. A mounted widget may
// treat it as present.
return createPortal(<Menu />, ctx.portalTarget!)
}Library-internal portals (the dominant case) pass ctx.portalTarget to the library's container prop:
<Popover.Portal container={ctx.portalTarget}>...</Popover.Portal> // Radix UI
<Portal container={ctx.portalTarget}>...</Portal> // Headless UI
<FloatingPortal root={ctx.portalTarget}>...</FloatingPortal> // Floating UI
// shadcn/ui passes through Radix's containerWhy. Under mode='inline' widgets mount inside a Shadow DOM root. A portal to document.body lands outside the shadow — CSS isolation breaks at that node, the widget's shadow-scoped tokens (geometry, layer bindings) don't apply, and the widget's dark: variants don't apply. WidgetCtx.portalTarget is the canonical target inside the widget's shadow (full-bleed, pointer-events: none with children opting back in — standard portal-layer pattern). Under mode='iframe', portalTarget is the iframe document's body — same API shape, naturally isolated.
CSS stacking-context caveat — Radix-user footgun. If any ancestor of the widget mount has transform, perspective, or filter set, browsers establish a stacking context that makes descendant position: fixed positioning relative to the transformed ancestor instead of the viewport. Radix's own docs note this and recommend passing container explicitly when it happens. WidgetCtx.portalTarget IS the explicit-container escape hatch — passing it is the right move regardless of whether the host happens to apply transforms.
In development builds, the host's <WidgetMount> installs a MutationObserver on document.body that fires console.warn when a widget mount inserts a child outside its mount root. Catches both direct-call escapes (createPortal(_, document.body)) and library-default-container escapes (Radix used without container=). Best-effort, but the common "Radix dropdown rendered outside the shadow → unstyled" silent breakage IS caught at dev time.
Persistence
Widgets MUST NOT use localStorage, sessionStorage, IndexedDB, document.cookie, or any other browser-managed storage. The persistence channel is the host's doc-store, accessed via @todayai-labs/tck/hooks. See building widgets for the full rule set.
Runtime instance semantics
Module identity at runtime is governed by three rules — which widgets share state with each other, which don't, and which share state across multiple mounts of themselves. Persistence above and the host-provided context channel are consequences of the first rule; this section makes the underlying invariant explicit.
TCK externals (singleton). Every specifier in TCK_EXTERNAL_SPECIFIERS is externalised by the bundler and resolved by the host's import map to a single physical URL per specifier. The browser keys JavaScript module entries by request URL plus module type; for this ABI's uniform JavaScript-module loads, the effective identity key is the resolved module URL. Every widget importing any of these specifiers therefore receives the same module instance — React's fiber dispatcher, the scheduler queue, and WidgetCtxContext (packages/tck/src/runtime/context.ts) all live in this one shared instance. The host's <WidgetCtxContext.Provider value={...}> propagates to widgets' useContext calls because both sides resolve WidgetCtxContext to the same object identity across the bundle boundary. DocStore itself is owned by @todayai-labs/tck-host, which is host-only and absent from the externals whitelist; widgets reach it indirectly through the shared WidgetCtxContext value.
The singleton surface is exactly what TCK_EXTERNAL_SPECIFIERS lists — no narrower, no wider — across the production path. Three structural enforcers keep producer and consumer aligned with that single export:
scripts/build-widgets.tsexternalises againstTCK_EXTERNAL_SPECIFIERSbefore bundling each widget and checks bidirectional parity at build time.packages/tck-shared-depsmanifest tests assert the import map covers every entry on the whitelist (the subset-relation property test landed intck-shared-deps@0.0.3).- The shared host import-map Vite plugin
@todayai-labs/tck-host/viteimports the canonicalTCK_EXTERNAL_SPECIFIERSdirectly from the@todayai-labs/tck/specifierssubpath — one copy, no inline mirror, no drift surface.
Inlined deps (per-bundle). Anything NOT in TCK_EXTERNAL_SPECIFIERS is inlined by the bundler into the widget's widget.mjs. If widget A and widget B both import { x } from 'lodash', each .tckb ships its own copy. The two copies are separate module evaluations: separate module-level state, separate closures, separate event-listener registrations. Widgets MUST NOT communicate through inlined-dep module state — A and B cannot share a counter, a registry, or a mutable Set via a common dependency. The only sanctioned cross-widget channels are the host's doc-store (see Persistence above) and host-level props.
Same-widget multi-mount. When the same widget.mjs module URL is loaded N times into the same document — host renders N instances of the same widget — the browser's module map dedups by that URL. All N React mounts share one module evaluation and one module graph. Module-level state inside the widget's own source (a module-scope counter, a memoisation cache) is therefore shared across all mounts of the same widget, but is NOT shared with mounts of any other widget. The .tckb envelope is the zip the host downloads; what dedups at module-map level is the resolved widget.mjs URL the host hands to import().
The rules compose: widget A's inlined lodash is shared across all mounts of widget A (same-URL dedup) but is NOT shared with widget B's inlined lodash (different URLs → different module evaluations → different instances).
Iframe / cross-origin rendering. The above describes the current direct-embed rendering mode, where widget DOM mounts directly in the host's document and shares the host's JavaScript realm. A future iframe or cross-origin rendering mode would move the sharing boundary to the embedding realm/document; singleton guarantees from TCK_EXTERNAL_SPECIFIERS would still hold, but scoped to that boundary rather than the host document. Whether the future design is one iframe per widget, one iframe per host page, or something in between is an open question and not pre-decided here. The host-side ABI contract gains an isolation-boundary section here when that mode ships.
Host-side ABI contract
Serve the shared-deps directory
Mount the tck-shared-deps output directory under the ABI's URL prefix (canonical: /__tck/v1/). Cache headers SHOULD be Cache-Control: public, max-age=31536000, immutable — the URL path already encodes the ABI label, so contents are immutable per path.
Embed the import map
Before any widget bundle loads, the host MUST emit a <script type="importmap"> whose imports object equals the manifest.json produced by tck-shared-deps. Two equivalent forms:
<!-- Inline form -->
<script type="importmap">
{
"imports": {
"react": "/__tck/v1/react-runtime.mjs",
"react/jsx-runtime": "/__tck/v1/react-runtime.mjs",
"react/jsx-dev-runtime": "/__tck/v1/react-runtime.mjs",
"react-dom": "/__tck/v1/react-runtime.mjs",
"react-dom/client": "/__tck/v1/react-runtime.mjs",
"scheduler": "/__tck/v1/react-runtime.mjs",
"@todayai-labs/tck": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/manifest": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/patch": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/agent": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/hooks": "/__tck/v1/tck.mjs",
"@todayai-labs/tck/runtime": "/__tck/v1/tck.mjs",
"@todayai-labs/tck-host": "/__tck/v1/tck-host.mjs"
}
}
</script>
<!-- External form (requires `es-module-shims` or a browser shipping
external-import-map support) -->
<script type="importmap" src="/__tck/v1/manifest.json"></script>Dev hosts (Vite-based workbench, tck-preview) build the import map at runtime from performance.getEntriesByType('resource') because Vite serves React from a volatile /node_modules/.vite/deps/... URL — see apps/host-playground/src/host/import-map.ts. Production hosts use the static manifest.
Mount the widget DOM
For each loaded widget the host calls <WidgetMount mode='inline' instance module buildCtx /> (from @todayai-labs/tck-host/bootstrap). The primitive:
- Calls
article.attachShadow({ mode: 'open' })on an article-level mount node and adopts two stylesheets into the shadow root viashadow.adoptedStyleSheets:- The host's shared preflight reset sheet.
- The widget's parsed
widget.css(resolved byWidgetCssCache— one parse per bundle, refcount-shared across instances).
- Constructs
portalTarget— a shadow-scoped full-bleed element withpointer-events: noneon itself andautoon children — for portal-routed UI. - Calls
buildCtx(portalTarget)once per mount to materialise theWidgetCtxpassed throughWidgetCtxContext.Provider. - Renders
<WidgetCtxContext.Provider value={ctx}><module.default /></Provider>inside the shadow root.
The article carries a data-tck-manifest-id (debug surface) and data-theme attribute the host updates as the user-selected theme changes (see theme).
The reference inline implementation is packages/tck-host/src/bootstrap/widget-mount.tsx; two consumers — apps/host-playground/src/host/widget-slot.tsx (canvas tier) and today-platform-web's apps/web/src/canvas-boot/feed-card-slot.tsx (embed tier) — demonstrate the surface from both sides.
Inject widget.properties.css
When the bundle ships widget.properties.css, the host injects it into document.head (light DOM, deduped by property name across all mounted widgets). @property is document-scoped per the CSS spec — registering it inside an adopted shadow stylesheet has no effect on Chromium — so this stream lives outside the shadow root by design.
Bundles with no widget.properties.css are non-fatal: typed-property utilities (shadow-lg, ring-*, gradient stops) degrade gracefully and the host warns once per URL rather than blocking the mount.
Set the theme attribute
The host MUST set data-theme="dark" or data-theme="light" on the document root (or any ancestor of the widget mount point) and update it as the chosen theme changes. Widgets MUST NOT read prefers-color-scheme directly — the host decides, not the OS. The bundler's dark variant binds to :host([data-theme='dark']), so the host's <WidgetMount> mirrors the document attribute onto each shadow host per mount.
The canonical client-side installer is mountHost(document, { theme }) from @todayai-labs/tck-host/mount; SSR hosts call getHostHeadNodes({ theme }) from @todayai-labs/tck-host/server to emit the attribute and the <link> to the host CSS as part of <head>.
Verify integrity (recommended)
On .tckb ingestion, the host SHOULD:
- Call
unpackTckb(bytes)— verifiesintegrity.jsondigests in-band. Audit-only when (2) also applies. - Pass
verifyHash: '<expected-sha384>'when the URL itself carries the integrity claim (e.g., content-addressed delivery through/api/widgets/v1/<hash>).
Both checks raise TckbUnpackError on mismatch. When (2) applies it is the load-bearing check; (1) reduces to an audit record. Any downstream consumer that fetches a bundle from a content-addressed URL MUST recompute the digest from the raw widget.mjs bytes and compare against the URL-borne hash — trusting the in-bundle integrity.json in that position is a confused-deputy (the producer of the bundle is also the producer of integrity.json).
Mount tiers — <TodayWidget> vs TodayHost primitives
The mechanics above apply under both of the two tiers a host uses to mount widgets. A host picks a tier by what kind of surface it is, not by preference.
Tier 1 — closed contract: <TodayWidget> + <TodayHostBoundary>. The @todayai-labs/tck-host/bootstrap <TodayWidget> component is a sealed single-widget mount. It accepts a discriminated-union prop shape — { src, instanceId } (production: load by URL) or { devModule, instanceId } (dev/HMR: an inline module promise) — never both, and never a loadModule override (loadModule?: never on both variants). Optional props: size? (caller-driven size) and onMounted? (post-mount { manifestId, instance } hook). Every module flows through WidgetLoader.parseModule validation. <TodayWidget> builds the per-widget WidgetCtx and wraps its own WidgetCtxContext.Provider internally; the surface MUST NOT wrap a second provider. Use Tier 1 for embedding surfaces — surfaces that render one or more discrete widgets but do not own a widget canvas: the today-platform-web embed routes, the tck-preview widget-frame.
Tier 2 — open primitives: TodayHost + host.mountWidget(...). A surface that owns a widget canvas — drag-and-drop placement, a persisted multi-widget layout, restore-from-storage — constructs a TodayHost directly and calls host.mountWidget(...) / host.restoreWidget(...) itself, building its own WidgetCtx (with host-driven size, visibility, focus). host.mountWidget's instanceId argument is optional at this tier — omit it for a host-generated UUID. Use Tier 2 for canvas-owning surfaces — the apps/host-playground workbench is the reference Tier-2 host.
The distinction is driven canvas state, not widget count. An embedding surface may show several widgets and still be Tier 1; a workbench is Tier 2 because it drives placement / size / layout and persists them. Do not reach for Tier 2 to escape a Tier-1 limitation that a <TodayWidget> prop already covers (e.g. caller-driven size) — and do not force a canvas onto Tier 1's closed contract.
The installer is tier-agnostic. mountHost(document, options) (client-side) and getHostHeadNodes(options) (SSR) wire the document-level concerns — the host CSS <link>, the <script type="importmap">, the data-theme attribute — and are called once per document regardless of tier. The build-time emitter hostImportMapPlugin (@todayai-labs/tck-host/vite) is the no-SSR equivalent of getHostHeadNodes for static-HTML Vite surfaces.
Build sandbox versions
For reproducible .tckb and host-deps builds, today-cloud and any external reproducer should pin:
| Tool | Version | Source |
|---|---|---|
| Node.js | 24.14.1 | .nvmrc |
| pnpm | 10.33.0 (with locked sha512) | package.json → packageManager |
| esbuild | ^0.27.4 (resolved by lockfile) | pnpm-workspace.yaml → catalog.esbuild. Consumed by tck-bundler + tck-shared-deps. |
| Tailwind v4 | ^4.2.3 (tailwindcss, @tailwindcss/oxide) | pnpm-workspace.yaml → catalog.tailwindcss, catalog.@tailwindcss/oxide. Consumed by tck-bundler. |
| TypeScript | ^5.9.3 | pnpm-workspace.yaml → catalog.typescript |
| React | ^19.2.5 (bundled into react-runtime.mjs) | pnpm-workspace.yaml → catalog.react, catalog.react-dom |
| fflate | ^0.8.2 | pnpm-workspace.yaml → catalog.fflate. Consumed by tck-bundler + tck-bundle-format. |
Reproducibility checklist:
nvm use(or any version manager honouring.nvmrc).corepack enable && corepack prepare pnpm@10.33.0 --activate.pnpm install --frozen-lockfilefrom the repo root.- Widget bundle:
pnpm exec tck-bundle <entry>.tck.tsx --out <name>.tckb(driven bytck-bundler/src/bin.ts). - Host shared-deps bundle:
pnpm exec tck-shared-deps --out <dir> --abi v1 --url-prefix /__tck/v1.
esbuild's output is byte-stable for a fixed (entry source, esbuild version, options) tuple. Tailwind v4's output is byte-stable for a fixed (utility set, version) tuple. The .tckb zip is built with fflate.zipSync over a deterministic entry list, no timestamps in the output stream. The only non-determinism in the current pipeline is the manifest extraction temp directory (node_modules/.cache/tck-bundle-*), which is created and deleted per build and never lands in the artifact.
Versioning
SDK package versions
The SDK packages — @todayai-labs/tck, tck-host, tck-bundler, tck-bundle-server, tck-bundle-format, tck-preview, create-widget — are in a changesets fixed group and always release at the same version. Source: .changeset/config.json.
tck-shared-deps is not in the fixed group and walks its own version line. It is a build-time tool — hosts depend on it via their own devDependencies; widgets never see it.
SemVer convention (current)
The group is at 1.x (since 2026-05-28). Use standard SemVer — patch for fixes, minor for additive, major for breaking. A single major ships all seven packages at the new major version, so coordinate before writing one. Production-side safety lives in the dev → main release-PR review, not in write-time discipline; see VERSIONING.md §3 and §7.
ABI label vs. SDK version
SDK package version and ABI label are independent.
- ABI label = the URL path segment (
v1,v2, …). Bumps on any breaking change to the contracts in this document. - SDK version = the npm version of the packages. Bumps on every release per standard SemVer (see §SemVer convention above).
The ABI label stayed at v1 across the SDK's 0.x → 1.0 transition; the 1.0 cut itself did not force v2. ABI bumps to v2 require a breaking change to one of:
- The
.tckbenvelope (renamed or removed required entries, changed hash algorithm, schema-breaking change inmanifest.jsonorintegrity.json). - The widget externals whitelist (specifier removed, semantic change to a kept specifier — e.g.,
reactgoing from 19 to 20). - The host-side mount contract (scope-class scheme change,
data-themeattribute name change). - The shared-deps manifest schema.
Additive changes at the same ABI
Non-breaking, additive changes can ship at the same ABI label:
- New entry on the externals whitelist (host import map gains an entry; old widgets unaffected).
- New optional field in
integrity.json(old hosts ignore, new hosts verify). - New optional zip entry (old hosts ignore it).
- New optional named export in
widget.mjs(old hosts ignore it).
When in doubt: if the new behaviour would cause an older host to reject an older widget, it's breaking and needs a new ABI label.
Persisting the ABI label downstream
Downstream consumers that store bundles long-term — content-addressed object storage, DB tables that key on bundle_sha384, registry indexes — SHOULD persist the ABI label (v1, v2, …) alongside the hash on each row, even while the live ABI is single-valued. The label is cheap to record at ingest time (the producer's path prefix already encodes it, see URL paths) and impossible to back-fill correctly once an ABI v2 rollout begins to interleave new bundles with the existing v1 corpus. With the label persisted:
- Cross-ABI bundles coexist without a storage migration. A v2 bundle and a v1 bundle share the same row shape; the host import-map used to load them is the only branch.
- Audit and rollback are tractable. "Which bundles were authored against ABI v1?" becomes a column predicate, not a heuristic.
- The v1 → v2 cutover does not need a freeze. Producers keep emitting v1 bundles, the storage layer keeps both, and the load path picks the correct import map per row.
Absent the label, downstream consumers face an awkward choice at ABI-bump time: assume every existing row is v1 (correct today but fragile when v3 ships), or reverse-engineer the label from bundle contents (the externals set is part of the contract that the label exists to disambiguate, so this is circular).
Versioning arrow
When a concept — field, constant, key, identifier — is shared between Today Canvas Kit and a downstream consumer (today-cloud, today-platform-web, today-native), the canonical name is anchored in the innermost package: the one closest to the bytes / wire format. Downstream consumers map their column names, struct fields, query parameters onto the canonical name; the canonical name does not move to mirror them.
Downstream consumers raise needs — "we need byte-budget enforcement, expose the unzipped widget.mjs size" — and the inner package picks the name, the shape, and the verification semantics. The arrow governs naming and source-of-truth, not who initiates the conversation.
The corollary: this site is the source of truth for envelope contents, integrity format, hash encoding, external specifier set, and manifest schema. Consumer specs describe how they consume the contract — which fields they read, persist, pass through, ignore — and reference this site for the contract's shape. They do not re-prescribe it.
The detectability-at-unpack test. When in doubt about where a new shared concept should be named: if changing the byte value byte-for-byte would be noticed on the next unpackTckb call, the concept belongs in tck-bundle-format (the unpacker will detect the drift loudly). If not, it lives in the layer that actually owns the behaviour — tck-bundler for bundler-side derivation baked into widget bytes, tck-host for runtime mounting and CSS injection, or further downstream still.
CHANGELOG of ABI changes
v1
Initial public ABI. Snapshot taken 2026-05-18; updated 2026-05-23 with the Task #57 Shadow-DOM cutover (additive at the wire-format level — see .tckb format history).
.tckbenvelope: zip(format.json+manifest.json+widget.mjs+ optionalwidget.css+ optionalwidget.properties.css+ optionalintegrity.json).- Hash: SHA-384, base64url unpadded, 64 chars (
SHA384_BASE64URL_LENGTH). - Externals:
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}. - Default host groups:
react-runtime.mjs,tck.mjs,tck-host.mjs. - CSS isolation: Shadow DOM via
<WidgetMount mode='inline'>adoptingwidget.cssinto the per-instance shadow root.@propertyregistrations split towidget.properties.cssand injected intodocument.head(deduped by property name). The format-1:where(.tck-widget-<slug>)selector-rewrite mechanism is retired. - Theme: host controls via
data-theme="dark"|"light"on document root;<WidgetMount>mirrors the attribute onto each shadow host. - Mount tiers: closed-contract
<TodayWidget>for embedding surfaces; open-primitiveTodayHost+host.mountWidget(...)for canvas-owning surfaces.
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.
.tckb wire format
Format 2 — six-entry envelope with format.json discriminator, unscoped widget.css adopted into a shadow root, and a separate widget.properties.css stream for @property registrations.