Today Canvas Kit

.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.

A .tckb is the wire format every widget bundle ships as: a plain zip containing six well-known entries — three required, three optional. It is freestanding, hash-addressable, runtime-agnostic, and consumed by the same code in browser hosts, native WebViews, dev shells, and BFFs without re-encoding.

This page is the canonical specification for downstream consumers — today-cloud, today-platform-web, native shells — to reference rather than redefine. (See the versioning arrow on the Platform ABI page for the naming rule that makes that hold.)

Status

  • Format version: 2 — carried in the bundle as the format.json entry's tckbFormat field.
  • Compatibility: not interoperable with format 1. Format 2 is the Task #57 Shadow-DOM cutover, and it changes the meaning of widget.css (see widget.css) — a format-1 bundle and a format-2 host do not work together in either direction. The format.json discriminator exists precisely so a host fails closed on a cross-format bundle instead of silently mis-rendering it.
  • Within a format version: additive. New optional entries or optional manifest fields may be added in minor package versions; readers MUST ignore unknown top-level entries and unknown manifest fields. Any change an existing reader must notice is a new format version, not an in-place mutation of an existing one.

Format history

  • Format 1 — pre-Task-#57. widget.css was rewritten so every selector was scoped under :where(.tck-widget-<slug>); there was no widget.properties.css and no format.json. A format-1 bundle carries no format.json at all — its absence is the format-1 signal.
  • Format 2 — the Task #57 Shadow-DOM cutover (this spec). widget.css is unscoped and adopted into a shadow root; @property registrations move to a separate widget.properties.css entry; a mandatory format.json entry stamps the envelope version.

Producer and consumer

Both ends live in @todayai-labs/tck-bundle-format:

  • Producer: src/pack.tspackTckb(input) — programmatic envelope construction. Every writer (the build pipeline, ad-hoc tooling, tests) goes through this.
  • Producer caller (build pipeline): @todayai-labs/tck-bundler's bundle.ts derives the manifest from the compiled widget.mjs, hands it to packTckb, and writes the bytes to disk.
  • Consumer: src/unpack.tsunpackTckb(bytes, options?) — runs identically in Node and the browser; only deps are fflate and crypto.subtle.digest. Gates format.json first; throws TckbUnpackError on any cross-format or structural failure.

Zip layout

.tckb (zip)
├── format.json            — { "tckbFormat": 2 }            (REQUIRED)
├── manifest.json          — widget manifest                  (REQUIRED)
├── widget.mjs             — widget ESM module                (REQUIRED)
├── widget.css             — unscoped CSS, shadow-adopted     (OPTIONAL)
├── widget.properties.css  — @property registrations          (OPTIONAL)
└── integrity.json         — digest manifest                  (OPTIONAL but recommended)

packTckb emits entries in exactly this sequence. The order is the producer's guarantee — a .tckb becomes byte-stable for a fixed input tuple, so two producers building the same widget land on byte-identical envelopes and a content-addressed delivery layer keyed on .tckb bytes deduplicates across reproducers. Readers MUST NOT depend on entry order for correctness — resolve entries by name — but a regression in producer order is an ABI-visible change.

Entry presence rules enforced by the unpacker:

EntryRequiredBehaviour when absent
format.jsonyesTckbUnpackError (format-1 / pre-discriminator; bundle must be re-baked)
widget.mjsyesTckbUnpackError: .tckb is missing widget.mjs
manifest.jsonyesTckbUnpackError: .tckb is missing manifest.json
widget.cssnowidget renders with only the shared reset adopted into the shadow root
widget.properties.cssnotyped-property utilities (shadow-*, ring-*, gradients) degrade (warned, never blocking)
integrity.jsonnounpack proceeds without digest verification

format.json

Mandatory. A single UTF-8 JSON object:

{ "tckbFormat": 2 }

tckbFormat is a monotonic integer, not semver — envelope formats are a discrete sequence of mutually-incompatible shapes; any change a host must notice is a whole new format, there is no "minor" envelope revision.

