Widgets — interactive pages your agents open for a person

A widget is a page an agent, a pipeline or a hook opens for a person: in Telegram, a message with a button that opens the page as a Mini App; on the dashboard, a page in the chat panel or on a request card; in Claude, ChatGPT or VS Code, a page their host renders. The page and your brain talk both ways — what the person does there arrives as events your code and agents react to, and your agent or your code pushes state, events and media back to the page while it is open.

What ships today. Widget files, the agent’s tools (widget_*), the pipeline helpers, delivery of submits as events and as turns, the page’s host (the Mini App shell, the sandboxed view, the page SDK), the person’s sign-in and the page’s calls into the engine. A widget opens in any chat your bot talks in — a private chat, a group, a forum topic, a channel, a business chat (§ 9) — and can be a person’s long-lived app on their menu button. The dashboard opens every widget, and one opened from a dashboard turn lives there (§ 13); an MCP Apps client (Claude, ChatGPT, VS Code) shows your widget files (§ 14).

Your agents also receive the widgets engine skill (skills/engine/widgets/, mirrored by omnislash upgrade) — the short authoring path of this page.

Contents: when to use one · the widget file · the agent’s tools · policy · what the page sends · the page API · security · limits · where it opens · worked example · hosting · HITL cards and the engine’s pages · the dashboard · MCP clients · in a browser · preview.

1. When a widget

The person needs to…Use
Read an answer, a short choice of 2–4 repliesa plain message (buttons are chat’s own)
Make a decision your run blocks on (approve, pick, fill a field)a HITL request — human-input.md. A widget event is never a blocking decision; the card itself may open a page that answers it (§ 12)
Read a report, a chart, a page to keepa published artifact — artifact-publishing.md
Interact: an exercise, a quiz, a form with many fields, a board, a picker over many items, audio with controls, a live view your agent updatesa widget

A widget costs a launch message and a page you (or your agent) write once as a file. It pays off when the interaction is richer than a message, happens many times, or must feed code without an agent turn.

2. The widget file

A widget is a file of a feature, like a scenario or a context:

features/<feature>/
├── <name>.widget.html   ← the page: one self-contained HTML document
└── <name>.widget.yml    ← optional: everything about it that is not HTML

It is addressed ui://<feature>/<name>. The name is unique across the brain (two files of one name in two features load neither). A widget has no switch of its own: its feature’s FEATURE.md enabled: false turns it off. The page is read verbatim — unlike a markdown entity, ${VAR} is never filled from the environment, because the page is served to whoever opens it. Put what the page needs from your brain into input (at open) or state (pushed).

<name>.widget.yml — every key optional, unknown keys refused:

