Reply files and MCP Apps
mono-agent can return more than answer text without placing file bytes, local paths, or app HTML in the reply stream. A response may carry an opaque file reference, an MCP App reference, quick reply choices, a restart proposal, or a visible per-part failure. These parts are additive: valid text and earlier parts remain deliverable when a later part fails.
Publishing a reply file
Section titled “Publishing a reply file”When PublishReplyFile is allowed by tools.allowedTools, the model can write a
file under the configured workspace (or its run-owned outbound directory) and
call the request-scoped tool with:
{ "path": "reports/weekly.csv", "name": "weekly.csv", "mediaType": "text/csv"}The host resolves the source inside an authorized root, rejects symlinks and non-files, opens it without following swapped path components where the platform supports that primitive, verifies the opened inode, and copies from the descriptor into an owner-private staging directory. It hashes while copying, writes the manifest, then atomically renames the whole finished directory into the live namespace. The reply contains only the sanitized name, media type, byte count, SHA-256 integrity id, expiry, and an opaque id. Host-private roots are refused even when they are located inside the configured workspace.
The default and maximum per-file limit is 20 MiB; configure a smaller value
with artifacts.replyFiles.maxFileBytes. Files and apps share one limit of 20 rich
parts per run. A retry with the same integrity identity reuses the first part;
the twenty-first distinct producer request returns a visible capability failure
to the model without replacing the first twenty.
A rejected publish attempt is superseded when the same run successfully
publishes an attachment with the same sanitized display name, regardless of
call order. The rejection still answers the model with its exact isError
reason so the file can be relocated, but the delivered reply carries only the
surviving attachment. Only publish-attempt failures are superseded, evaluated
after delivery binding against surviving attachments: capacity failures and
delivery-binding failures are never suppressed, an attachment lost at delivery
binding supersedes nothing, and a superseded rejection releases its reply-part
budget claim. A file that never publishes still produces exactly one visible
failure part.
PublishReplyFile is request-scoped. Its random loopback endpoint is
high-entropy, closes with the request, is not logged, and does not contain the
run id, conversation id, workspace, or artifact path. The tool is removed by
the host’s sealed tool policy and is not installed on a route that
cannot safely receive its MCP server.
Answer text written immediately before reply-presentation calls (PublishReplyFile,
SuggestReplies, or ProposeRestart, including MCP-qualified names) remains part
of the final reply. A trailing chain of presentation-only assistant messages is
joined with blank lines, followed by any final assistant text. Ordinary tool calls
break the chain; their interim narration is not promoted. The settled web console
shows promoted text in the answer, not again in Activity.
Presentation tools attach parts beneath the reply text; they do not replace the
answer or finish the turn. Their successful results remind the model to write its user-facing
reply as normal assistant text. If a normally settled session turn contains no text at
all, the runtime makes one same-session finalization request with tools disabled.
It never exposes thinking as an answer. Cancelled or failed turns, silent
completions, pending parent questions, max-turn stops, and structured-output turns
are excluded. If the retry is still empty, the existing empty_response failure
remains. The framework nudge is retained only in native provider-session history,
not displayed as a user message in channel transcripts or web Activity.
Web-only quick replies
Section titled “Web-only quick replies”SuggestReplies is an app-owned, request-scoped MCP tool on web-console
conversation turns, including existing web-bound background/job wakes. It is
non-blocking: the agent finishes its reply, and a click arrives later as a new
user turn in the same conversation. Use AskUser when the current run must
wait for a selection. Ordinary tools.allowedTools / tools.disallowedTools
policy applies; a restrictive allow-list must include SuggestReplies.
{ "options": ["Review draft", "Keep going", "Try another approach"] }Supply 2–8 distinct single-line labels, each 1–75 characters after trimming.
Control characters and duplicate trimmed labels are rejected. The tool adds
one reply_options part containing only type, an opaque id, and options.
A later call in the same run replaces the choices without claiming another
slot in the shared 20-part reply budget. The choices persist with the assistant
message and appear as keyboard-accessible, wrapping buttons directly below the
final answer. A click sends the label verbatim through the normal chat path and
shows it as a user message. Buttons become disabled after a click, while a turn
is running, or once any later user message exists. There is no separate click
store or cross-device lock: the later user message makes old choices inert on
reload and other devices.
Telegram, Slack, webhook, terminal, ACP, cron, A2A and OpenAI-compatible turns
never receive this tool. Unexpected delivery to a non-web human destination
adds only a concise warning, not choices or actionable controls. Machine
transports leave answer text unchanged and expose a sanitized
unsupported_destination outcome with partType: "reply_options", never labels.
Web-only restart proposals
Section titled “Web-only restart proposals”ProposeRestart is an app-owned, request-scoped MCP tool only on an
interactive web-console turn whose keyed, supervised agent advertises verified
restart support; ordinary tool allow/deny policy still applies. A call with
{ "reason": "short explanation" } records at most one additive
restart_proposal part on that assistant reply. The part contains only
{ "type": "restart_proposal", "id": "opaque id", "reason": "optional short text" }:
reason is single-line and capped at 280 characters; no URL, bearer, command,
agent target, source id or process generation is model-supplied. The tool
reports proposed, never restarted. The web service binds the stored
part to its real conversation, source and process generation. An operator sees
it directly beneath the answer, reviews the interruption warning, and clicks
Confirm to use the same restart flow as selected-agent settings. Old, used,
offline and unsupported proposals remain readable but cannot start a second
request. Read restart one agent
for outcomes and the trust boundary.
Telegram, Slack, webhook, terminal, ACP, cron and other non-web turns never
receive the tool. If a producer nevertheless delivers this part to another
human destination, its existing unsupported-part fallback says it could not
be delivered, with no actionable link or restart. Machine/verbatim
transports leave assistant text unchanged and return only sanitized terminal
unsupported_destination outcomes.
Channel delivery and fallback
Section titled “Channel delivery and fallback”| Destination | Reply-file behavior | Other unsupported rich parts |
|---|---|---|
| Slack | Uses files.getUploadURLExternal, uploads bytes to Slack’s returned URL, then confirms with files.completeUploadExternal in the exact channel/thread. | Concise human-readable warning. |
| Telegram | Sends a native sendDocument to the exact chat/reply target and preserves silent proactive delivery. | Concise human-readable warning. |
| Web console | Shows a message-bound download control after server-side authorization and integrity verification. | Quick replies and restart proposals render beneath the answer; MCP Apps render as described below; failures remain individual message parts. |
| Terminal and other human channels | Preserve the answer and render a safe warning when a part has no native representation. | Same. |
| OpenAI-compatible API | Keeps assistant content byte-for-byte unchanged and returns sanitized failures in mono_agent.reply_part_outcomes. | Non-stream JSON and a metadata-only SSE chunk use the same bounded shape. |
| Webhook | Keeps text byte-for-byte unchanged and returns sanitized replyPartOutcomes. | Sync responses, async status reads, and result callbacks retain the outcomes. |
| A2A | Keeps answer text byte-for-byte unchanged and adds an A2A structured data Part to the final artifact. | Attachments and MCP Apps become explicit unsupported_destination failures; no file bytes or private references cross A2A. |
| Cron / verbatim notification | Keeps answer text byte-for-byte unchanged and retains sanitized replyPartOutcomes on CronJobResult. | The app records one outcome audit for the run; native notification carries text only and never claims a file was sent. |
Machine delivery outcome wire contract
Section titled “Machine delivery outcome wire contract”Every machine adapter uses the shared
AgentReplyPartDeliveryOutcome sanitizer. It emits a dense array with at most
20 entries and this exact ordinary record shape:
{ "partIndex": 0, "partType": "attachment", "status": "failed", "code": "unsupported_destination", "message": "Attachment reply parts are unsupported on this destination."}partIndexis rewritten to the dense zero-based output position.partTypeis exactlyattachment,mcp_app,restart_proposal,reply_options,failure, orunknown.statusis the terminal literalfailed.codeis exactly one ofapp_capability_mismatch,app_connection_closed,app_resource_invalid,artifact_expired,artifact_integrity_failed,artifact_missing,artifact_publish_failed,artifact_too_large,reply_part_too_large, orunsupported_destination.messageis reconstructed from the accepted type/code. Producer text is never copied.affectedPartCountis forbidden on ordinary records. Only the final overflow record has it: index 19,partType: "unknown",code: "reply_part_too_large", and the fixed message “Additional reply parts exceeded the bounded delivery outcome limit.”
Sparse holes, undefined, null, primitives, accessor-backed values, failed
descriptor reads, extra fields, unknown types/codes, and non-terminal statuses
cannot place unsafe values or literal JSON null into the array. Each malformed
entry becomes an independent fixed unknown failure, so it cannot mutate or
erase valid siblings. Non-array and empty inputs omit the field. An off-contract
array above 20 entries becomes the first 19 individual records plus the counted
aggregate; its count is the full source length minus 19.
The response locations are exact and additive:
| Producer or reader | Exact envelope / projection |
|---|---|
| OpenAI-compatible non-stream | Top-level mono_agent.reply_part_outcomes on the chat.completion; choices[].message.content is unchanged. |
| OpenAI-compatible stream | One metadata-only chat.completion.chunk carries mono_agent.reply_part_outcomes before the ordinary finish_reason: "stop" chunk and [DONE]. |
| Webhook | Top-level replyPartOutcomes on successful sync JSON, terminal async status JSON, programmatic getStatus(), and the result callback. Store, callback, and each read receive independent arrays. |
| A2A | A final artifact data Part with media type application/vnd.mono-agent.reply-part-outcomes+json and value { "schemaVersion": 1, "replyPartOutcomes": [...] }, beside the unchanged text Part. |
| Cron adapter | Optional top-level CronJobResult.replyPartOutcomes; a cancelled responder that resolves late may retain sanitized part outcomes but never its late text. |
| Cron durable/operator projection | Private SQLite column cron_runs.reply_part_outcomes_json stores { "schemaVersion": 1, "replyPartOutcomes": [...] }. Legacy NULL rows mean absent outcomes. Detail CronOperatorRun.replyPartOutcomes retains all 20 records; compact summaries retain the first eight in stable part order so a 100-run page stays below the operator response ceiling. The web client strictly reparses both projections after restart. |
The version applies to the A2A and SQLite envelopes. Direct OpenAI, webhook, cron result, operator, and web projections are unversioned additive fields and must be feature-detected. No outcome envelope copies part ids, filenames, local or host-only URLs, capability values, integrity ids, producer error messages, or payload bytes. Cron run rows retain the sanitized outcomes, while rollback-safe run-now idempotency receipts omit them. Replay derives outcomes from a retained row and accepts their absence after row eviction. The first terminal cron state cannot be overwritten by a late callback or event.
Slack and Telegram remove a file part from textual fallback only after the platform confirms its native upload. The deduplication key binds the file integrity id to the destination and thread/reply target, so a retry does not post the same file twice and delivery to another destination remains distinct. Upload failures retain the safe fallback and never include the artifact path or private capability URL.
Downloads are authorized against the exact message/conversation ownership and
expected integrity id, rehash the retained bytes, set Accept-Ranges: none, and
stream only after the manifest, size, and hash agree.
MCP Apps
Section titled “MCP Apps”MCP Apps are enabled only when every possible runtime route supports the
host-owned bridge. Today that means a Pi-native route with no direct-runtime
fallback. The MCP client advertises the standard
text/html;profile=mcp-app capability to the server; the browser bridge then
explicitly intersects the App’s requested revision with the two revisions
reviewed from @modelcontextprotocol/ext-apps 1.7.5 (2026-01-26 and
2025-11-21). Unsupported routes do not receive the extension and do not
advertise an operator capability.
For one successful MCP tool result, the Pi bridge reads only the tool’s declared
ui:// resource through that originating MCP connection. The app host stores
the exact resource, bounded tool input/result, app-visible tool names, and one
declared resource URI. It does not enumerate server resources. A later
resources/read request is allowed only for that exact declared URI and exact
connection; cross-resource and cross-connection reads are denied.
The web console uses two nested iframes. Both are opaque-origin sandboxes with
exactly allow-scripts—no same-origin, popups, forms, or top navigation. When
the sanitized resource grant includes clipboard writes, and only then, both
levels also delegate clipboard-write. The trusted outer proxy loads from a
fixed, same-origin, no-store response with its own CSP; the console shell keeps
script-src 'self'. Invocation data appears in neither the proxy document nor
its URL. The direct parent supplies bounded configuration after load, the proxy
derives the parent origin from that browser-authenticated event, and the first
valid nonce/invocation/connection tuple permanently binds the instance. A
matching repeated configuration re-arms the bridge and retires the prior inner
frame; repeated host-ready remains an ignored one-shot acknowledgement. Origin,
parent, identity, or permission replacement is rejected. Parent and inner
traffic remains bound by exact source window, origin where available, that
identity tuple, and bounded JSON-RPC messages. A second inner-frame navigation
removes the frame, and callbacks from a retired frame cannot affect its
replacement.
The inner app remains srcdoc, so it inherits the proxy response policy. That
route-local envelope explicitly permits inline script for the fixed proxy/app
bootstrap. With no default-src, the fixed proxy’s inline bootstrap style still
works. The envelope retains non-clipping containment directives and deliberately
omits default-src, connect-src, style-src, img-src, font-src,
media-src, frame-src,
child-src, and base-uri. The canonical sanitized inner meta policy is
therefore authoritative for those capabilities. Before assigning srcdoc, the
proxy requires the bounded HTML to begin with that exact policy/referrer output
shape. The SPA shell policy is never relaxed to make an App executable, and no
remote origin ever enters script-src.
Server-declared network, image, frame, and resource origins are intersected
with a host allowlist; the default is empty. Resource origins never enter
script-src, so a declaration such as evil.com cannot grant remote script,
connect, image, frame, or base access. Inline script is permitted only inside
the isolated app document because MCP App HTML is executable UI.
Tool calls, external links, and model-context requests require an explicit web confirmation. Tool dialogs show a 2 KiB, depth- and item-bounded argument preview with token, credential, password, cookie, and similar keys redacted. While the dialog is pending, the app iframe and header are inert and removed from keyboard navigation; focus is trapped in the dialog and restored after a decision. Exact declared resource reads are read-only and do not prompt.
Lifetime and limits
Section titled “Lifetime and limits”Configured agents use artifacts.retention.maxAgeDays for stored reply files
and app resources (365 days by default); direct service composition defaults to
30 days. Cleanup removes expired complete publications and old abandoned
staging directories, but tracks active staging ids so a concurrent cleanup
cannot delete an in-flight publish.
An MCP App’s live originating connection is shorter-lived than its stored
record. The host retains at most eight connections by least-recently-used order,
evicts one after ten idle minutes, and closes the underlying MCP client and
transport on eviction or shutdown. A stored app whose connection is gone
reports connected: false and cannot call tools or read resources.
Bridge requests are limited to 64 KiB, results to 1 MiB, and each connection to
60 requests per minute. Each app keeps a rotating owner-private audit log with
a 256 KiB file ceiling and two retained rotations. Durable rich-reply payloads
use artifacts.replyFiles.maxStorageBytes (2 GiB by default;
"unlimited" disables content quota rejection). Configured composition reserves 1 MiB
for independently admitted audit records when MCP Apps are enabled; finite
budgets must exceed that reserve. Published files use
artifacts.replyFiles.maxFileBytes (20 MiB by default and at most 20 MiB),
because web, Slack and Telegram delivery buffer whole files and web reply
storage enforces its own 20 MiB bound. Values above 20 MiB fail config
validation rather than producing undownloadable files. Retention pruning still
applies with unlimited storage. Audit storage uses fair-share,
oldest-segment reclamation when that reserve fills. One bounded inventory per
artifact-root lifecycle restores accounting after a restart; later appends
update exact in-memory ownership under one process-wide gate instead of
rescanning every invocation. Reclamation removes rotated history first, then
inactive and unprotected owners’ active files only as a last resort. A live or
protected owner’s active audit.jsonl, including an in-flight confirmation, is
never a candidate. Foreign owners that cannot be inspected are isolated under
bounded conservative accounting and remain quarantined for the process
lifetime; recovery requires a process restart and fresh root inventory. Append,
rotation, cleanup, and quota reclamation revalidate the
audit root, owner directory, and singly linked file identities after the
operation hook and before using a child path. Admission is rejected before
deletion when the available candidates cannot safely create enough room,
preserving unrelated and out-of-root history. Audit entries
contain only host-owned identity, method, timestamp, and phase fields—never
model-filled tool names, arguments, resource URIs, URLs, or tool results.
Filling the model-visible budget fails only the new rich-reply part without
evicting retained content or blocking a later audited bridge action. Stable
errors distinguish oversize, rate-limited, forbidden, confirmation-required,
audit-failed, audit-incomplete, expired, and closed connection outcomes. Failure
to admit the pre-action confirmation and its bounded completion record returns
app_audit_failed and refuses execution. The completion bytes remain reserved
until the tool returns. If their real filesystem write then fails, the operator
and web transports preserve app_audit_incomplete as a conflict outcome rather
than a generic gateway failure. The side effect may have happened, so callers
must not retry automatically.
The operator producer continues to cap each NDJSON frame at 256 KiB. The web consumer accepts up to the legacy 8 MiB boundary so a new console remains compatible with an older running agent.