The browser runtime of Chatwright — the
orchestrator embedded in the Studio Playground/player component, and the
second implementation of the runtime concept alongside
chatwright.dev/runtime (Go).
Naming note: the repository is runtime-ts and the package is
@chatwright/runtime — repositories carry the language suffix because
they share one namespace; package names don't, because each registry is
already language-scoped. The Go sibling follows the same rule: repository
runtime-go, module chatwright.dev/runtime.
Status: iframe transport + HTTP webhook/inline-response slice + Telegram & WhatsApp codecs + deterministic expect layer
This repository has moved past interfaces-only. Working, tested code now
exists for all three seams decision 0012 names, wired together by a
Session orchestrator, plus a deterministic scenario-verb layer on top of it:
IframeHost(src/protocol/iframe-host.ts) — the host side of the iframe postMessage handshake: origin-validated hello/hello-ack,MessagePorthandoff, ordered update queueing until handshake, session reset on a repeated hello, and call/result correlation.TelegramCodec(src/telegram/codec.ts) — buildsmessage/callback_queryupdates and answerssendMessage,editMessageText,answerCallbackQueryandgetMe; everything else returns Telegram's own501shape and journals an"uncaptured"entry. See Fidelity below for exactly what is and is not covered.WhatsAppCodec(src/whatsapp/codec.ts) — the runtime's second platform codec, following the samePlatformCodecseam: buildsmessageswebhook updates and answers a"sendMessage"call carryingtype: "text"; everything else returns the WhatsApp Cloud API's own{"error": {...}}shape and journals an"uncaptured"entry. Text only — no interactive buttons, no message edits (WhatsApp's Cloud API has no edit endpoint at all); see Fidelity below and the capability data and runtime parity register for exactly what that means.InMemoryJournal(src/journal/in-memory-journal.ts) — append-only, per-chat, withsubscribe()and an injectableClock.Session(src/session/session.ts) — ties the three together: register one bot,submitText/submitClick, andtoBundle()to produce a run-bundle v1 document that validates against the schema. Codec-agnostic: it drives whicheverPlatformCodecSessionOptions.codecsupplies (Telegram by default, WhatsApp vianew WhatsAppCodec(...)) —platformand everyplatformIdentitieskey in the bundle it produces come from that codec, never a hardcoded string.chatOf/Chat/BotMessageExpectation(src/expect/) — the deterministic expect layer: a PlaygroundChat-style handle (sendText,click,expectBotMessage,expectEdited) built onSession's journal via subscribe-based waiting (never polling), with a per-chat consumption cursor and transcript-bearing failures — the TypeScript twin ofruntime-go'scwpackage (chatwright/runtime-go/cw/{chat,expect}.go), ported as an algorithm per decision 0012. See Fidelity below anddocs/architecture.mdfor the full deviation list versus Go.
Read docs/architecture.md for the session model,
how the three seams fit together, the planned (not yet built) live-append
path into the Studio player, and exactly what remains deferred. Deep design
for what's not here yet still happens in dedicated sessions tracked as
research backlog items in
chatwright/chatwright's
spec/research/knowledge-platform.md:
- I-66 — browser runtime internals beyond this slice: a multi-bot registry, scenario execution, request routing across bots, and how the player's rendering accepts a live, append-only journal instead of a finished bundle.
- I-67 — platform emulation fidelity in TypeScript beyond this slice, and shared conformance fixtures proving parity with the Go emulator.
- I-68 — the bot protocol envelope's full specification: error
semantics, timeouts, port lifecycle, multi-chat routing, version
negotiation, iframe
sandbox/CSP attributes.
This package's progress against runtime-go is also tracked in the
cross-repo runtime parity register
(decision 0015).
Once built out, the runtime embedded in the Playground/player component owns:
- Scenario execution — driving a scenario against one or more bots.
- Bot registry — tracking which bots are addressable and how.
- Request routing — getting a platform update to the right bot.
- Response correlation — matching a bot's method call back to the request that provoked it.
- Platform emulation — presenting a platform-native surface (Telegram first) to black-box bots.
- Transport abstraction — iframe vs remote HTTPS, one mental model.
- Recording — journalling every exchange as a side effect of routing.
- Replay — driving a recorded run back through the same seams.
- State — the append-only journal as ground truth.
Chatwright defines no generic bot API. A Telegram bot receives Telegram Bot API updates and answers with Telegram Bot API method calls; a WhatsApp bot speaks WhatsApp Cloud API payloads; and so on per platform. Chatwright owns only the minimal routing envelope where a transport needs one — the payload inside is always the platform's own wire format, opaque to Chatwright.
Two transports share that model, "point your bot at Chatwright":
- Iframe + postMessage — the runtime loads
<iframe src="bot-url">and speaks to it with a handshake, aMessagePorthandoff, and an envelope ({id, kind, platform, payload}) carrying platform-native JSON. The envelope shape issrc/protocol/envelope.ts; the working host-side implementation issrc/protocol/iframe-host.ts(this slice). - Remote HTTPS —
HttpTransportimplements webhook delivery and executes platform method calls returned inline in successful webhook responses (Telegram JSON and form encoding). The broader emulated platform API base URL and long-polling surface remain deferred.
Full rationale lives in decision 0012 ("Black-box bots over platform-native payloads; a browser runtime").
src/
protocol/envelope.ts the iframe postMessage envelope + PROTOCOL_VERSION
protocol/iframe-host.ts IframeHost: handshake, port management, call correlation
transport/transport.ts BotTransport interface; IframeTransport; HTTP webhook/inline-response transport
platform/codec.ts PlatformCodec interface (per-platform, Telegram first)
telegram/codec.ts TelegramCodec: builds updates, answers Bot API calls
whatsapp/codec.ts WhatsAppCodec: builds webhook updates, answers sendMessage (text only)
journal/journal.ts Journal + JournalEntry interfaces, mirrors platform.JournalEntry
journal/in-memory-journal.ts InMemoryJournal + Clock/systemClock
session/session.ts Session orchestrator; toBundle() → run-bundle v1
runtime/runtime.ts ChatwrightRuntime orchestrator interface (still provisional, I-66)
expect/chat.ts Chat + chatOf(): sendText/click/expectBotMessage/expectEdited, the consumption cursor
expect/bot-message.ts BotMessageExpectation: text/expectText/expectActions/within
expect/wait.ts subscribe-based waitForCondition() + the nth-outbound-message / latest-edit lookups
expect/transcript.ts renderTranscript(): the prose embedded in every expect-layer failure
testkit/fake-bot.ts test-only: drives the bot side of the protocol over a MessagePort
index.ts re-exports
These are the three seams decision 0012 decomposes the Go emulator's
monolith into — platform codec, transport, journal + observation — plus the
envelope that ties transports together, Session that ties the seams
together for this slice, expect/ that layers the deterministic scenario
verbs on top of Session, and runtime/runtime.ts's still-provisional
ChatwrightRuntime interface for the fuller orchestrator I-66 will design.
Chatwright's cross-language contracts are language-independent formats:
- The run-bundle v1 schema
— the wire contract this runtime produces.
runtime-tsis now its second producer:Session.toBundle()assembles a document in the schema's camelCase wire shape, andsrc/session/session.test.tsvalidates a real scripted exchange's output against a vendored copy of the schema with ajv. This package still does not generate TypeScript types from the schema (decision 0012's "shared contracts are formats, never code");toBundle()returnsunknowndeliberately — seesrc/journal/journal.tsfor the live, in-memory journal shape this converges from. - The bot-protocol envelope (this repository's
src/protocol/envelope.ts, eventually promoted to a JSON Schema underformats/per I-68). CHATWRIGHT.md— the repository manifest format (decision 0013).
Conformance between the Go and TypeScript runtimes is proven by shared fixtures, never by shared libraries.
Fidelity is declared, never assumed (decision 0008): this is exactly what
this slice covers, checked directly against src/telegram/codec.ts,
src/whatsapp/codec.ts, src/protocol/iframe-host.ts and src/expect/ —
not an aspiration. See docs/architecture.md for
the design behind each row and the
runtime parity register
for how this compares to runtime-go.
Bot API methods (bot → host, over the iframe transport):
| Method | Status |
|---|---|
getMe |
Supported. Always the fixed identity (id: 1, is_bot: true, first_name: "ChatwrightBot", username: "chatwright_bot"). |
sendMessage |
Supported, validated. chat_id and non-empty text are required (else a Telegram-shaped 400); reply_markup inline keyboards are parsed and normalised into journal actions. |
editMessageText |
Supported, validated. chat_id and message_id are required; appends a new, versioned journal entry rather than mutating the original; removes the existing keyboard when reply_markup is omitted (matches real Telegram: a keyboard survives an edit only if the call explicitly re-sends reply_markup); 400 if the target message isn't found. |
answerCallbackQuery |
Acknowledged, no-op. No journal entry — it produces no observable chat content. |
answerInlineQuery |
Supported, validated. Correlates the answer with a submitted inline_query; normalises article/photo result content, photo and thumbnail URLs, captions and inline keyboards for deterministic scenario observation. JSON and form-encoded webhook-response methods share the same path. It does not invent a chat message: selecting a result is a separate client action. |
setWebhook, deleteWebhook, setMyCommands |
Unsupported, errors (501). Deliberately narrower than runtime-go, which acknowledges these as no-ops: the iframe transport has no webhook concept at all, so this codec does not special-case them. See docs/architecture.md. |
Everything else (sendPhoto, sendDocument, sendPoll, deleteMessage, pinChatMessage, …) |
Unsupported, errors (501). Returns {"ok":false,"error_code":501,"description":"method not emulated: <method>"} and journals an "uncaptured" entry — never silently swallowed. |
Update types (host → bot):
| Update | Status |
|---|---|
message (plain text) |
Supported. |
callback_query (inline-keyboard click) |
Supported. |
inline_query |
Supported. Session.submitInlineQuery and Chat.sendInlineQuery deliver the chat-independent update and expose the correlated answer without treating it as a posted message. |
edited_message, media messages, group/channel updates |
Not implemented in this slice. |
WhatsApp Cloud API calls (bot → host, over the iframe transport):
No buttons, no edits — this is a deliberately text-only slice. WhatsApp's
capabilities list is exactly ["messaging.text"] — no
messaging.buttons.inline, no messaging.message.edit — matching the Go
emulator's own "MVP-scope, text-first" package doc. See the capability data
in chatwright/recipes (data/capabilities/messaging.text.json,
messaging.buttons.inline.json, messaging.message.edit.json) and the
runtime parity register
for the authoritative, machine-checkable statement of this — not this
table's prose.
| Call | Status |
|---|---|
sendMessage with params.type === "text" |
Supported. Journals a bot message entry and returns the Cloud API's success envelope ({messaging_product, contacts, messages}). |
sendMessage with any other type (image, interactive, template, location, …) |
Unsupported, errors. Returns the Cloud API's own {"error": {"message","type","code","error_subcode","fbtrace_id"}} shape (type: "ChatwrightNotEmulated", code: 501 — chatwright's own honesty marker, not a real Meta error) and journals an "uncaptured" entry. Deliberately stricter here than runtime-go's emulator, which silently accepts any /messages POST as text — see src/whatsapp/codec.ts's module doc comment for the full reasoning. |
Any call whose method isn't "sendMessage" |
Unsupported, errors — same shape as above. |
| Interactive-reply clicks, message edits | Not implemented — no buttons, no edits. No buildCallbackUpdate at all (WhatsApp has no free-form inline-keyboard equivalent to emulate over this transport in this slice) and no edit call (the Cloud API itself has no edit endpoint to emulate). Session.submitClick throws an honest error for this codec rather than silently no-op'ing. |
WhatsApp update types (host → bot):
| Update | Status |
|---|---|
messages webhook change, type: "text" |
Supported. |
messages webhook change, any other type; status updates; interactive-reply webhooks |
Not implemented in this slice. |
Deterministic scenario verbs (src/expect/):
| Capability | Status |
|---|---|
chatOf(session, chatId, user) handle |
Supported. PlaygroundChat-style: sendText, click, expectBotMessage, expectEdited. Aliased per (session, chatId) — repeated calls return the same handle, sharing its cursor — mirroring Go's PrivateChat. |
| Consumption cursor | Supported, matches runtime-go's cw.Chat exactly. Each expectBotMessage() consumes the next not-yet-consumed outbound message to that chat, once, in order; expectEdited() targets a specific message identity (messageId + version) instead and never advances the cursor. |
expectBotMessage / expectEdited waiting |
Supported, subscribe-based (Journal.subscribe) — never polling. Default safety timeout 5s (DEFAULT_SAFETY_TIMEOUT_MS), overridable per call via {timeoutMs}. |
BotMessageExpectation assertions |
Supported: text() (getter), expectText(want), expectActions(...labels) (exact set, row-major flattened, order-sensitive), within(ms). |
within(ms) latency budget |
Supported, but narrower than Go's Within. Asserts against the latency already recorded when the message arrived ("after arrival"); unlike Go, it cannot retroactively extend how long expectBotMessage/expectEdited waited for that arrival — see docs/architecture.md for why. |
| Transcript-in-failure | Supported. Every thrown Error embeds a chronological chat transcript (src/expect/transcript.ts), ported as an algorithm from runtime-go's Emulator.Transcript/renderTranscript. |
click(actionIdOrLabel) |
Supported, but targets the chat's most recently resolved message, not an explicit (row, col) coordinate the way Go's BotMessage.ExpectAction(row, col).Click() does — matches a Playground chat's UI model directly. Matches by action id (callback_data) first, then by visible label. |
ExpectNoMessage equivalent |
Not implemented in this slice. |
| Portable scenario file format | Partially supported. src/scenario/document*.ts parses, validates and independently verifies scenario-document/v1 documents with full behavioural parity to runtime-go (same accept/reject rules, same rule codes, byte-identical verify detail strings). Executing a document is supported for bot.exampleBot: "greetbot" only (the one cross-runtime conformance fixture); a url-addressed bot validates but Build refuses it by name — see docs/runtime-parity.md, "Part 3". Scenarios written directly against the Chat/BotMessageExpectation API remain the general-purpose path — see research item I-71. |
Transport and scope:
| Capability | Status |
|---|---|
Iframe + postMessage transport |
Supported — handshake, ordered update queueing, session reset on repeated hello, call/result correlation. See docs/architecture.md. |
| Remote HTTPS transport | Partially supported. HttpTransport posts updates to a webhook and executes JSON/form-encoded platform methods returned inline in successful webhook responses through the normal Session codec/journal path. An emulated platform API listener, independent bot calls, and long polling remain deferred. |
| Multiple bots per session | Not implemented. Session.registerBot supports exactly one bot; a second call throws. |
| Multiple chats per session | Supported — Session journals and bundles every chatId it is addressed with. |
| Group/channel chats | Not implemented. Every Telegram chat is built as type: "private"; WhatsApp has no chat-type field on the wire at all — every update is simply addressed to one wa_id. |
| Run-bundle v1 output | Supported for a single deterministic part. toBundle() always emits one run with one kind: "deterministic" part spanning the whole journal; multi-part runs, AI-goal parts, bookmarks and annotations are not produced. |
| Live-append rendering (Studio player) | Not implemented. Journal.subscribe() is the seam; wiring it to the player is deferred to I-66 — see docs/architecture.md. |
npm install
npm run typecheck # tsc --noEmit
npm test # vitest runApache-2.0. See LICENSE.
Chatwright is an independent open-source project developed by Sneat.co.
Chatwright is developed spec-first with SpecScore —
product specs live in the standard repository;
this repository's own specs live under spec/.