KeyDefaultMeaning
descriptionnoneThe catalog line (feature_get); also the default launch text
policy{}This widget’s tier of the policy (§ 4) — any key but maxTtlHours, maxOpenPerAgent, retentionDays
tools[]Tools the page may call directly, with no agent turn (§ 6). Never one behind an approval. A tool no agent is offered (agents: false — page-only code) belongs here as well as any other
csp.connectDomains[]Origins the page may fetch / open a WebSocket to (https://…, wss://…). Empty = no network
csp.resourceDomains[]Origins images, media, fonts, scripts and styles may load from
permissions[]Device permissions the frame is allowed: camera, microphone, geolocation, clipboard-write
inputSchemanoneJSON Schema the opener’s input must satisfy — widget_create refuses an input that does not
description: A listening exercise
policy:
  ttlHours: 2
  deliver: events
tools: [kv_get]
csp:
  connectDomains: []
  resourceDomains: ["https://cdn.example.com"]
permissions: [microphone]
inputSchema:
  type: object
  properties: { level: { type: string } }
  required: [level]

A page-only tool — your createTool({ …, agents: false }), which no agent’s tool set offers — is allowed in tools: the page’s call runs it directly, so code the agent must never call (grading an answer against its key, starting a round’s clock) can still serve the page. A tool behind a capability that needs a person’s approval is refused in tools — a tap on a page has no card to wait on —, so is a tool that acts for a calling agent (one that controls agents — any agent:* capability: agent_send, agent_clear, … — or works in its checkout: bash, file_read, file_write, file_edit), so is a tool that writes widgets (widget_create, widget_update, widget_close, widget_widen — a page’s own text would come back to the agent as the opener’s side), and so is a name the page’s calls cannot run (they run in the worker, on its tool set — an integration that is not configured there has no tool); widget_create refuses such a widget, and doctor Widget tools names it first. A spec that does not load keeps its widget from opening (never “the defaults instead”), and omnislash doctor (Brain entities) names the file, as it names a .widget.yml with no page beside it.

An agent finds and writes widget files with the feature tools: feature_get / feature_list list each feature’s widgets (its ui:// address, the page and the spec beside it, the page’s sha); feature_read_file / feature_write_file with a path (features/<feature>/…/<name>.widget.html or .widget.yml) read and write one — created when absent, checked as the loaders read it, dry run first. A widget already open keeps the page it opened with.

An agent can also open an HTML file it published (artifact_publish) as an ad-hoc widget: artifact:<pub_id>. Such a widget has no spec — no tools, no outside origins, no permissions.

A page not written into a feature yet opens as a draft: widget_create({ draft: { path, specPath? } }) — the <name>.widget.html (and .widget.yml) the agent wrote in its artifacts directory — or { html, spec? } inline for a small page or a caller with no artifacts directory (a pipeline). It is the same draft widget_preview takes, read by the same code, so a draft that previews is a draft that opens. The engine stores the page as the widget’s own (no publication, nothing in Artifacts; it goes when the widget closes) and pins it like any page; widget_get shows its source as draft:<12 hex>, which is not an address file takes. The spec is honoured whole — a live test of a widget is a test of its tools — so a draft whose spec gives the page more than an artifact: page gets (any tools, permissions, csp domain or policy key) needs feature:write on top of widget:create; one with no spec, or only description / inputSchema, opens on widget:create alone. A HITL card never opens a draft, and an MCP Apps client does not render one (§ 14); the dashboard does. Test a page this way, never by publishing it.

The engine ships two pages of its own, addressed under its reserved feature id: ui://engine/form and ui://engine/review (§ 12). They open like your files (widget_create({ file: 'ui://engine/review', input: { items } })) and are pinned by content hash the same way — an engine upgrade that changes one reads to an open widget like an edit of yours.

3. The agent’s tools

ToolWhat it does
widget_previewSee a page as a person will — a file, an engine page or a draft not written yet — before opening it for anyone: screenshots shown to you alone and a report, problems first (§ 16). Writes nothing, opens nothing.
widget_createOpen a widget for the person in this chat (or a channel your config declares, or one person’s private chat with the bot — to), with an input the page starts from and the media it may show. From a turn with no chat that serves the dashboard (or an MCP client) it opens a widget with no chat, there (§ 13). Answers widgetId, the page url (null with no chat), expiresAt, launch (delivered, queued when the bot has not confirmed yet, none with no chat or launch: false), the deliver the widget got, the widgets it superseded, the stored source and the media names the page holds (never their URLs — the page reads them by name).
widget_updatePush to the open page: replace its state snapshot (with expectedVersion, only over the version you read) or send it a named event; grant it more media publications (alone or with either — granted first, so a state naming the new file finds it). Every push gets the next sequence number, so a page that reconnects replays what it missed. Answers the version, the newest seq and the media names the page holds. A page your brain’s tools push to (§ 5) needs none of this — never re-push what a tool says it showed.
widget_closeEnd it: the page stops accepting anything and the launch message loses its button. A closed widget is never reopened.
widget_getEverything about one widget now: status, the state the page shows (fenced as untrusted text, stateContent — a page shows back what people typed) and its version, the policy in force, the media it holds (kind, type, size — no URLs), the latest pushes — each with the tool whose code made it (tool, null for widget_update) and who called it (createdBy), a state push with its version, never the state — and the submits, what the page sent fenced as untrusted text (submitsContent), who sent it beside each row.
widget_listYour widgets (open ones by default).
widget_widenLet more people use an open widget — named people (people), or everyone its chat admits (tiers: ["member"]). Needs a person’s approval.
widget_list_sessionsThe browsers people signed into with Telegram to open widget pages outside Telegram (§ 15): who, when they signed in and were last seen, the browser.
widget_revoke_sessionsSign one browser (sessionId) or every browser of a person (person: 'tg:user:<id>') out of widget pages (§ 15). Needs access:write.

widget_create arguments: file (ui://<feature>/<name> or artifact:<pub_id> — an HTML publication, public or a private one you published yourself, for the reason media below says) or draft (a page not written yet, § 2 — exactly one of the two), input (an object, checked against inputSchema, at most maxInputBytes), to (current — default —, a declared notification channel, or tg:user:<id> — that person’s private chat with the bot that speaks for your role (its persona bot, else the chief’s), the page for them: its audience is them and the owners, and they need not have written to the bot), launch (false — post no launch message: hand the answer’s url over yourself — by email, another channel — and the person opens it in a browser, signing in with Telegram where the deployment offers it; the widget stays open as usual), title (the launch text and page title), label (the button, default Open), menu (true — also make it the person’s menu button, § 9; a private chat with the bot only), ttlHours, deliver (turn | chat | events, § 5), resources (up to 10 extra resource keys every submit carries), media (up to 20 names → artifact:<pub_id>: a public publication, or a private one you published yourself — another agent’s private file is refused (not_allowed), since the media URL would show it to the widget’s audience; make it public with artifact_widen first. Audio and images made for the page go there directly instead — below), idempotencyKey (the same key again answers the widget it already opened, as it opened it — its chat and its launch message or none, whatever the retry asks).

Media made for the page. Audio and images an engine tool makes go straight onto an open widget’s page as the widget’s own media: voice_render({ text, widget: { widgetId, name } }) (speech in the role’s voice), elevenlabs_sound_effect({ …, widget }), image_generate({ prompt, widget }). The bytes go to the engine’s blob store and the page reads them as widget.media.<name> (a media push, § 6); the answer carries only { widget: { widgetId, name, seq } } — no bytes, no URL, so nothing rides a state, a pipeline step or a transcript, and the page’s <audio> seeks the clip by byte range. The file lives exactly as long as the widget — nothing to expire, nothing in Artifacts — and each is at most maxMediaBytes (§ 4). The same name again replaces the file. Render it before the push whose state plays it: the page plays a clip when the state naming it arrives, so a state pushed first would play the previous clip under that name. A refused render (the widget closed, not yours, too large) is the tool’s refusal, in words. The widget must be one you may push to — one you opened (§ 5, “Who may push”); a deployment with no content origin (PUBLIC_DOMAIN / ARTIFACTS_PUBLIC_URL) has nowhere to serve it and refuses.

Without widget, an agent gets a file, never bytes: voice_render / elevenlabs_sound_effect write the audio into its artifacts directory (agents/<id>/audio/) and answer its path — file_send it, or artifact_publish({ path }) it with the visibility and lifetime you choose; image_generate writes images/ the same way. Only a caller with no agent (a pipeline step, the webhook door) gets base64. A data: URI in the state is the wrong tool for anything larger than an icon: it rides every push, every reload and every journal entry.

{ "file": "ui://dutch/listening", "input": { "level": "A2" }, "title": "Listening — lesson 4", "label": "Start",
  "media": { "intro": "artifact:pub_7f3a" }, "idempotencyKey": "lesson-4" }
{ "widgetId": "widget_01J…", "state": { "step": 3, "hint": "listen for the verb" }, "expectedVersion": 2 }
{ "widgetId": "widget_01J…", "event": { "name": "checked", "value": { "ok": true } } }
{ "widgetId": "widget_01J…", "media": { "clip": "artifact:pub_9c1e" }, "state": { "step": 4, "play": "clip" } }

widget_create, widget_update and widget_close are the widget:create capability (no approval: the destination is only the current chat or a declared channel, the audience the person served and the owners) — a role needs widget:create in its capabilities: to open widgets. widget_widen is widget:widen (approval on every call). Reads are runtime:read; widget_preview is feature:read, like the widget files it reads. An agent pushes only to its own widgets, and closes or widens its own — or any while its turn serves an owner; who else may is § 5’s “Who may push” and “Who may close or widen”. A brain with no bot cannot open one.

The brain caps how many widgets one agent keeps open (maxOpenPerAgent); opening one past it closes your oldest (superseded) once the new one’s launch went out, and widget_create lists which. A widget whose launch message cannot be posted (the person blocked the bot) closes at once (failed) and widget_create refuses. A widget past its expiresAt takes nothing more — no submit, no push — even before its close lands.

4. Policy — what you can tune

Four tiers, most specific wins per key: the engine default → constitution/MANIFEST.yaml § widgets → the file’s policy: → widget_create’s arguments (ttlHours, deliver). The file’s and the call’s keys are stored on the widget when it opens; the MANIFEST tier is read at every use, so a MANIFEST edit reaches open widgets for every key they did not set — except the lifetime, frozen into expiresAt at open.

KeyDefaultMeaning
ttlHours24Lifetime of a new widget; it closes (ttl) when it passes
maxTtlHours168MANIFEST only — the cap on what a widget may ask for (a HITL card’s page has no lifetime of its own — § 12)
authMaxAgeSeconds3600Oldest Telegram launch data a page may sign in with
deliverturnturn — a submit also becomes a turn of the agent that opened it (§ 5); chat — a turn of the agent serving the widget’s chat, as the person who sent it; events — code only. A widget not opened by the chat’s own agent in its chat is events unless a tier says chat; a widget with no chat (§ 13) keeps turn for any agent that opened it, and a file’s or MANIFEST’s chat becomes turn there (no chat’s agent to reach)
debounceMs3000A burst of submits lands as one turn: the turn waits this long after the latest submit
maxWaitMs30000…but never longer than this after the oldest one waiting, so a steady stream still gets its turn
maxInputBytes / maxSubmitBytes / maxStateBytes64 KiB / 16 KiB / 256 KiBSize caps on the opener’s input, one submit (or page message), the state and one pushed event
maxMediaBytes25 MiBOne media file made for the page — by a generating tool’s widget or your code’s media bytes (§ 5)
maxTurnContextBytes8 KiBHow much of the push record — the events and media pushed since the agent’s previous widget turn, one line each — a delivered turn carries outside the fence (§ 5); 0 = none
submitsPerMinute30Per widget and person — every gesture that does work together: submits, messages and calls of a tool that is not a read; past it the page is told to wait
toolTimeoutSeconds60How long one tool call from the page may run; past it the tool is stopped and the page is told it took too long
audienceservedWho may use a new widget: served — the person the opening turn served, and the owners; owners — the owners alone
transcriptchatWhere the chat lines your code writes go (§ 5, “Lines for the chat”): chat — posted into the widget’s chat; off — dropped (the push’s ack says skipped). Also whether a page message shows in the chat as the person’s quote — the one line the engine writes itself, so off is how a widget keeps its page messages out of the chat (a group widget whose people should not be quoted there). Not a widget_create argument: writing a line is the opt-in, the tiers only turn it off
maxOpenPerAgent5MANIFEST only
retentionDays30MANIFEST only — how long a closed (or expired) widget and its pushes are kept (0 = forever)
# constitution/MANIFEST.yaml
widgets:
  ttlHours: 6
  debounceMs: 1500
  maxOpenPerAgent: 3

constitution/MANIFEST.yaml § widgets needs an engine that knows the section — an older one rejects the manifest. An agent edits it with manifest_update.

5. What the page sends — events and turns

Every submit from a page is an inbound event (source widget), the same kind a webhook or a GitHub delivery is. Its payload: eventId, widgetId, type and payload (what the page sent — untrusted, fenced in any prompt), actor (the person the engine verified, never taken from the page — { kind: 'telegram', botId, userId, chatId } from Telegram, { kind: 'dashboard' } from the dashboard (§ 13), { kind: 'mcp', sessionId } from an MCP client (§ 14); branch on kind before reading userId) and timestamp. The same submit sent twice by the page (a retry, same key) is one event — per person: two people sending the same key are two events.

Where it goes is the widget’s deliver:

deliverThe submit reaches
eventscode only: hooks on widget:submitted, resource watches and pipeline subscriptions on widget:<id> (or a key from resources), widget_get
turn (default)all of the above, and the agent that opened the widget, as a turn of its own
chatall of the above, and the agent that serves the widget’s chat, as a turn of the person who sent them

Code: hooks, watches, pipelines

  • A hook on widget:submitted fires on every submit; filter on the page’s type (a top-level field), or decide in a <hook>.hook.ts gate:

    ---
    # features/quiz/quiz-finished.hook.md
    event: widget:submitted
    filter:
      type: quiz.finished
    agent: quiz-coach
    ---
    A person finished a quiz on widget {{widgetId}}. Their result: {{payload}}
    Record it with kv_set under quiz/<their user id>; say nothing in the chat.
  • A resource watch (watch_create({ resource: 'widget:<id>' })) batches a widget’s submits into the watching agent’s turns — use it for an agent that did not open the widget, or wants the watch’s own batching.

  • A pipeline reads a widget’s submits as a durable stream (below).

What comes back as a turn

With deliver: turn, what the person does on the page also comes back to the agent that opened the widget:

  • a burst of submits lands as one turn — it waits debounceMs after the latest submit, never longer than maxWaitMs after the oldest;
  • the turn runs as the turn that opened the widget ran: same chat, same person served, same capability ceiling (a member’s widget cannot hand its agent more than the member had);
  • the submits arrive fenced as untrusted text, with the sender the engine verified; the agent’s answer goes to the widget’s chat, headed 🧩 Widget · <title> (the person never typed anything into the chat), below the chat lines your code wrote before it (§ 5, “Lines for the chat”);
  • before them, outside the fence, the turn carries the record of what moved the page since this agent’s previous widget turn: one line per event (in full) and per media push, and every state replacement collapsed into one line — State: replaced 3× — now v31, last by your dutch_log_item — widget_get reads it. — each line naming the tool whose code pushed it and on whose call (your <tool>, <tool> on tg:user:<id>'s tap, <tool> in pipeline run <id>; widget_update for the op). All of it went through the engine from that agent and your code, never from the page, so the agent can act on it without a tool call. The state itself is never in the turn: it is the page’s data — it may show back what people typed — and the agent reads it with widget_get, fenced (stateContent). What your code must tell the agent, it pushes as an event. What another agent wrote — with deliver: chat, the heartbeat or hook agent that opened the widget — comes in a fence of its own, its author named: one agent’s words never reach another as the brain’s record. Bounded by maxTurnContextBytes, newest first ((N older pushes left out — widget_get) when cut).

That needs an agent whose answers land in the widget’s chat — the agent that serves the chat (its active agent) — so it holds only for that agent opening the widget in its own chat. A widget opened by a pipeline, by a hook’s code, into a declared channel (to), or by an agent that does not serve the chat (a heartbeat’s, a hook’s, a sub-agent) cannot be turn; asking widget_create for it there is refused, and it gets deliver: events — unless you ask for chat. A widget with no chat (opened from a dashboard or MCP turn, § 13) is the exception: there is no chat to miss the answer, so turn holds for any agent that opened it, read where its turns are read.

deliver: chat — hand the page’s results to the chat’s agent

A pipeline, a hook or any opener that is not the chat’s agent opens with deliver: 'chat' to have the page’s submits answered by the agent that serves the widget’s chat (its active agent, created on first need — as a message the person typed would be). Each delivered turn runs as the person who sent the submits — their own permissions, as their page message would (in a business chat, the connection’s owner) — never as the opener; one person’s submits per turn (a group widget’s two people get two turns), and a person no longer admitted to the widget gets none. The same debounce, the same fence, the same outside-the-fence context, the answer in the widget’s chat. The chat’s agent did not open the widget, so it reads it (widget_get) and answers in the chat; your pipeline or hook keeps pushing to it. widget_create answers which deliver the widget got.

Pushing from your code

Every tool body — your createTools, engine tools — gets context.widgets: push(widgetId, push, { tx? }) puts a state, an event or media on an open widget’s page, and page is the widget whose page made this call (a person’s tap on it), else null. A tool’s code keeps the page in sync itself; never hand a state to the agent to relay with widget_update — the agent would pay for it in output tokens and carry it in its history twice.

A page tool — the verdict and the derived state, atomic with the grade:

export const answerTool = createTool({
  name: 'quiz_answer', label: 'Quiz Answer', category: 'Quiz', risk: 'moderate',
  providesCapabilities: ['quiz:play'], agents: false, schema: AnswerSchema,
  async execute(args, context) {
    const widgetId = context.widgets.page;                         // a person's tap on the page, never a turn
    if (!widgetId) return refusal('Only from the quiz page.');
    return db.transaction(async tx => {
      const verdict = await grade(tx, widgetId, args);             // your writes
      const page = await context.widgets.push(
        widgetId,
        { event: { name: 'verdict', value: verdict }, state: () => quizState(tx, widgetId) },
        { tx }
      );
      return success({ ...verdict, page: page.status });
    });
  },
});

A tool an agent calls — the page follows the brain’s rows, the agent gets an ack; a push the tool cannot do without rolls its writes back by throwing inside the transaction (refusal() returned there would commit them):

class PageRefused extends Error {}                         // your own class, thrown to roll back

try {
  return await db.transaction(async tx => {
    const posted = await poseDrills(tx, run, args);          // your writes
    const page = run.widgetId
      ? await context.widgets.push(run.widgetId, { state: () => drillState(tx, run.id) }, { tx })
      : null;
    if (posted.length > 0 && page?.status === 'refused') throw new PageRefused(`the drill page: ${page.reason}`);
    return success({                                       // 'dry-run' under a loop replay counts as shown
      posted,
      page: page?.status ?? 'none',
      ...(page?.status === 'refused' && { page_reason: page.reason }), // the description says what to do then
    });
  });
} catch (err) {
  if (err instanceof PageRefused) return refusal(err.message); // nothing was written
  throw err;
}

A push — { state?, event?, media?, chat? }, at least one; media land first, then the state, then the event (chat — lines for the widget’s chat, below):

  • state: () => value — the page’s new snapshot, computed by your function after the engine holds the widget’s row lock, on the push’s transaction: a snapshot derived from your tables never lands older than one committed before it (two tools that lock different rows of yours cannot reorder the page). It replaces the state whole and bumps version; JSON, at most maxStateBytes. A constant is state: () => value.
  • event: { name, value } — a named event for the page, and for the agent its submits reach (outside the fence, § 5 turns). An event carries no person-written text — never echo an argument the page sent into it: it would put the page’s words outside the fence. A state may carry it (it is fenced wherever an agent reads it).
  • media: { name: { bytes, contentType } | 'artifact:<id>' } — bytes your code made (the widget’s own, at most maxMediaBytes each, gone when it closes) or a publication; a name the page holds is re-pointed: at the same publication or the same bytes it keeps its URL, at anything else — a re-render included, its bytes are new — it gets a new one, and the page reads it from widget.media.<name> on that push.

On your transaction. Pass the tx of db.transaction(async tx => …) from @omnislash-ai/infra/drizzle and the push commits or rolls back with your writes — a crash between your commit and the page can never leave rows the page does not show. Push last in the transaction: the push holds the widget’s lock until you commit. The page wakes when your tool returns. Without tx the push commits on its own and the page wakes at once — use that for progress pushes inside one long call. Pass tx, never db: both compile, and db throws only at the call — a test with a fake context.widgets will not catch it; your integration test or the first live call does.

The ack. push never throws for the widget’s condition — it answers { status: 'ok', seq, version, media, chat? } (the names the page holds; chat when the push carried lines, below), { status: 'dry-run' } (a run the self-evolving loop replays: nothing written — read it as shown), or { status: 'refused', code, reason }:

codeWhat it asks of you
not_found, closedThe page is gone (closed, or past its expiresAt): roll back what only made sense on it, or let a result through and say page: refused in the answer
not_yoursThis caller may not push to that widget (below) — a wrong id passed in, usually
too_largeThe state or event is over maxStateBytes, a file over maxMediaBytes
too_manyMore than 20 media names
not_live, not_allowedA granted publication is not live, or private and not this caller’s to show (§ 7)
no_originThe deployment has no content origin to serve media (PUBLIC_DOMAIN / ARTIFACTS_PUBLIC_URL)

It throws only for a malformed call: tx not a transaction, an event name outside 1–100 characters, a media name off [a-z][a-z0-9_-]{0,39}, a contentType that is not a MIME type, a state that is not a function, a chat that is not a list of { text, echo? } with some text, more than 20 lines, an echo line from a call no page made. A push is not a person’s gesture: no rate limit, only the size caps.

Who may push — judged on who called your tool:

The call came fromIt may push to
A page (context.widgets.page set)that widget, or an open widget with the same opener (an agent, a pipeline or a run — a host page and its player pages); never a HITL card’s page but its own
An agent’s turnthe widgets that agent opened
Brain code — a pipeline step, a workflow run’s codeany widget
An MCP session with no agentthe widgets an MCP session opened — every session of the bearer key is one principal, so a reconnect keeps them
A person off a page — the dashboardany widget
The webhook door (POST /tool/:name, no identity)none (not_yours): move the page from brain code — an ingest event, a hook, a pipeline

Gate a page-only tool on context.widgets.page, never on context.auth.widget alone: an agent’s widget turn carries that too. No capability is checked: your tool’s own capability authorized the call. The same rule holds widget_update.

Who may close or widen — widget_close / widget_widen end a page or change who may use it; they put nothing on it. Whoever may push may; beyond that, an agent whose turn serves an owner (the person it answers is an owner — in a Secretary chat the counterpart, never the connection’s owner it runs as) and any MCP session reach every widget — the chief ending a page a stopped pipeline left open, an owner’s MCP client after a reconnect. A HITL card’s page is never closed this way (it closes with its card).

A stopped run takes its pages with it. Killing, resetting or cancelling a pipeline run (run_kill, run_reset, run_cancel, the dashboard, a run card) closes every widget the run opened (agent; a HITL card’s page closes with its card) and deletes the resource watches it was waiting on: no code of the run will read them again, and a gate of yours that skips while “the previous page is still open” would otherwise hold until the TTL. A watch a step created for an agent (watch_create with its agentId) wakes that agent and stays. A run that had already finished when the stop reached it keeps its pages, as any finished run does. The engine’s own stop of a run a code change breaks (onCodeChange, pipelines.md § Editing a .pipeline.ts with in-flight invocations) is the same kill, so its pages close too — and a restart finds the previous page closed.

Tests. Pass a fake context.widgets — page set for a page tool’s call, push recording each push and awaiting its state() on the same transaction — and assert on what it recorded.

A message the person writes on the page (widget.sendMessage, § 6) is different from a submit: it goes into the chat’s conversation as that person — their own permissions, never the agent’s that opened the page — and the chat’s agent answers it like anything they typed (never merged with what they typed in the chat itself). Under transcript: chat (outside a business chat) it also shows in the chat as their quote, above the answer — the engine writes it as their chat line (below), together with the message — neither exists without the other. In a group that names them there, so a group widget whose page messages should stay private sets transcript: off.

Lines for the chat

Without lines, a widget’s chat sees only a widget turn’s answer (§ 5, turns) — a tap your page tool graded runs no turn, and a verdict or a remark pushed to the page never leaves it, so the chat shows part of the session at random. chat lines write the session into the chat the widget was opened from, with the push your code already makes:

return db.transaction(async tx => {
  const verdict = await grade(tx, widgetId, args);
  const page = await context.widgets.push(
    widgetId,
    {
      state: () => quizState(tx, widgetId),
      chat: [{ text: task.prompt }, { text: args.answer, echo: true }, { text: `✅ ${verdict.reason}` }],
    },
    { tx }
  );
  return success({ ...verdict, page: page.status });
});
  • On the push’s transaction. Lines commit with the state, under the same row lock — a rolled-back grade posts nothing, and a line never disagrees with the page. A push of lines alone ({ chat: [...] }) is valid.
  • Whose words. A line is the brain’s — in the bot’s voice, markdown — unless echo: true: then it is the words of the person whose gesture on the page made this call — pushed to that page’s own widget (only when context.widgets.page is that widget; anywhere else, a sibling widget included, the push throws: a sibling may live in a chat the person never wrote in). The engine shows it as their quote (💬 _words_, escaped, never a mention) and names them in a group. Never put text a person wrote into a line without echo — it would read as the bot’s.
  • Bounds. 1 to 20 lines per push; a line over 1000 characters is cut with ….
  • The ack’s chat. { status: 'queued', lines }, or { status: 'skipped', reason } — never a refusal, the state still lands: no_chat (a widget with no chat — the dashboard’s, an MCP client’s, § 13–14), business_chat (the bot speaks as the connection’s owner there, so a page’s words would reach the counterpart as theirs), off (policy transcript, § 4).
  • How they post. The bot posts them after the push commits: a widget’s consecutive lines as one silent message headed 🧩 Widget · <title>, split at line boundaries when long. Before a turn’s answer in that chat — a widget turn’s, a page message’s, a turn the person started in the chat — the chat’s owed lines go out first, so an answer never lands above the lines it answers — its text before a question and a stopped turn’s answer included (what a turn sends on its own — a HITL card, chat_send, a file, a notification — is not ordered against them); a widget’s close posts them before it marks the launch (closed). At least once: a crash between a send and its record posts that message again. A posted line is gone from the engine — the chat holds it, and it is not in the agent’s chat history either (it is not the bot’s words). A line not posted within a minute (a lost wake, a failed send) is retried by the widget sweep for half an hour; past that it waits for the chat’s next answer.
  • A reply to them. A person replying to a lines message reaches the agent fenced, as the page’s lines — never as the agent’s own earlier message.
  • widget_update and the pipeline helpers take no lines: an agent speaks in the chat with its answer, a pipeline with notifications (notifications.md).

From a pipeline

import { closeWidget, openWidget, updateWidget, widgetEvents } from '@omnislash-ai/pipeline-sdk';

const widget = await openWidget(ctx, { file: 'ui://dutch/listening', input: { level: 'A2' }, to: 'owner' });
const stream = await widgetEvents(ctx, widget.widgetId, { expiresAtMs: Date.parse(widget.expiresAt) });
try {
  const got = await stream.next({ timeoutMs: 30 * 60_000 });
  if (got.kind === 'submits') {
    for (const submit of got.submits) {
      // submit.type, submit.payload (the page's — untrusted), submit.actor (verified)
    }
    await updateWidget(ctx, { widgetId: widget.widgetId, event: { name: 'checked', value: { ok: true } } });
  }
} finally {
  await stream.close();
  await closeWidget(ctx, widget.widgetId);
}

Each of openWidget / updateWidget / closeWidget is one journaled step over the same widget_* tool an agent calls (a refusal fails the run with its reason; to: 'current' is the invocation’s chat). openWidget carries an idempotency key drawn per step, so a retried step never opens a second widget. widgetEvents subscribes to widget:<id> from the widget’s start, so a submit made before you subscribed is not missed; next answers { kind: 'submits', submits } or { kind: 'timeout' }. A widget a pipeline opens delivers events — or, opened with deliver: 'chat', also a turn of the chat’s agent as the person (above). openWidget takes draft as widget_create does (inline { html, spec? } — a pipeline has no artifacts directory). Audio the run renders goes straight onto the open page — open, render, then push the state that plays it:

const widget = await openWidget(ctx, { file: 'ui://dutch/listening', ttlHours: 12, deliver: 'chat' });
await callTool(ctx, 'voice_render', { text, widget: { widgetId: widget.widgetId, name: 'clip' } }, { role: 'dutch-coach' });
await updateWidget(ctx, { widgetId: widget.widgetId, state: { audio: 'clip', questions } });

The bytes never enter the journal — the step’s result is the ack. openWidget / updateWidget answer media as names. Moving a render after openWidget reorders the run’s steps: runs in flight across that deploy need pipelines.md § Editing.

The widget helpers are refused in a run the self-evolving loop launched: a widget reaches a person. More on pipelines: pipelines.md.

A blocking wait for a person’s decision is never a widget event — that is a HITL request (human-input.md). Inbound events in general: integrations.md.

6. The page

A widget page is an MCP App — the same standard Claude and ChatGPT render — and the engine is its host. In Telegram the button opens the engine’s shell on the widgets origin, which signs the person in and shows your page in a sandboxed frame on the content origin. Write the page as one self-contained HTML file and import the engine’s page SDK from the page’s own origin:

<script type="module">
  import { connect } from '/sdk/widget.js';

  const widget = await connect();          // the host's theme is applied to <html> as CSS variables
  render(widget.input, widget.state);      // what the opener handed it, the state now
  widget.subscribe(event => {              // every push from here on, in order, once
    if (event.type === 'state.snapshot') render(widget.input, event.payload.state);
    else if (event.payload.name === 'hint') showHint(event.payload.value);
  });
  check.onclick = () => widget.submit('answer', { choice: 2 });   // an event your brain reads (§ 5)
  audio.src = widget.media.intro;                                  // media you granted or made for it
  widget.telegram?.mainButton({ text: 'Check', visible: true });   // Telegram's own button
  widget.telegram?.onMainButtonClick(() => widget.submit('done'));
</script>

The page API

CallWhat it does
connect({ applyTheme?, autoResize?, appInfo? })→ widget, once the host handed the initial data. Applies the host’s theme variables to <html> and reports the page’s size (both on by default)
widget.widgetId, .input, .state, .version, .seq, .media, .expiresAt, .hostContextWhat the opener handed it, the state and the newest push folded in (kept current by pushes), the media URLs by name (kept current when a push grants more); .expiresAt is null on a HITL card’s page (§ 12)
widget.subscribe(onEvent)→ unsubscribe. Every push after widget.seq, in order, once: { seq, type: 'state.snapshot', payload: { state, version } }, { seq, type: 'custom', payload: { name, value } } (an opener’s event, or one your code pushed — a page tool’s verdict) or { seq, type: 'media', payload: { media } } (every media URL the page holds now) — after a reconnect it replays what it missed
widget.submit(type, payload?, { idempotencyKey? })One inbound event (§ 5) → { status: 'accepted', eventId } or { status: 'duplicate' }. A key is drawn per call; pass your own to make a retry safe
widget.getState(){ state, version, seq } from the engine now
widget.callTool(name, args?)A tool on the spec’s tools list, run as the person, no agent turn → the tool’s MCP CallToolResult: structuredContent when it succeeded with an object, isError: true when it ran and failed — its text worded for the person (below; the details stay in the engine’s log)
widget.sendMessage(text)Says something into the chat as the person; the chat’s agent answers it. Under transcript: chat (§ 4) the chat shows it as their quote, above the answer
widget.openLink(url), widget.requestDisplayMode('fullscreen' | 'inline'), widget.close()Open a link outside, go full screen, ask the host to close the page
widget.onHostContextChanged(fn), widget.onTeardown(fn)The theme or viewport changed; the page is about to go (a teardown listener may return a promise)
widget.telegramnull outside Telegram; else mainButton({ text, visible, enabled, progress, color, textColor }), onMainButtonClick(fn), backButton(visible), onBackButtonClick(fn), haptic({ kind: 'impact', style } | { kind: 'notification', type } | { kind: 'selection' }), confirmClose(enabled)

What a failed tool call says to the person. Only one failure reaches the page in the tool’s own words: a refusal your tool returns with refusal('This round is over.') (or any classifiedFailure(code, message, 'permanent' | 'safe')) — write it for the person at the page. Everything else is worded by the engine: a missing grant or an unknown tool reads “This action is not available here.”, a transient fault “Something went wrong. Try again.”, and a plain failure('…') — written for an agent, free to carry internals — reads “<the tool’s label> did not work.” and never shows its text.

A refused call rejects with a WidgetCallError whose message is worded for the person and whose code says why: -32001 refused (not in the audience, a tool off the list or beyond the ceiling, a body too large), -32002 the widget is closed or past its lifetime, or the person’s session ended, -32003 too many calls — wait, -32004 the page cannot be shown (its file changed since the widget opened). Use CSS variables such as var(--color-background-primary) and var(--color-text-primary) and the page follows the chat’s theme.

Standard MCP Apps. The SDK speaks MCP Apps over postMessage, on every host — the Telegram shell, the dashboard, a foreign MCP Apps client (§ 14). A page written against the official SDK (@modelcontextprotocol/ext-apps, inlined into the file) works as well: tools/call reaches widget_submit ({ type, payload, idempotencyKey }), widget_await ({ sinceSeq, waitMs } — the long-poll for pushes, at most 25 s) and the spec’s tools; ui/message is sendMessage. The engine’s pushes and Telegram controls are an extension (x-omnislash/*, advertised in the host’s capabilities under the same name, x-omnislash/widget); the engine SDK uses them where offered — the Telegram shell, the dashboard — and on a foreign host reaches the engine through its app-only tools instead (§ 14): subscribe polls widget_await, getState and an allowlisted callTool go through widget_get_state / widget_call_tool. widget.telegram is null off Telegram, and sendMessage on a foreign host talks to that host’s own model.

The version it opened with. Editing the file after a widget opened does not change that widget’s page — it shows nothing and asks the person to get a new one.

7. Security model

  • Two origins. The shell — on the widgets origin, the Mini App’s — holds Telegram’s launch data and the session token in memory. Your page runs in a frame on the content origin (the same content-only origin published artifacts use), sandboxed (allow-scripts allow-forms, no same-origin): no cookies, no storage, no access to Telegram or to the session. It talks only to its host, by postMessage.
  • No network by default. The page may fetch only from csp.connectDomains; images, media, fonts and scripts load inline, from data: / blob:, from csp.resourceDomains, or from the widget’s media — publications granted, files made for it (capability URLs that die when the widget closes). A form cannot post anywhere. What an open widget may reach (tools, origins, permissions) is frozen when it opens: editing the file never widens a live page.
  • Sign-in. Inside Telegram the shell signs the person in with Telegram’s launch data, checked against the token of the bot that posted the widget and no older than authMaxAgeSeconds; in a plain browser with a Telegram sign-in (§ 15) — the same admission either way. Only someone in the widget’s audience gets in — the person it was opened for and the owners, plus whoever widget_widen added; anyone else sees “opened for someone else”. Who someone is comes from the WIDGET’S chat (their tier in the group, the business chat’s counterpart), wherever they open it from. In a DM, widening to the member tier admits the DM’s own person — not every member of the brain, nor anyone a members: everyone rule lets talk to the bot; in a business chat, the counterpart alone. Launch data of a direct link names the widget it was made for and opens no other. The session lasts while the widget is open (renewed every few minutes, never stored by the page) and ends the moment it closes.
  • Everything as the person. A submit, a message and a tool call run as the person at the page — attributed to them, checked against who they are in the chat on every call. A deliver: chat turn runs as them too, re-admitted when it is delivered.
  • Tools. widget.callTool reaches only the names in the spec’s tools, and only within both what the agent that opened the widget may do (its role’s grant, cut by the opening turn’s ceiling; a widget a pipeline or hook code opened has no agent above it) and the person’s own ceiling in the chat (a member’s capabilities). Once the agent that opened it is gone (deleted, reaped), the page can call no tool at all. A tool behind an approval never runs from a page, nor one that controls agents, writes widgets or works in an agent’s checkout (shell, files); a tool that asks a person (AskUserQuestion, hitl_request_*) refuses when called — there is no agent to resume. The call runs in the worker, on the same tool set an agent’s turn uses there, for at most toolTimeoutSeconds; a call the deployment could not start in that time is never run later. While no worker runs, the page is told the action has not started yet — never to try again, since a worker that comes up before the deadline still runs it.
  • Untrusted payloads. Everything a page sends — type, payload, a message — is written by whoever holds the page. It reaches a prompt fenced as untrusted text (a hook’s {{payload}}, a turn, widget_get); never let a role act on instructions inside it. Check the shape in code before you use it (a hook gate, a pipeline). What a delivered turn shows outside the fence — your pushes, a tool’s verdict — is only what your agents and code wrote through the engine; keep page text out of an event (don’t echo a page argument into a verdict). The state may carry it — an agent reads the state only fenced (widget_get’s stateContent), never in a turn.
  • Who may push. A page’s call reaches its own widget (and same-opener siblings), an agent the widgets it opened, an MCP session the widgets an MCP session opened, brain code and the dashboard any, the webhook door none (§ 5). Closing and widening reach further — an agent serving an owner and any MCP session, every widget — but put nothing on a page. So a caller cannot steer your tool at a page it does not own, even when the widget id is an argument.
  • Who may show a private publication. A public one anyone; a private one an agent only its own, a page’s call only one the widget’s opener published, an MCP session only its own, brain code and the dashboard any — else not_allowed. A capability URL shows its file to the widget’s whole audience.
  • The action log. Each submit (widget.submit), message (agent.send) and tool call that changes something is recorded against the widget, as the person.
  • A frame that navigates away. CSP cannot stop a frame from navigating itself (a link the person taps, a script setting location), and every MCP Apps host carries that risk. Here two things bound it: the shell’s own policy lets its frame hold only the content origin’s view documents, so a foreign page never loads in it; and the moment the page’s document unloads, the shell ends the session — no push, no forwarded call and no message reaches whatever comes next. The person reads “The page went somewhere else” and reopens it from the chat. Open links outside with widget.openLink.

8. Limits

WhatLimit
Opener’s input / one submit or message / state or one pushed eventmaxInputBytes / maxSubmitBytes / maxStateBytes (§ 4)
Submits, messages and non-read tool calls per person per widgetsubmitsPerMinute (§ 4)
Open widgets per agentmaxOpenPerAgent (§ 4)
LifetimettlHours, capped by maxTtlHours (§ 4)
Media per widget20 names ([a-z][a-z0-9_-]{0,39}), each file made for the page at most maxMediaBytes (§ 4)
A draft (draft, § 2) — and any page widget_preview rendersa page file 8 MiB, inline HTML 512 KiB, a spec 64 KiB (engine constants)
Extra resources per widget10
One long-poll (widget_await)25 s
HTTP traffic (engine constants, not tunable)page RPC 600 calls a minute per address, and 600 per widget counted only after sign-in; sign-in 30 a minute per address; the view 120 and media 600 requests a minute per address and widget

9. Where a widget opens

The engine picks the launch for the chat — nothing to choose in widget_create:

ChatLaunch
A private chat with the botthe launch message’s button opens the page in place
A group, a forum topic, a channel — the bot has a Main Mini Appthe button is a direct link (t.me/<bot>?startapp=…): one tap opens the page in place, over the chat
The same, no Main Mini Appthe button is a deep link (t.me/<bot>?start=…): it opens the person’s private chat with the bot, where the bot answers with the page’s button — two taps, nothing to set up
A business chat (Secretary Mode)the deep link, written into the message text (a business chat carries no button), into the counterpart’s private chat with the bot
No chat — a turn that serves the dashboard, an MCP clientno launch message: the dashboard shows it in its chat panel (§ 13); an MCP client shows a widget file through its own tool (§ 14)

Who may use it in a shared room. Until it is widened, only the person the opening turn served and the owners — in a group the person who addressed the bot, in a business chat the counterpart. Anyone else who taps the button sees “opened for someone else” (the deep link answers so in their private chat). widget_widen({ tiers: ["member"] }) opens it to everyone the chat’s access rule lets talk there (never a stranger or someone blocked — chat-access.md) and, in a group, whom Telegram shows in it — the launch is a link that survives forwarding, so someone members: everyone would admit but who is not in the group is refused (as is everyone when the bot cannot see the group’s members); people to named persons. Each person’s sign-in is their own; a submit says who sent it.

One tap in groups — the Main Mini App. It is a BotFather setting only a person with the bot’s account can make: @BotFather → /mybots → the bot → Bot Settings → Configure Mini App → Enable Mini App, with the URL <widgets origin>/i/start (e.g. https://widgets.example.com/i/start). doctor_check({ check: 'widget-launch' }) says, per bot, which launch groups get and gives that step with your URL — an agent relays it to an owner. The bot notices the change within minutes. The engine assumes the bot’s Main Mini App IS the widgets entry; a bot whose Main Mini App is some other app opens that app instead.

A person’s app — the menu button. widget_create({ …, menu: true }) in a private chat also puts the page on the person’s menu button (beside the message field), so they reopen it any time without scrolling back — a dashboard, a tracker, a lesson plan. Give it a long ttlHours (capped by maxTtlHours). One menu per person and bot: a newer menu widget takes the button; when one closes, the button goes back to the previous open one, or to the chat’s default menu.

10. Worked example — a quiz

A feature quiz, a page that asks three questions, and an agent that comments on the result.

# features/quiz/capitals.widget.yml
description: A three-question capitals quiz
policy:
  ttlHours: 1
inputSchema:
  type: object
  properties:
    questions:
      type: array
      items:
        type: object
        properties:
          q: { type: string }
          options: { type: array, items: { type: string } }
          answer: { type: integer }
        required: [q, options, answer]
  required: [questions]
<!-- features/quiz/capitals.widget.html -->
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <style>
    body { font: 16px system-ui; margin: 16px; background: var(--color-background-primary); color: var(--color-text-primary); }
    button { display: block; width: 100%; margin: 8px 0; padding: 12px; font: inherit; border-radius: 10px; }
  </style>
</head>
<body>
  <h2 id="question"></h2>
  <div id="options"></div>
  <script type="module">
    import { connect } from '/sdk/widget.js';

    const widget = await connect();
    const { questions } = widget.input;
    const picks = [];

    function show(index) {
      if (index === questions.length) return finish();
      const { q, options } = questions[index];
      document.getElementById('question').textContent = q;
      const box = document.getElementById('options');
      box.replaceChildren(...options.map((text, choice) => {
        const button = document.createElement('button');
        button.textContent = text;
        button.onclick = () => { picks.push(choice); widget.telegram?.haptic({ kind: 'selection' }); show(index + 1); };
        return button;
      }));
    }

    async function finish() {
      const score = picks.filter((choice, i) => choice === questions[i].answer).length;
      document.getElementById('question').textContent = `${score} / ${questions.length}`;
      document.getElementById('options').replaceChildren();
      await widget.submit('quiz.finished', { score, picks }, { idempotencyKey: 'finished' });
      widget.telegram?.mainButton({ text: 'Close', visible: true });
      widget.telegram?.onMainButtonClick(() => widget.close());
    }

    show(0);
  </script>
</body>
</html>

The agent (its role holds widget:create) opens it in the chat:

{ "file": "ui://quiz/capitals", "title": "Capitals quiz", "label": "Start",
  "input": { "questions": [
    { "q": "Capital of Australia?", "options": ["Sydney", "Canberra", "Melbourne"], "answer": 1 },
    { "q": "Capital of Canada?", "options": ["Ottawa", "Toronto"], "answer": 0 },
    { "q": "Capital of Turkey?", "options": ["Istanbul", "Ankara"], "answer": 1 } ] } }

With the default deliver: turn, the quiz.finished submit comes back to the agent as a turn — it reads the score (fenced, from the person it names) and answers in the chat, then may widget_close it. To keep the result in code instead, open it with deliver: "events" and add the hook from § 5. Here the answers travel in input, so a curious person could read them in the page: when that matters, keep the answers out of input, submit each pick, and check it in a page tool that pushes its verdict back itself (§ 5, “Pushing from your code”) or in a pipeline (updateWidget).

11. Hosting and doctor

In domain mode (PUBLIC_DOMAIN) the shell is served from widgets.<your-domain> and the page from artifacts.<your-domain> — each needs a DNS record pointing at the server (an admin adds them; the deployment’s Caddy config already has both). A single-origin deployment (a tunnel in WEBHOOKS_PUBLIC_URL) serves both from the webhooks origin; WIDGETS_PUBLIC_URL / ARTIFACTS_PUBLIC_URL override either. Telegram opens a Mini App only over https, so a deployment on a bare IP cannot host widgets.

doctor_check({ check: 'widget-hosting' }) (omnislash doctor, Widget hosting) says whether a widget can open here: a bot, both origins https, each hostname resolving. Widget launch (runtime only) says per bot whether a widget opened in a group opens in one tap or two, with the BotFather step for one (§ 9) — a note, never a failure. Widget browser sign-in (runtime only) says whether widget links open in a plain browser and, if not, why — with the exact BotFather step (§ 15). Widget tools checks every spec’s tools against the deployment’s tools; Brain entities names a widget file or spec that does not load.

12. HITL cards and the engine’s pages

A HITL card (human-input.md) can carry a page that answers it. It is still one request: the page answers through the same gateway as a button, a reply or the dashboard, as the person at the page — whoever may decide the card from a chat (an owner; a question’s addressee) may decide it there, nobody else. The card keeps its other buttons and its text reply; the page’s button comes first, where widgets are hosted (§ 11), and opens by the same launch as a widget in that chat (§ 9). When the request is decided — on the page, by a button, in the dashboard, by its deadline — the page closes with it.

CardIts pageThe page answers
An approval with a widget action — { id, label, widget: { file, input? } } in actionsthe action’s page: your ui://<feature>/<name>, or ui://engine/review{ actionId, data? } — the widget action’s own id (or another of the card’s answers) and its structured decision, held to the card’s responseSchema when it has one
An input requestpayload.widget when the producer named one, else ui://engine/form — rendered from its inputSchema{ input }, checked against inputSchema before it lands

A card’s page is a widget file (ui://), never a publication. Its input is checked against the file’s inputSchema when the card is requested — a page that cannot open, or an input it refuses, fails the request instead of posting a card with a dead button.

From a pipeline (pipelines.md):

import {
  awaitApproval,
  ENGINE_WIDGETS,
  requestInput,
  ReviewWidgetDecisionSchema,
} from '@omnislash-ai/pipeline-sdk';

const answer = await awaitApproval(ctx, {
  title: 'Send these 12 drafts?',
  message: { text: 'Review them one by one.', parseMode: 'plain' },
  widget: { file: ENGINE_WIDGETS.review, input: { items: drafts.map(d => ({ id: d.id, title: d.to, fields: { subject: d.subject, body: d.body }, editable: ['subject', 'body'] })) } },
});
if ('actionId' in answer && answer.actionId === 'widget') {
  // The gateway already held `data` to this schema; parse it to get the type.
  const decision = ReviewWidgetDecisionSchema.parse(answer.data);
  for (const item of decision.items) { /* item.verdict, item.edits */ }
}

const { budget } = await requestInput(ctx, {
  title: 'Campaign budget',
  inputSchema: { strict: true, schema: { type: 'object', required: ['budget'], properties: { budget: { type: 'number' } } } },
});

widget puts a 🧩 Open button (label to rename it) with the id widget before the card’s actions (Approve / Deny when you declare none); the page answers with that id, and an answer with a widget action’s id must carry data whenever the card has a schema — from any surface. With ENGINE_WIDGETS.review the review decision’s schema (ReviewWidgetDecisionSchema) is the card’s responseSchema by default, whoever asks; pass your own responseSchema for your page. Type the review page’s input as ReviewWidgetInput — it also takes a title and a message. requestInput offers the engine form unless you pass widget. An agent does the same with hitl_request_approval (actions[].widget, responseSchema).

The page opens as widget_create would open its file: the file’s policy: tier applies (maxSubmitBytes, authMaxAgeSeconds, submitsPerMinute — raise maxSubmitBytes there for a review of many editable items), and its input is held to maxInputBytes and inputSchema when the card is requested. It has no expiry of its own — no ttlHours, no maxTtlHours: it lives exactly as long as its card. It is the card’s, not yours: widget_get / widget_list show its hitlId, and widget_close / widget_widen refuse it (answer or cancel the card instead). When an agent asks, the page’s tool calls stay under that agent’s own ceiling (§ 7), as for a widget it opened itself.

Your own page reads the request as widget.hitl — { hitlId, kind, title, message, actions, actionId, responseSchema, inputSchema, data } (null for a widget no card opened) — and answers with widget.respond(response) → { status: 'answered' }, or 'already' when another surface decided it first. A refusal rejects with a WidgetCallError: -32001 the person may not decide this request, -32002 it was already closed, -32602 the answer does not fit. widget.submit still records events, for code only.

ui://engine/form renders an object JSON Schema — string (a date format, a long text area past 200 characters), number and integer, boolean, enum (a select), arrays of strings (checkboxes for an enum, else one per line) and nested objects; required fields are marked and checked. Bound to an input request it answers { input }; to an approval, one button per answer of the card, each sending { actionId, data } (data held to responseSchema). Opened with widget_create, it reads input.schema (and input.title, input.message) and submits submit with the values.

ui://engine/review shows input.items — each { id, title?, fields, editable? }, field values strings, numbers, booleans or null — with Approve / Reject per item, Approve all / Reject all, and inputs for the fields an item names editable. The decision is { items: [{ id, verdict: 'approve' | 'reject', edits? }] }, one entry per item, edits only the fields that changed. Bound to an approval it answers { actionId, data: decision }; opened with widget_create, it submits review with the decision.

13. The dashboard

The dashboard is a widget host too — the same sandboxed view and page SDK, themed by the dashboard’s colours (the same standard CSS variables, so var(--color-background-primary) follows it). The person there is the dashboard’s login, which reaches every widget, as every dashboard page does.

  • The chat panel lists every open widget above the thread — first the ones with no chat (opened from a dashboard turn), then any chat’s — and a tap opens the page in a dialog. Close the dialog and the widget stays open for the next visit.
  • A widget with no chat. A turn with no chat whose principal is the dashboard’s (a message sent from the chat panel or a session’s composer) opens its widget there: no launch message, no menu, no deliver: chat (refused in the call; a file’s or MANIFEST’s chat opens as turn); deliver: turn brings the submits back to the agent that opened it, read where its turns are read — except an agent a live workflow run owns (its widget gets events: a turn would run outside the run’s checkout). A message from the page (widget.sendMessage) goes to that agent, as the dashboard — only while the submits are its turns (deliver: turn).
  • A chat’s widget, viewed from the dashboard. Submits and tool calls work as the dashboard; a message refuses (it would speak into a chat the dashboard is not in), and a deliver: chat widget’s turn goes only to Telegram persons — what the dashboard submits reaches your code.
  • Request cards. An approval’s widget action is a button that opens its page (§ 12) — the card’s own widget, opened there if no chat opened it yet. An input request that names its own form (payload.widget) offers it beside the fields.

It needs the content origin and the dashboard’s public address (PUBLIC_DOMAIN, or ARTIFACTS_PUBLIC_URL with DASHBOARD_PUBLIC_URL): the view names the dashboard as the one page allowed to frame it.

14. MCP clients — Claude, ChatGPT, VS Code

To an MCP client that is an MCP Apps host and speaks for a person (the bearer door’s mcp:default, never an agent’s session), the engine’s MCP server offers your widget files as MCP Apps:

  • each enabled file is a resource ui://<feature>/<name> (text/html;profile=mcp-app) — your HTML with an import map that points /sdk/widget.js at the content origin, and _meta.ui from its spec: csp (connectDomains, resourceDomains plus the content origin for the SDK and the media), permissions;
  • each file has a tool, ui_<feature>_<name> (its address flattened: lowercase, every other character _; a file whose name would pass 64 characters or match another file’s is not offered, and the MCP server logs it — rename it), whose input is the file’s inputSchema and whose _meta.ui.resourceUri is the file: the host’s model calls it, the engine opens a widget with no chat for the session’s principal through widget_create (every gate of it — the session needs widget:create), and the host renders the page with the state and the widget’s id;
  • the page’s calls are four app-only tools (widget_submit, widget_await, widget_get_state, widget_call_tool — _meta.ui.visibility: ["app"]), run as the session’s principal by the same rules as on the engine’s hosts, a tool call never past the session’s own grant (the capabilities of its MCP URL); every other tool is marked model-only, so the page reaches tools only through its tools list.

What a foreign host cannot show: a page that is a publication (artifact:) or a draft (draft:), and a widget widget_create opened from an MCP session — the spec renders a tool’s result only with a resource the tool declared ahead, never one the result names; the dashboard shows both. The host reads the file when it renders, so an edit between the open and the render shows the new page. A file added while a client is connected appears on its next tool listing.

15. In a browser

A widget’s link — https://widgets.<your-domain>/i/<id> — also opens in a plain browser: on a laptop, from an email, from another messenger. Outside Telegram the page asks the person to sign in with Telegram; Telegram shows them the login bot and your domain and asks them to confirm in the app; then the page opens exactly as inside Telegram — the same audience, their tier from the widget’s chat, every gesture as them. Sign-in proves who someone is, never more: a person the widget does not admit gets no session at all. widget.telegram is null there (no main button, no haptics). A link tapped inside a Telegram chat opens in Telegram’s own browser, where the page offers Open in Telegram instead — the page as a Mini App, no sign-in.

Turning it on — once, by an owner. Telegram has no API for this step:

  1. The login bot is the chief’s bot, unless defineProject({ widgets: { loginBot: '<bot name>' } }) names another — one bot signs people in for every bot’s widgets (a Telegram user id is the same to every bot), so pick the one whose name and picture people should see.
  2. the @BotFather mini app (https://t.me/botfather?startapp; the chat menu has no such item) → the login bot → Login Widget → Switch to OpenID Connect Login if it offers that. Trusted origin: https://widgets.<your-domain>; Redirect URL: https://widgets.<your-domain>/i/_login/callback, no trailing slash. Keep the signing algorithm RS256 (or ES256) — the others carry no user id.
  3. BotFather shows a Client Secret: it goes into the env var the login bot names in loginSecretEnv (bots: [{ name, tokenEnv, loginSecretEnv: 'NOVA_LOGIN_SECRET' }]; a brain without bots uses TELEGRAM_LOGIN_SECRET). An admin’s setting, like the bot’s token. A name of your own is declared in the brain’s env schema as well, like a tokenEnv: validation drops an undeclared var.
  4. Widgets need a hostname of their own — not shared with the webhooks or artifacts origin (domain mode gives widgets.<domain>): the sign-in’s cookie is scoped by host name.

doctor_check({ check: 'widget-browser-sign-in' }) prints those two URLs for your deployment, names what is missing (the bot, its secret, its own host) and reaches Telegram’s sign-in provider from the deployment. Without the setup every widget still opens inside Telegram, as before.

Where a browser may open a chat’s widgets is a key of the chat rules in constitution/access.yaml, browser: — audience (the engine default: whoever the widget admits), owners, or off — at a bot’s default, dm or one group’s level, like members (chat-access.md). A business chat (Secretary Mode) is off unless a rule opens it: the sign-in would show the counterpart your login bot and domain, and their gestures run as the business owner. Closing it is access_update; opening it (off → owners → audience) is access_widen, behind a person’s yes — on a Secretary bot that includes a browser written on its dm or default, which its business chats then read too.

Sending someone a link. widget_create({ file, to: 'tg:user:<id>', launch: false }) opens the widget in that person’s private chat with your role’s bot — their tier there — and posts nothing: hand the answer’s url over yourself (an email, another channel). They need not have written to the bot. The page is theirs by name only when your turn serves them (their own chat with you, a business chat’s counterpart); then launch may stay on and the bot posts the page’s button into their chat. Anyone else is a destination you typed, which no person approved: launch: false is required, and the page admits them only as that bot’s private-chat rule does (access.yaml makes them a member there — a DM’s one member is its peer) — to let them in by name, call widget_widen({ people: ['tg:user:<id>'] }), a person’s decision. Their submits arrive as events (a hook, a watch, a pipeline), or with deliver: 'chat' as turns of the agent serving their private chat — not as turns of yours (deliver: turn is for a widget in your own chat). A widget with no chat (opened from the dashboard or an MCP client) does not open in a browser; open one in the person’s private chat instead.

Sessions. A sign-in keeps that browser signed in for 30 days (an engine constant), on the widgets origin only; signing in again starts a new session and ends the old one. widget_list_sessions lists who is signed in where; widget_revoke_sessions ends one browser’s session or every one of a person — an open tab stops at its next call. Setting a person blocked with access_update signs them out of every browser as part of the same write. The person has Sign out and Sign out everywhere on the page. Ended sessions stay listed (includeEnded) for the widgets’ retentionDays, then go.

Decisions want a fresh sign-in. A HITL card’s page (§ 12) accepts a browser person only if they signed in within the widget’s authMaxAgeSeconds (default an hour); an older session is asked to sign in again, and a page left open past that age stops answering and asks too — a month-long cookie is no month-long right to approve. Other widgets accept the session as it stands; every call still re-admits the person through the chat’s rules.

Where an action came from. Every gesture’s action-log row says how the person signed in — credential: 'mini_app' (launch data) or 'web' (a browser); a HITL card answered from a page reads “in a browser” for the latter.

A chat whose rule is browser: off offers no sign-in at all: its page says it opens in Telegram, and nobody is shown the login bot.

16. Preview — see the page before a person does

Before you open a page for a person, look at it: widget_preview renders it in a headless browser inside the real host — the Telegram Mini App shell (with Telegram’s main button, haptics and theme) or the dashboard’s frame — under the page’s own CSP, and answers with screenshots and a report. The screenshots go to you only, as images beside the answer — never into a chat, a journal or the dashboard. Write → preview → fix, then widget_create.

{ "file": "ui://quiz/capitals", "input": { "questions": [ { "q": "Capital of Australia?", "options": ["Sydney", "Canberra"], "answer": 1 } ] } }

That is the whole call for the common case: one phone screenshot in Telegram’s light theme, and the report.

What to preview. file — a widget file (ui://<feature>/<name>), an engine page (ui://engine/form, ui://engine/review) or an HTML publication (artifact:<id>, which needs runtime:read, like artifact_get); or draft — a page not written yet: { path, specPath? }, the <name>.widget.html (and .widget.yml) you wrote in your artifacts directory, or { html, spec? } inline for a small one, spec being the .widget.yml text — checked exactly as the loader will check it. Exactly one of the two. A page may be up to 8 MiB. A draft that previews is a draft that opens: widget_create({ draft }) takes the same draft and reads it with the same code (§ 2) — preview it, open it live to test its tools, then feature_write_file it into the feature.

The report, problems first — verdict is one line: no problems found, or how many and the first:

  • a variant that did not render (the page was never shown, the render ran out of time), with the reason — when none rendered, status is failed and verdict says nothing rendered — …;
  • page errors and console errors;
  • what the CSP refused and the requests the preview stopped, each with the .widget.yml key that would allow it (csp.connectDomains, csp.resourceDomains) or the word that none can;
  • layout: horizontal overflow, clipped or overflowing text, elements off screen, tap targets under 24 px on a phone, contrast under 3:1, colours that ignore the theme (--color-*) in the dark;
  • a step that failed, a tool the page called that is not in its tools (refused, as live), a tool call you gave no mock for;
  • the input failing the file’s inputSchema or maxInputBytes — listed as a problem (widget_create would refuse it), and the page still renders.

A problem seen in several variants is listed once, with each. Then shots (each screenshot’s variant, step, element, size, what changed since your last preview — image its number among the attached images), and per variant what the page sent (submits, tool calls and how they were answered, messages, HITL answers, links, Telegram controls), the state it holds last and its accessibility tree as text (readable without vision; same as <variant> when it reads like an earlier one). notes say what was cut (a page taller than 4000 px, screenshots past ten, report entries past forty per list — every list keeps its first forty, and anything the page reported is cut at 4000 characters) and that fonts are approximate: the browser carries DejaVu only, so a system UI font falls back and emoji may show as boxes.

Variants — variants: [{ device: 'mobile' | 'desktop', theme: 'light' | 'dark', host: 'telegram' | 'dashboard' }], up to 8, each rendered on its own (default: one, mobile · light · telegram). Screenshots are full height, at device pixel ratio 2, scaled to a long edge of 1568 px, taken as a person scrolls: the frame keeps the device’s size, and a page (or element) taller than one screen comes as several screenshots, one per screen (top says where each starts), so its text stays readable and position: fixed bars, vh units and height media queries look as they will on that device — at most ten per preview.

Steps — a script run on every variant, in order: { click: '<selector>' }, { type: '<selector>', text }, { select: '<selector>', value }, { wait: <ms> | '<selector>' }, { push: { name, value } } or { push: { state } } (what widget_update would push), { mainButton: true } (Telegram’s main button), { shot: true } or { shot: '<selector>' } (a screenshot now — the page or one element). A script without a shot gets one at its end. Up to 30 steps.

Mocks — nothing leaves the preview. No real tool runs and the page reaches no network:

  • a page tool call (widget.callTool) on the spec’s tools answers your mock — { tool, result, event?, state? } (event — { name, value }, and state — what the tool’s code would push to the page with context.widgets.push; the state lands first, as live), or { tool, error }; with no mock it answers no mock for <tool>, and it is reported. A tool off the allowlist is refused with the code a live page gets;
  • a request to one of csp.connectDomains answers a mock { url, method?, status?, contentType?, body? } (a request whose URL is url or starts with it), else it is stopped and reported. A WebSocket never opens — no mock can answer one — so a wss: origin is reported as stopped even when listed;
  • csp.resourceDomains (scripts, fonts, images from a CDN) are fetched for real by the engine and cached, so a CDN page renders as it will — up to 100 files and about 32 MB a preview, past which a request is stopped and reported;
  • submits, messages and HITL answers are recorded, never delivered; media: { name: 'artifact:<id>' } hands the page files by name, as widget_create grants them (needs runtime:read; 5 MB together).

Diffs. Every screenshot is sent, and says how it compares with your last preview of the same page, variant and shot: first, unchanged (your fix did not move it), or changed with by how much and where. Within one turn of an API agent, a newer screenshot of the same tile replaces the older one in what the model re-reads ([superseded]), so a write → look → fix loop pays for each tile once.

Unavailable. The renderer is the deployment’s browser service (on by default, services.browser). Without it, or while it does not answer, the answer is status: 'unavailable' and verdict: 'preview unavailable: …' with the reason — nothing failed, there is just nothing to look with; busy — every browser slot stayed taken, try again. doctor_check({ check: 'widget-preview' }) says whether a preview can render now.

A preview is a draft’s look, not a live widget: no row, no launch, no person, nothing in the action log. A page bound to a HITL card previews unbound (widget.hitl is null).

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.