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 replies | a 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 keep | a 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 updates | a 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:
| Key | Default | Meaning |
|---|---|---|
description | none | The 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 |
inputSchema | none | JSON 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
| Tool | What it does |
|---|---|
widget_preview | See 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_create | Open 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_update | Push 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_close | End it: the page stops accepting anything and the launch message loses its button. A closed widget is never reopened. |
widget_get | Everything 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_list | Your widgets (open ones by default). |
widget_widen | Let more people use an open widget — named people (people), or everyone its chat admits (tiers: ["member"]). Needs a person’s approval. |
widget_list_sessions | The 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_sessions | Sign 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.
| Key | Default | Meaning |
|---|---|---|
ttlHours | 24 | Lifetime of a new widget; it closes (ttl) when it passes |
maxTtlHours | 168 | MANIFEST only — the cap on what a widget may ask for (a HITL card’s page has no lifetime of its own — § 12) |
authMaxAgeSeconds | 3600 | Oldest Telegram launch data a page may sign in with |
deliver | turn | turn — 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) |
debounceMs | 3000 | A burst of submits lands as one turn: the turn waits this long after the latest submit |
maxWaitMs | 30000 | …but never longer than this after the oldest one waiting, so a steady stream still gets its turn |
maxInputBytes / maxSubmitBytes / maxStateBytes | 64 KiB / 16 KiB / 256 KiB | Size caps on the opener’s input, one submit (or page message), the state and one pushed event |
maxMediaBytes | 25 MiB | One media file made for the page — by a generating tool’s widget or your code’s media bytes (§ 5) |
maxTurnContextBytes | 8 KiB | How 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 |
submitsPerMinute | 30 | Per 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 |
toolTimeoutSeconds | 60 | How long one tool call from the page may run; past it the tool is stopped and the page is told it took too long |
audience | served | Who may use a new widget: served — the person the opening turn served, and the owners; owners — the owners alone |
transcript | chat | Where 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 |
maxOpenPerAgent | 5 | MANIFEST only |
retentionDays | 30 | MANIFEST 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:
deliver | The submit reaches |
|---|---|
events | code 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 |
chat | all 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:submittedfires on every submit; filter on the page’stype(a top-level field), or decide in a<hook>.hook.tsgate:--- # 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
debounceMsafter the latest submit, never longer thanmaxWaitMsafter 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_updatefor 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 withwidget_get, fenced (stateContent). What your code must tell the agent, it pushes as an event. What another agent wrote — withdeliver: 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 bymaxTurnContextBytes, 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 bumpsversion; JSON, at mostmaxStateBytes. A constant isstate: () => 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 mostmaxMediaByteseach, 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 fromwidget.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 }:
code | What it asks of you |
|---|---|
not_found, closed | The 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_yours | This caller may not push to that widget (below) — a wrong id passed in, usually |
too_large | The state or event is over maxStateBytes, a file over maxMediaBytes |
too_many | More than 20 media names |
not_live, not_allowed | A granted publication is not live, or private and not this caller’s to show (§ 7) |
no_origin | The 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 from | It 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 turn | the widgets that agent opened |
| Brain code — a pipeline step, a workflow run’s code | any widget |
| An MCP session with no agent | the 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 dashboard | any 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 whencontext.widgets.pageis 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 withoutecho— 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(policytranscript, § 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_updateand 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
| Call | What 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, .hostContext | What 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.telegram | null 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, bypostMessage. - No network by default. The page may fetch only from
csp.connectDomains; images, media, fonts and scripts load inline, fromdata:/blob:, fromcsp.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 whoeverwidget_widenadded; 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 themembertier admits the DM’s own person — not every member of the brain, nor anyone amembers: everyonerule 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: chatturn runs as them too, re-admitted when it is delivered. - Tools.
widget.callToolreaches only the names in the spec’stools, 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’scapabilities). 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 mosttoolTimeoutSeconds; 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’sstateContent), 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 withwidget.openLink.
8. Limits
| What | Limit |
|---|---|
Opener’s input / one submit or message / state or one pushed event | maxInputBytes / maxSubmitBytes / maxStateBytes (§ 4) |
| Submits, messages and non-read tool calls per person per widget | submitsPerMinute (§ 4) |
| Open widgets per agent | maxOpenPerAgent (§ 4) |
| Lifetime | ttlHours, capped by maxTtlHours (§ 4) |
| Media per widget | 20 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 renders | a page file 8 MiB, inline HTML 512 KiB, a spec 64 KiB (engine constants) |
Extra resources per widget | 10 |
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:
| Chat | Launch |
|---|---|
| A private chat with the bot | the launch message’s button opens the page in place |
| A group, a forum topic, a channel — the bot has a Main Mini App | the button is a direct link (t.me/<bot>?startapp=…): one tap opens the page in place, over the chat |
| The same, no Main Mini App | the 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 client | no 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.
| Card | Its page | The page answers |
|---|---|---|
An approval with a widget action — { id, label, widget: { file, input? } } in actions | the 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 request | payload.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, nodeliver: chat(refused in the call; a file’s or MANIFEST’schatopens asturn);deliver: turnbrings 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 getsevents: 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: chatwidget’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.jsat the content origin, and_meta.uifrom its spec:csp(connectDomains,resourceDomainsplus 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’sinputSchemaand whose_meta.ui.resourceUriis the file: the host’s model calls it, the engine opens a widget with no chat for the session’s principal throughwidget_create(every gate of it — the session needswidget: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 (thecapabilitiesof its MCP URL); every other tool is marked model-only, so the page reaches tools only through itstoolslist.
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:
- 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. - 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. - 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 withoutbotsusesTELEGRAM_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 atokenEnv: validation drops an undeclared var. - 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,
statusisfailedandverdictsaysnothing rendered — …; - page errors and console errors;
- what the CSP refused and the requests the preview stopped, each with the
.widget.ymlkey 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
inputfailing the file’sinputSchemaormaxInputBytes— listed as a problem (widget_createwould 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’stoolsanswers your mock —{ tool, result, event?, state? }(event—{ name, value }, andstate— what the tool’s code would push to the page withcontext.widgets.push; the state lands first, as live), or{ tool, error }; with no mock it answersno 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.connectDomainsanswers a mock{ url, method?, status?, contentType?, body? }(a request whose URL isurlor starts with it), else it is stopped and reported. A WebSocket never opens — no mock can answer one — so awss: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, aswidget_creategrants them (needsruntime: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).