Outbound Media Delivery
Deliver agent-generated images and files to users via Telegram, Slack, Discord, and other messaging adapters
Agents can now return images, charts, and files that are automatically delivered through the bot adapter’s native upload primitive โ Telegram send_photo, Slack files_upload_v2, Discord file send, and more.
from praisonaiagents import Agent
chart_agent = Agent(
name="ChartAgent",
instructions="Generate charts and return file paths in the media field.",
)
chart_agent.start("Draw a sales chart for Q1 and send it to the user.")The user messages the bot; generated images and files upload through the channel adapter automatically.
Quick Start
- Simple Usage
python from praisonaiagents import Agent chart_agent = Agent( name="ChartAgent", instructions=""" Generate the requested chart, save it under /tmp/agent-media/, then return a message that includes the file path in the 'media' field. """, ) - With Configuration
yaml roles: - role: ChartAgent backstory: Generates and delivers charts. tasks: - description: Draw a chart for the user's request. media_delivery: enabled: true max_size_mb: 10 strict: true allowlist_roots: - /tmp/agent-media recent_mtime_seconds: 300Start the bot:
bash praisonai bot telegram --config bot.yamlUsers receive text plus the chart image attached automatically.
How It Works
When an agent returns a media file path, the gateway’s outbound binding validates it through the path safety guard and delivers it via the adapter’s native primitive:
On the send_message path, uploads to an unattested <platform>:<chat_id> are refused upstream in BotOutboundMessenger.send() โ the media path is never entered. See Destination Attestation.
Path safety guard
validate_media_delivery_path() runs two checks before any file is sent:
System directory denylist (always blocked): /etc, /proc, /sys, ~/.ssh, ~/.aws, ~/.gnupg, gateway secret/pairing directories.
Credential basename denylist (always blocked): .env, id_rsa, id_ecdsa, id_ed25519, .npmrc, .netrc, and similar credential filenames โ matched from any directory.
Remote-sandbox media (Modal, E2B, Daytona, Fly.io, SSH, โฆ)
When an agent runs off-box in a remote sandbox and generates a file, the gateway fetches the artifact back and delivers it through the same MEDIA path โ the user gets the file automatically.
from praisonaiagents import Agent, SandboxConfig
chart_agent = Agent(
name="ChartAgent",
instructions=(
"Generate the requested chart, save it under /workspace/, "
"then return a MEDIA:/workspace/<filename>.png line."
),
sandbox=SandboxConfig(sandbox_type="modal"), # or e2b, daytona, flyio, ssh, novita
)
chart_agent.start("Draw a sales chart for Q1 and send it to the user.")The user sends a message. The agent runs remotely, writes the chart on the sandbox filesystem, and returns MEDIA:/workspace/chart.png. The gateway detects the remote path, fetches it back, re-screens it, and delivers it โ no extra wiring.
End-to-end flow
Behaviour
| MEDIA path shape | Resolver present? | Behaviour |
|---|---|---|
| Local file that exists on the gateway host | any | Delivered as before |
http:// / https:// URL | any | Delivered as before |
Sandbox path (e.g. /workspace/chart.png) | โ none | Dropped + warning logged (historical behaviour) |
| Sandbox path owned by resolver | โ | Fetched to local, re-screened, delivered |
| Sandbox path not owned by resolver | โ | Dropped + warning logged |
Sandbox path, fetch_to_local raises | โ | Dropped + warning logged, never crashes the reply |
| Fetched file rejected by delivery guard | โ | Dropped + warning logged |
Unlike the old silent drop, the operator now always gets a logger.warning(...) when a MEDIA path is undeliverable, so the failure is visible in logs.
The RemoteMediaResolver protocol
Exported from praisonaiagents.gateway:
from praisonaiagents.gateway import RemoteMediaResolver
@runtime_checkable
class RemoteMediaResolver(Protocol):
"""Resolves a sandbox-local media path to a gateway-local deliverable path."""
def owns_path(self, path: str) -> bool:
"""True if `path` refers to a file inside this sandbox."""
...
async def fetch_to_local(self, remote_path: str) -> str:
"""Fetch `remote_path` out of the sandbox to a local path.
The caller re-screens the returned path through the outbound-path guard."""
...| Method | Signature | Purpose |
|---|---|---|
owns_path | (path: str) -> bool | Non-blocking check: is this a path inside my sandbox? |
fetch_to_local | async (remote_path: str) -> str | Pull the artifact into a local delivery cache; returns the gateway-local path |
Each remote sandbox backend already has a download_file / read_file primitive (Modal read_file, E2B read_file, Daytona fs.download_file, Fly.io compute.download_file, SSH read_file, Docker download_file, Novita read_file, Subprocess read_file). The active sandbox backend for the turn is the resolver โ application code does not implement this protocol itself.
Compatibility & safety
- Byte-for-byte backward compatible. With no resolver in play,
split_media_from_output_async()returns the same dict shape and values as the existingsplit_media_from_output()โ local paths and URLs are untouched. - No new required parameters on
Agent. No new dependencies added. No per-turn plumbing surfaced to user code. - The exfiltration guard still runs. Fetched artifacts are re-screened by
validate_media_delivery_path()before delivery, so the system-directory / credential-basename denylist, size cap, strict-mode allowlist, andrecent_mtime_secondswindow all still apply. See Path safety guard. - Fail-open on error. A resolver failure (missing/wrong backend, download error, guard rejection) degrades to the historical silent drop plus an operator-visible warning โ never a crash on the reply path.
- The sync
split_media_from_output()is unchanged. Only the new async variantsplit_media_from_output_async()is remote-aware.
When does this fire?
Configuration (media_delivery YAML block)
media_delivery:
enabled: true
max_size_mb: 25
strict: false
allowlist_roots:
- /var/agent/output
- /tmp/agent-media
recent_mtime_seconds: 600
basename_denylist:
- secret_config.ymlOutboundMediaPolicy fields
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | True | Master switch. Quoted "false" in YAML also disables |
max_size_mb | int | 25 | Per-file size cap in megabytes |
strict | bool | False | If True, only deliver files inside allowlist_roots or with mtime within recent_mtime_seconds |
allowlist_roots | list[str] | [] | Strict-mode: only files under these roots are delivered |
recent_mtime_seconds | int | 600 | Strict-mode: only files modified within this window (seconds) are delivered |
basename_denylist | list[str] | [] | Extra basenames added on top of the built-in denylist |
Platform Support
| Platform | Native primitive | Media types |
|---|---|---|
| Telegram | send_photo, send_document | Images, documents |
| Slack | files_upload_v2 | Any file type |
| Discord | File attachment | Any file type |
PlatformCapabilities.supports_media is checked per adapter โ platforms that don’t support file uploads receive text-only replies and the attachment is skipped with a log entry.
Threaded media delivery
Attachments follow the same platform:channel_id:thread_id grammar the text path uses. When a route names a thread, both the text reply and any uploaded file land in that thread.
from praisonai.bots import DeliveryRouter
# Scheduled attachment into a Slack thread
router.send_media("slack:C0123456:1728987654.001234", "/tmp/report.pdf",
caption="Weekly report")
# โ Slack files_upload_v2(..., thread_ts="1728987654.001234")
# Telegram forum topic
router.send_media("telegram:-1001234567890:42", "/tmp/chart.png",
caption="Q1 chart")
# โ bot.send_photo(..., message_thread_id=42)| Platform | thread_id becomes | Ignored on non-threaded adapters |
|---|---|---|
| Slack | thread_ts on files_upload_v2 | โ |
| Telegram | message_thread_id on send_photo / send_document | โ |
| Discord | Thread channel id used as the send target | โ |
| WhatsApp / Email / custom | โ (no thread concept) | โ silently dropped |
DeliveryRouter.send_media resolves the thread from the target and forwards it to deliver_media_to_adapter, which passes it through each transport’s native keyword only when the primitive accepts it โ so non-threaded adapters never raise.
Discord thread targeting is scoped to the parent channel
A target shaped discord:<channel_id>:<thread_id> resolves the thread under the parent channel. If <thread_id> isn’t a thread of <channel_id>, the upload lands in the parent channel rather than being redirected to a channel that happens to share that global id. This matches how send_message‘s text path already resolves Discord threads.
Retry & backoff on transient failures
Media uploads go through the same deliver_with_retry wrapper as text replies. A transient transport failure โ HTTP 5xx, rate limit, connection reset โ is retried with bounded exponential backoff (honouring a server-mandated Retry-After hint) instead of silently dropping the attachment on the first blip.
Which backoff policy is used?
DeliveryRouter.send_media reuses the adapter’s already-configured _outbound_backoff BackoffPolicy if one is set (matching the text path). If the adapter exposes no policy, a sensible default is used:
| Field | Default |
|---|---|
initial_ms | 1000 |
max_ms | 10000 |
factor | 1.5 |
max_attempts | 3 |
To customise, set outbound_resilience on the channel in praisonai.yml (the same block that tunes text retries โ see Outbound Resilience). The media upload picks up the same values automatically.
Non-retryable outcomes are preserved
| Outcome | Behaviour |
|---|---|
| Adapter exposes no upload primitive | Returns False immediately โ no retry. |
Missing optional discord package | Logged as “unavailable”, returns False โ no retry. |
| Permanent transport error (e.g. 4xx that is not 429) | Surfaces as a failed send after policy exhaustion. |
Common Patterns
Chart generation bot
from praisonaiagents import Agent
import matplotlib.pyplot as plt
import os
chart_agent = Agent(
name="ChartAgent",
instructions="""
When asked for a chart:
1. Generate it using matplotlib
2. Save to /tmp/agent-media/<filename>.png
3. Return the file path in your response
""",
)media_delivery:
enabled: true
max_size_mb: 10
strict: true
allowlist_roots:
- /tmp/agent-media
recent_mtime_seconds: 300Multi-tenant / hosted gateway (strict mode)
For hosted environments where multiple users share the same gateway, enable strict mode with an explicit output directory:
media_delivery:
enabled: true
max_size_mb: 5
strict: true
allowlist_roots:
- /var/agent/output
recent_mtime_seconds: 120
basename_denylist:
- config.yml
- secrets.jsonDisable media delivery
media_delivery:
enabled: falseOr with quoted string (both are equivalent):
media_delivery:
enabled: "false"Summary Messages
When media delivery completes, BotOutboundMessenger.send appends a summary:
2 attachment(s) delivered, 1 skippedSkipped files (denied by path guard, over size cap, etc.) are logged at debug level and never cause the message send to fail.
Best Practices
Write agent output to a dedicated directory
Configure your agent to save all generated files to a single directory like /tmp/agent-media/ and set that as the allowlist_roots entry. This keeps the path guard simple and predictable.
Enable strict mode for production
In production or multi-tenant gateways, set strict: true with explicit allowlist_roots. This prevents agents from accidentally delivering files from unexpected locations.
Set a reasonable size cap
Default is 25 MB. Telegram and Slack have their own size limits โ set max_size_mb below those limits to fail fast at the gateway rather than at the adapter.
Never save credentials near the output directory
The basename denylist blocks common credential filenames, but keep your output directory completely separate from config directories. Symlink attacks are also blocked (symlinks are resolved before path checks).
Target the thread for reports that belong to a conversation
Use platform:channel_id:thread_id โ not just platform:channel_id โ for reports and charts that belong to an ongoing thread. Text and attachment now land together in the same thread; the :channel_id form still posts to the parent chat.
When you run agents in a remote sandbox, instruct them to save generated files under a stable directory (e.g. /workspace/) and reference that exact path in MEDIA: lines. Every shipped remote backend’s owns_path recognises its sandbox filesystem root, and the fetched copy is validated by the same validate_media_delivery_path() guard as local files โ so the same allowlist_roots / strict / max_size_mb rules apply after fetch.
Related
Full bot setup and configuration guide
Channels GatewayGateway channel routing and configuration
GatewayGateway architecture and YAML reference
Bot Inbound MediaHandle images and files sent by users
Destination AttestationWhy sends to unknown chats are refused
Sandbox BackendsWhere the agent’s code actually runs
Outbound Turn LivenessA stopped or superseded turn cannot send, react, thread, edit or delete