format.json is the discriminator that lets a host fail closed on a bundle built for a different envelope shape. A reader MUST gate on it first, before any other entry's presence check:

  • tckbFormat equals the reader's supported version → proceed.
  • tckbFormat lower than supported → the bundle predates this format; reject it (it must be re-baked). For a current reader this is any format-1 bundle.
  • tckbFormat higher than supported → the bundle is newer than the reader; reject it (the host must be upgraded).
  • format.json absent → the bundle predates the discriminator entirely (format 1); reject it as un-loadable.
  • format.json present but malformed (not JSON, not an object, or no integer tckbFormat) → reject it as a broken bundle — a corrupt format.json is not an old bundle and must not be mistaken for one.

format.json is written by the bundler, never the widget author — it describes the envelope, not the widget. It is distinct from the author-owned manifest.schemaVersion (the widget-state schema) and manifest.engines.abi (the runtime ABI label); the three version axes move independently.

Fail-closed scope. The discriminator gates the forward direction — a current host rejecting an older or newer bundle. It cannot gate the backward direction: a pre-#57 (format-1) host receiving a format-2 bundle predates the format.json check entirely and cannot recognise it. That case is covered not by the format gate but by the coordinated cutover — every .tckb is re-baked to format 2 and every host upgraded together (see documents/shadow-dom-migration.md).

widget.mjs

A single-file ESM module — the output of esbuild configured for format: 'esm', platform: 'browser', target: 'es2022', classic JSX transform (React.createElement against a banner-injected import React from 'react'). The bundle:

  • Imports every TCK external bare-specifier listed in TCK_EXTERNAL_SPECIFIERS; imports anything else inlined.
  • Exports default — a React component (the widget root).
  • Exports manifest — the validated WidgetManifest object the bundler extracted from the widget source. (The bundler also serialises this object as manifest.json; see manifest.json.)
  • May export migrations — an array of { to: number, run(doc) } forward-only state migrations.

