.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.jsonentry'stckbFormatfield. - Compatibility: not interoperable with format 1. Format 2 is the Task #57 Shadow-DOM cutover, and it changes the meaning of
widget.css(seewidget.css) — a format-1 bundle and a format-2 host do not work together in either direction. Theformat.jsondiscriminator 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.csswas rewritten so every selector was scoped under:where(.tck-widget-<slug>); there was nowidget.properties.cssand noformat.json. A format-1 bundle carries noformat.jsonat all — its absence is the format-1 signal. - Format 2 — the Task #57 Shadow-DOM cutover (this spec).
widget.cssis unscoped and adopted into a shadow root;@propertyregistrations move to a separatewidget.properties.cssentry; a mandatoryformat.jsonentry stamps the envelope version.
Producer and consumer
Both ends live in @todayai-labs/tck-bundle-format:
- Producer:
src/pack.ts→packTckb(input)— programmatic envelope construction. Every writer (the build pipeline, ad-hoc tooling, tests) goes through this. - Producer caller (build pipeline):
@todayai-labs/tck-bundler'sbundle.tsderives the manifest from the compiledwidget.mjs, hands it topackTckb, and writes the bytes to disk. - Consumer:
src/unpack.ts→unpackTckb(bytes, options?)— runs identically in Node and the browser; only deps arefflateandcrypto.subtle.digest. Gatesformat.jsonfirst; throwsTckbUnpackErroron 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:
| Entry | Required | Behaviour when absent |
|---|---|---|
format.json | yes | TckbUnpackError (format-1 / pre-discriminator; bundle must be re-baked) |
widget.mjs | yes | TckbUnpackError: .tckb is missing widget.mjs |
manifest.json | yes | TckbUnpackError: .tckb is missing manifest.json |
widget.css | no | widget renders with only the shared reset adopted into the shadow root |
widget.properties.css | no | typed-property utilities (shadow-*, ring-*, gradients) degrade (warned, never blocking) |
integrity.json | no | unpack 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:
tckbFormatequals the reader's supported version → proceed.tckbFormatlower than supported → the bundle predates this format; reject it (it must be re-baked). For a current reader this is any format-1 bundle.tckbFormathigher than supported → the bundle is newer than the reader; reject it (the host must be upgraded).format.jsonabsent → the bundle predates the discriminator entirely (format 1); reject it as un-loadable.format.jsonpresent but malformed (not JSON, not an object, or no integertckbFormat) → reject it as a broken bundle — a corruptformat.jsonis 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 validatedWidgetManifestobject the bundler extracted from the widget source. (The bundler also serialises this object asmanifest.json; seemanifest.json.) - May export
Expanded— a React component used whenmanifest.expandable === true. The host rejects bundles that claimexpandable: truebut ship noExpanded. - 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, expandable, 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.
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.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (reverse-DNS) | yes | Drives manifestIdToScopeSlug. Non-empty. |
name | string | yes | Human-readable label. Non-empty. |
version | string (semver) | yes | Widget code version; orthogonal to ABI label. |
schemaVersion | positive integer | yes | Bumped on state-shape change; drives migrations matching. |
sizes | non-empty WidgetSize[] | yes | Allowed render sizes. |
defaultSize | WidgetSize (∈ sizes) | yes | Validator enforces membership. |
defaultState | JsonValue | yes | Initial doc-store state for cardType: 'widget'; ignored for cardType: 'feed' (see below) but still required syntactically. |
cardType | 'widget' | 'feed' | no | Defaults to 'widget'. See cardType semantics. |
expandable | boolean | no | When true, the bundle MUST also export Expanded. |
icon | string (URL or data-uri) | no | Host 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 carries two values with different host behaviours; downstream consumers MAY route on it to pick a rendering region or a storage shape:
cardType: 'widget'(default). Lives in the host's grid canvas. Sized to a grid cell. Draggable. Has a doc store backinguseFieldState/useDoc. Honourssizes/defaultSize.cardType: 'feed'. Lives in the host's feed stack. Always full-width with intrinsic height;sizes/defaultSizeare ignored by host placement. Presentation-only —useFieldState/useDocare no-ops andctx.applyPatchwarns and drops the call.
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'for that pipeline. Cloud storage and feed-list APIs are currently routing-blind oncardType(they route on a separate cloud-ownedkindenum), but they MAY tighten to validate or filter oncardTypewithout an ABI bump. - Workbench-style grid hosts route on
cardTypedirectly —<TodayWidgetCanvas>filters out'feed',<TodayFeedStack>filters in only'feed'.
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
TextEncoderfirst). - Wire form: same shape today-platform-web uses in
/api/widgets/v1/<hash>URLs, so a bundle'swidget.mjshash 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.jsonfirst: a pre-#57 (format-1) bundle or one declaring an unsupportedtckbFormatthrowsTckbUnpackErrorbefore any presence check, so the failure message names the format mismatch rather than a confusing downstream symptom. - Throws
TckbUnpackErrorfor any structural problem (missingformat.json, unsupported format, malformed zip, missing required entries, bad JSON, integrity mismatch, hash claim mismatch). cssandpropertiesCssareUint8Array | null—nullwhen the bundle ships no entry of that name.manifestis returned as anunknownraw JSON value — callers runvalidateManifestfrom@todayai-labs/tckfor the full schema check.mjsByteLengthis the post-decode byte length ofwidget.mjs(seewidget.mjs).- Runs identically in Node and in the browser; only deps are
fflateandcrypto.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.
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.
Container contract (size / theme)
Typed ContainerParams the host offers a widget, the size-mismatch check at the mount boundary, and how fill-auto interacts with cardType.