A widget is mini-HTML: one self-contained HTML document with inline CSS and
JS, at most 4 KB compressed and 32 KB uncompressed, delivered right in the
message. This repository is the framework every Telegram client runs it with,
released as tags (v0.1.0 is the first).
Writing widgets (or prompting a model to): docs/widget-runtime-profile.md
is the authors' guide, with a block for a model's system prompt
(corepack pnpm tsx packages/core/scripts/sandbox-node.ts --guide) and how to test a widget at a
bubble width and with presses.
What the framework does:
- The widget's JavaScript runs only inside QuickJS, on its own thread, with
no network capability at all:
fetch, XHR, WebSocket and the rest are stubs. It sees a DOM shim, not a browser. - Everything the widget produces is validated by trusted host code against
allowlists: elements, attributes, CSS, URLs (
data:images and#fragmentonly). The validator enforces widget profile 1 (docs/profile-v1.md, generated frompackages/core/profile.json): the same features drawn the same way on every client, no form controls, no keyboard, no bundled libraries. - The renderer draws validated ops only. On the web that is the Shadow DOM renderer inside the client's page; native clients choose their renderer (WKWebView, Android WebView, or an engine of their own) behind one seam and receive the same ops.
- SDK v1 (
docs/sdk-v1.md):TelegramWidgetwith the theme, profile detection,openLink/openTelegramLinkgated by a real tap, haptics, and Web Storage kept per message by the client. No messages to the bot in v1. strictpolicy by default on every host, with runtime limits: CPU quota per second, a 64 MB memory cap, 256 timers, off-screen pause, and the last frame kept when a widget is killed.
Client (trusted)
├─ QuickJS runtime (own thread): the widget's scripts against the DOM shim (untrusted)
│ ── ops, canvas commands, sync queries ──▶ host
│ ◀── events, timer/frame ticks, query answers, SDK calls ── host
├─ Validator: allowlists, CSS sanitizer, URL policy, widget profile 1, SDK v1 policy
└─ Renderer: Shadow DOM (web) · WKWebView · WebView · a native engine — validated ops only
The same three JS bundles (sandbox shim, validator, renderer) ship to every platform with precompiled QuickJS bytecode; each platform adds only glue. The engine is the TelegramMessenger/quickjs fork, a git submodule.
twidget is the project's working name and survives as an internal codename
(the twidget-run / twidget-tests binaries, the TwidgetDemo apps, the
tw* prefixes); the repo and the packages a client depends on are widget-sandbox
(org.telegram.widgetsandbox where a hyphen is not allowed).
Pin the latest tag (v0.1.1), never a branch: the bundles, the bytecode and the
QuickJS submodule change together. Then read, in this order:
docs/client-checklist.md |
one page of checkboxes: adopt, PoC on layer-233 messages, SDK v1, test, send back |
docs/integration.md |
how to pin the tag per package manager, create a WidgetHost, pass the policy and features, handle the widget payload, size the bubble, what input a widget gets, the per-platform API |
docs/tgw-format.md |
the widget payload: messageMediaWidget (layer 233), the server's checks and errors, caption, old clients |
docs/profile-v1.md, packages/core/profile.json |
widget profile 1: what a widget may use and what the validator does with the rest |
docs/sdk-v1.md |
the TelegramWidget API, the protocol, the host interface on each platform and what the client implements |
packages/conformance/CLIENTS.md |
the conformance set: run it on your renderer and compare with the other clients |
docs/changes.md |
release notes, known issues, API changes per platform |
Changing the framework: CONTRIBUTING.md.
Packages: Swift package WidgetSandbox (repo-root Package.swift, iOS and
macOS), Gradle library org.telegram.widgetsandbox:widget-sandbox
(packages/android), npm @telegram/widget-sandbox (packages/core), C++
core packages/desktop. docs/decisions.md records why things are the way
they are; docs/protocol.md is the wire protocol.
Everything is built from this repo; nothing is fetched at run time. What you need depends on which part you touch:
| To work on | Install |
|---|---|
| the web package, the dev app, the Playwright suites | Node ≥ 22 with corepack (corepack pnpm — the repo pins pnpm in package.json); then corepack pnpm exec playwright install chromium once for the browser suites |
packages/ios / apps/ios, apps/macos |
Xcode 26 (iOS 16 / macOS 13 deployment targets); xcodegen to regenerate the demo projects; a C compiler is already there for qjsc |
packages/android / apps/android |
JDK 21, Android SDK with platform 35, NDK 28.2 and cmake 3.31 (local.properties or ANDROID_HOME); Gradle comes from the wrapper |
packages/desktop |
cmake ≥ 3.22, ninja, a C++20 compiler |
the renderer prototypes (apps/renderer-blitz, apps/renderer-litehtml) — optional, dev tooling |
rustup's cargo (+ cargo-ndk for Android, wasm-bindgen-cli for the web build); cmake + ninja (+ emscripten for the litehtml web build) |
Clone with git submodule update --init (the QuickJS fork in
packages/ios/Sources/CQuickJS/quickjs; SwiftPM does it for package
dependencies). The native packages ship the JS bundles and their bytecode, so
a client needs none of the Node tooling; only changing packages/core/src
requires pnpm build:<platform> (which compiles packages/tools/qjsc from the
submodule on first use, into .cache/).
corepack pnpm install
corepack pnpm dev # dev app on http://127.0.0.1:5173 — pick a corpus file, drop any .html
corepack pnpm typecheck && corepack pnpm test:unit
corepack pnpm test:attacks # the security suite: 99 files, 0 canary hitsNative: xcodebuild test -scheme WidgetSandbox-Package -destination 'platform=macOS'
for the Swift package; cd packages/android && ./gradlew :widget-sandbox:assembleDebug
(+ connectedDebugAndroidTest with an emulator); cd packages/desktop && cmake -S . -B build -G Ninja && cmake --build build && ./build/twidget-tests.
The demo apps are in apps/, each README has the commands.
| Suite | What it proves | Run |
|---|---|---|
unit (packages/core/tests/unit, apps/web/tests/unit) |
validator, CSS sanitizer and profile rules, the SDK policy against the shared fixture, the DOM shim through QuickJS in Node, the CPU budget against the shared fixture, the wasm memory cap, the shipped bundles against a fresh build | pnpm test:unit |
attacks (tests/attacks.spec.ts, corpus/attacks) |
every exfiltration vector gets 0 canary hits and 0 outside requests; also on the Shadow DOM renderer with TW_RENDERER=shadow |
pnpm test:attacks |
| activation, sdk, lifecycle | privileged calls need a real tap; the SDK on the web host (theme variables, gesture claims, storage across a restart); off-screen pause | pnpm test:activation, test:sdk, test:lifecycle |
| compat / generated / common / showcase / htmlbubbles | widgets load, run their scripted interactions, and the sandbox render matches the native reference (screenshot diff) → docs/compat-report*.md |
pnpm test:compat … |
conformance (packages/conformance) |
the profile features, checked by the widgets themselves on each renderer → packages/conformance/results |
scripts/conformance/run-web.ts, CLIENTS.md for a client |
| Swift package (macOS 220, iOS 221 tests), Android instrumented (60), desktop (45; 51 with the renderer prototypes) + the attack corpus with 0 leaks on every renderer | the same invariants through the native glue: engine, validator, renderer, limits, pools, SDK | see above |
| demo autoruns (`--autorun attacks | generated | compat |
Before a commit that touches packages/core/src: typecheck, unit, attacks,
and pnpm build:ios/android/desktop so the shipped bundles and .qbc
match the source (a stale .qbc is refused at load time and the native
tests fail).
pnpm install
pnpm dev # dev app on http://127.0.0.1:5173 (COOP/COEP set by Vite)
pnpm test:unit # validator / sanitizer / profile / SDK unit tests + shim regression tests through QuickJS (Node)
pnpm test:attacks # every corpus/attacks/*.html → zero canary hits, no outside requests
pnpm test:compat # corpus/compat/*.html in sandbox vs native reference → docs/compat-report.md (screenshots in reports/compat/)
pnpm test:generated # corpus/generated/** (committed corpus, or regenerate with pnpm generate-corpus) → docs/compat-report-generated.md
pnpm typecheck
pnpm tsx packages/core/scripts/sandbox-node.ts corpus/compat/tabs.html --verbose # run a widget through the sandbox in Node
pnpm tsx packages/core/scripts/profile-build.ts # regenerate docs/profile-v1.md and the profile tables from packages/core/profile.json
ANTHROPIC_API_KEY=… pnpm generate-corpus [--model claude-sonnet-5-5] [--count 20]The committed corpus/generated/ (20 prompts × {without,with}-profile) was authored
by Claude inside a Claude Code session rather than through the API (no key was
available); manifest.json in each batch says so. Results and caveats are in
docs/compat-report-generated.md and the "Phase 6" section of docs/decisions.md.
The dev app: corpus list and drag-and-drop on the left, the widget in a
chat-bubble-sized frame in the middle (light/dark toggle, renderer switch,
reference-mode toggle), diagnostics on the right (widget console, unsupported
APIs, ops/queries/latency, JS time, memory, errors). The iframe renderer stays
the dev app's default and the reference; the Shadow DOM renderer (?shadow=1)
is the product one. URL parameters: ?file=compat/tabs.html&theme=dark&reference=1.
packages/ is the framework — what a client depends on; nothing in it imports
from apps/. apps/ is the demos, the native renderer prototypes and the dev
tooling, consuming the packages the way a client would. corpus/, tests/
(Playwright, run against apps/web) and docs/ are shared.
| Path | What |
|---|---|
packages/core/ |
npm package @telegram/widget-sandbox: src/host/ (trusted: widget-host.ts, worker.ts, validator.ts, css-sanitizer.ts, url-policy.ts, allowlist.ts, shadow-renderer.ts), src/renderer/ (trusted code on the shadow root / inside the dev iframe), src/sandbox/ (untrusted once loaded: the DOM shim over linkedom and the TelegramWidget SDK), src/shared/ (protocol.ts, profile-tables.ts, sdk-policy.ts), profile.json; vite.ts (the plugin that builds the bundles), scripts/ (bundle build, profile build, Node harness), tests/unit/ |
packages/ios/ |
Swift package WidgetSandbox (the QuickJS fork + WKWebView, same JS bundles) for iOS and macOS; the manifest is the repo-root Package.swift — see packages/ios/README.md; pnpm build:ios builds its bundles |
packages/android/ |
Gradle library widget-sandbox = org.telegram.widgetsandbox:widget-sandbox (the same QuickJS sources via NDK + android.webkit.WebView, same JS bundles) — see packages/android/README.md; pnpm build:android |
packages/desktop/ |
Qt-free C++ core for tdesktop-class hosts — see packages/desktop/README.md; pnpm build:desktop |
packages/conformance/ |
the widget profile conformance set: feature catalogue, 40 self-checking widgets, 20 visual widgets and 17 client-SDK cases (every one a mini-widget), reference pictures, the test-channel post list, the report tool — see packages/conformance/README.md and CLIENTS.md |
packages/quickjs-wasm/ |
the QuickJS fork compiled to wasm for the web host and the Node harness (build.sh; the output is committed and reproducible with the pinned emscripten: build.sh --check [--docker] against dist.sha256) |
packages/tools/qjsc |
bytecode compiler from the QuickJS submodule (.qbc next to each bundle) |
apps/web/ |
the dev app (src/main.ts, exposes window.__tw for the Playwright suites), the chat feed demo (src/feed.ts), bench pages, the Blitz / litehtml wasm renderer glue (src/renderers/) |
apps/ios/DemoApp, apps/macos/DemoApp |
SwiftUI demos (the macOS one shares the iOS sources) with `--autorun attacks |
apps/android/ |
the Compose demo and the Blitz / litehtml renderer modules; a second Gradle build over packages/android (includeBuild) |
apps/renderer-blitz/, apps/renderer-litehtml/ |
native renderer prototypes behind the Renderer seam (Rust crate; C++ core with vendored litehtml) — dev tooling, not a product path |
corpus/attacks/ |
one exfiltration vector per file (99) |
corpus/compat/, corpus/common/, corpus/showcase/, corpus/generated/, corpus/htmlbubbles-ported/, corpus/out-of-profile/ |
realistic widgets on profile 1 with scripted interactions; the out-of-profile set shows what the validator removes |
tests/ |
Playwright suites, canary server |
docs/ |
integration.md, tgw-format.md, profile-v1.md, sdk-v1.md, changes.md, decisions.md, protocol.md, widget-runtime-profile.md, the compat reports, native-port-notes.md, the renderer experiments |
See CLAUDE.md ("Invariants") and the per-file comments in packages/core/src/host/
and packages/core/src/renderer/. In short: widget JS only in QuickJS; everything from the
sandbox is validated against allowlists and widget profile 1; only data: images, host
blob: and #fragment URLs reach the renderer; the renderer runs trusted code only, on a
page whose CSP closes the network (Shadow DOM) or in a frame with a strict per-widget CSP,
and cancels every navigation; QuickJS has no network capability at all.