Wire format: UTF-8, no BOM, ends with \n. Soft cap: 256 KB (tck-bundler's default; configurable via maxSizeBytes). Exceeding the cap is a build-time error, not a load-time error.

The unpacker exposes the post-decode size of widget.mjs as mjsByteLength on the unpack result. This is the unpacked byte length — not the .tckb file size, not the gzipped transfer size — and is the canonical "did this bundle stay under our budget?" measurement.

manifest.json

JSON serialisation of the manifest named export inside widget.mjs, pretty-printed with 2-space indent. The host uses it as the registry view of the widget without paying for a dynamic import.

The runtime parses only the structural subset it needs to identify the bundle (parseManifest in src/manifest.ts):

interface WidgetManifestSummary {
  readonly id: string // non-empty
  readonly name: string
  readonly version: string
  readonly schemaVersion: number // positive integer
}

The full schema (sizes, defaultSize, permissions, source, …) lives in @todayai-labs/tck's types.ts and is validated by validateManifest (packages/tck/src/manifest/validate.ts). The wire-format-layer check stays minimal so any consumer can inspect a .tckb without pulling in the full SDK.

Where the full contract is enforced

validateManifest runs at two independent trust boundaries, by design — not redundantly:

  • Producer edge — tck-bundler. When emitting a .tckb, bundleWidget runs the full validateManifest on the in-memory manifest the bundle just exported, before packing. A malformed manifest (e.g. an id that fails ID_RE) fails fast at pnpm build instead of sailing through commit + distribution and only surfacing at host load. The bundler validates the object already in hand — it does not unpack its own .tckb (that would re-verify integrity the packer just computed, and re-extract a manifest already in memory).
  • Consumer edge — tck-host. The loader / registry run validateManifest again at load time. .tckb producers are pluggable (today-cloud reads and writes the wire format by hand, without the SDK), so the consumer can never assume the producer validated. This edge is non-negotiable; the producer-edge check is defence-in-depth that improves authoring DX, not a replacement.

The tck-bundle-format layer deliberately stays out of this — it validates the envelope (zip shape, integrity digests, the parseManifest structural subset above), never the manifest semantics, so it carries no dependency on @todayai-labs/tck and stays version-decoupled from the SDK's manifest contract.

Cloud-extractable manifest fields

Downstream consumers that index, persist, or route bundles by manifest content (today-cloud's widgets table being the reference consumer) SHOULD read only the fields below at ingest time. These fields are stable-at-v1: their names, types, and value spaces do not change without an ABI bump. Any other field in manifest.json is host- or SDK-internal and MAY change additively within ABI v1.

FieldTypeRequiredNotes
idstring (reverse-DNS)yesDrives manifestIdToScopeSlug. Non-empty.
namestringyesHuman-readable label. Non-empty.
versionstring (semver)yesWidget code version; orthogonal to ABI label.
schemaVersionpositive integeryesBumped on state-shape change; drives migrations matching.
sizesnon-empty WidgetSize[]yesAllowed render sizes.
defaultSizeWidgetSize (∈ sizes)yesValidator enforces membership.
defaultStateJsonValueyesInitial doc-store state. Optional at authoring (defaults to {}); the bundler bakes the resolved value into the wire form, so it is always present here. A stateless card just carries {}.
cardType'widget' | 'feed'noDefaults to 'widget'. A presentation hint, not a routing switch. See cardType semantics.
iconstring (URL or data-uri)noHost falls back to a placeholder when absent.

Fields the contract does not promise to downstream consumers (read at your own risk; subject to additive evolution within v1): description, permissions, source, and any future field not yet listed. validateManifest may accept additional fields as the SDK grows; consumers that pin a strict allow-list should refresh it on each ABI label bump rather than each SDK release.

cardType semantics

cardType is a presentation hint, not a behavioural switch. It never gates a capability and never routes layout — the size variant decides layout. A downstream consumer MAY read it as a hint for presentation defaults or storage shape, but MUST NOT treat it as the authority for where a card renders.

  • cardType: 'widget' (default). Marks the interactive, grid-snapped tile.
  • cardType: 'feed'. Marks a read-only snapshot card; conventionally pairs with fill-auto.

Layout is decided by isAutoHeightSize(size), not cardType: a fill-auto instance renders in the feed stack, a fixed CxR instance renders on the grid. Every widget keeps the full API (doc store, hooks) regardless of cardType — a feed card being read-only is a content convention, not a host-enforced gate.

Routing guidance for downstream consumers in ABI v1:

  • Today Page V2 cards on today-cloud are linear feed entries; the agent SHOULD emit cardType: 'feed' (paired with the default fill-auto) for that pipeline. Cloud storage and feed-list APIs are routing-blind on cardType (they route on a separate cloud-owned kind enum).
  • Workbench-style grid hosts partition by size variant, not cardType<TodayWidgetCanvas> takes the fixed CxR sizes, <TodayFeedStack> takes fill-auto (isAutoHeightSize).

widget.css

The widget's compiled Tailwind utilities and design tokens — UTF-8-encoded CSS.

Format 2: widget.css is unscoped. Unlike format 1 — where every selector was rewritten under :where(.tck-widget-<slug>) — a format-2 widget.css carries plain, unscoped selectors. It is correct only when the host adopts it into the widget's shadow root via adoptedStyleSheets: the Shadow DOM boundary is what isolates the widget's styles from the host page, replacing the retired selector rewrite. A format-2 widget.css applied to light DOM would leak globally — this is the core reason a format-2 bundle is not interoperable with a format-1 host.

If widget.css is absent, the widget ships no bundled stylesheet (the reference unpacker returns css: null); the host's mount still proceeds, the widget just renders with only the shared reset sheet adopted into its shadow root.

widget.properties.css

UTF-8-encoded CSS containing only @property registrations (typed custom-property declarations) — e.g. the registrations Tailwind v4 emits for shadow-*, ring-*, gradient stops, and animated transforms.

@property is document-scoped per the CSS spec — an @property rule inside a shadow root's adopted stylesheet has no effect. So format 2 ships the @property stream as a separate entry: the host injects widget.properties.css into the document head (deduped by property name across all bundles on the same Tailwind version), while widget.css is adopted into the shadow root. Splitting them is what keeps widget.css correct as a shadow-adopted sheet.

If the widget registers no typed properties, widget.properties.css is absent (the reference unpacker returns propertiesCss: null). A missing widget.properties.css is non-fatal: typed-property utilities degrade gracefully (shadow-lg renders without the typed-property animation; the box-shadow itself still applies), and the host warns once per URL rather than blocking the mount.

integrity.json

Optional. Every field is itself optional. The producer always emits a widget.mjs digest, and emits a CSS-entry digest only when the corresponding CSS file is present.

{
  "widget.mjs": { "sha384": "<base64url-no-padding>" },
  "widget.css": { "sha384": "<base64url-no-padding>" },
  "widget.properties.css": { "sha384": "<base64url-no-padding>" },
}

parseIntegrity accepts the file as long as the root is a JSON object. Verification semantics (verifyIntegrity):

  • Present file + declared digest matches actual bytes → ok.
  • Present file + declared digest mismatch → TckbUnpackError.
  • Present file + missing field → that entry skipped (no integrity declared).
  • Absent file → all entries skipped.
  • Declares a CSS-entry digest but the zip has no matching entry → TckbUnpackError.

Legacy bundles built before the SHA-384 migration may carry sha256 instead of sha384. The current parser tolerates the legacy field name but does not verify it; the current bundler emits sha384 only.

Hash format

Source: src/hash.ts.

  • Algorithm: SHA-384 (48-byte digest).
  • Encoding: base64url, unpadded (+-, /_, = stripped).
  • Length: exactly 64 ASCII characters (SHA384_BASE64URL_LENGTH).
  • Alphabet check: /^[A-Za-z0-9_-]{64}$/ (isSha384Base64Url).
  • Domain: hashed over the raw entry bytes (no UTF-8 re-encoding for string inputs — callers convert with TextEncoder first).
  • Wire form: same shape today-platform-web uses in /api/widgets/v1/<hash> URLs, so a bundle's widget.mjs hash is the bundle's URL-addressable id.

The unpacker computes widget.mjs's sha384 unconditionally (bundleHash in the result), independent of whether integrity.json declared it. Callers wanting an upfront integrity claim pass verifyHash to unpackTckb.

The bundle id is content-addressed on widget.mjs only. A CSS-only rebuild — e.g. a re-bundle that shifts widget.css or widget.properties.css byte-for-byte but leaves widget.mjs identical — does not change the bundle id. This keeps content-addressable URLs stable across CSS-only ABI updates so cache warming and CDN deduplication work as expected.

Unpack contract

import { unpackTckb } from '@todayai-labs/tck-bundle-format'

const { bundleHash, mjs, mjsByteLength, css, propertiesCss, manifest, integrity } =
  await unpackTckb(bytes, { verifyHash: '<optional-expected-sha384>' })
  • Gates format.json first: a pre-#57 (format-1) bundle or one declaring an unsupported tckbFormat throws TckbUnpackError before any presence check, so the failure message names the format mismatch rather than a confusing downstream symptom.
  • Throws TckbUnpackError for any structural problem (missing format.json, unsupported format, malformed zip, missing required entries, bad JSON, integrity mismatch, hash claim mismatch).
  • css and propertiesCss are Uint8Array | nullnull when the bundle ships no entry of that name.
  • manifest is returned as an unknown raw JSON value — callers run validateManifest from @todayai-labs/tck for the full schema check.
  • mjsByteLength is the post-decode byte length of widget.mjs (see widget.mjs).
  • Runs identically in Node and in the browser; only deps are fflate and crypto.subtle.digest.

See also

  • Platform ABI — the full contract this envelope is part of.
  • Versioning arrow — why this page is the canonical home for the wire format and what that means for downstream specs.
  • packages/tck-bundle-format/FORMAT.md — the in-package spec this page mirrors. Both move together; if one is updated and the other isn't, the in-package FORMAT.md wins.

On this page