Skip to content

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.

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.

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.

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.

DestinationReply-file behaviorOther unsupported rich parts
SlackUses files.getUploadURLExternal, uploads bytes to Slack’s returned URL, then confirms with files.completeUploadExternal in the exact channel/thread.Concise human-readable warning.
TelegramSends a native sendDocument to the exact chat/reply target and preserves silent proactive delivery.Concise human-readable warning.
Web consoleShows 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 channelsPreserve the answer and render a safe warning when a part has no native representation.Same.
OpenAI-compatible APIKeeps 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.
WebhookKeeps text byte-for-byte unchanged and returns sanitized replyPartOutcomes.Sync responses, async status reads, and result callbacks retain the outcomes.
A2AKeeps 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 notificationKeeps 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.

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."
}
  • partIndex is rewritten to the dense zero-based output position.
  • partType is exactly attachment, mcp_app, restart_proposal, reply_options, failure, or unknown.
  • status is the terminal literal failed.
  • code is exactly one of app_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, or unsupported_destination.
  • message is reconstructed from the accepted type/code. Producer text is never copied.
  • affectedPartCount is 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 readerExact envelope / projection
OpenAI-compatible non-streamTop-level mono_agent.reply_part_outcomes on the chat.completion; choices[].message.content is unchanged.
OpenAI-compatible streamOne metadata-only chat.completion.chunk carries mono_agent.reply_part_outcomes before the ordinary finish_reason: "stop" chunk and [DONE].
WebhookTop-level replyPartOutcomes on successful sync JSON, terminal async status JSON, programmatic getStatus(), and the result callback. Store, callback, and each read receive independent arrays.
A2AA 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 adapterOptional top-level CronJobResult.replyPartOutcomes; a cancelled responder that resolves late may retain sanitized part outcomes but never its late text.
Cron durable/operator projectionPrivate 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 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.

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.