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.

python
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.

Diagram

Outbound Media Delivery

no

yes

platform:channel

platform:channel:thread

๐Ÿค– Agent

media path

validate_media_delivery_path

Safe?

โ›” Skip + log

Route names a thread?

๐Ÿ“ค Adapter primitive

๐Ÿงต Adapter primitive + thread

Telegram / Slack / Discord

Quick Start

  1. 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.
        """,
    )
  2. 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: 300

    Start the bot:

    bash
    praisonai bot telegram --config bot.yaml

    Users 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:

Diagram
Bot Adaptervalidate_media_delivery_pathOutboundMediaPolicyAgentBot Adaptervalidate_media_delivery_pathOutboundMediaPolicyAgentthread_id forwarded via native kwarg(thread_ts / message_thread_id) when supported/etc, /proc, ~/.ssh, ~/.aws,.env, id_rsa etc. โ†’ always rejectedreturn media path: /tmp/agent-media/chart.pngvalidate pathโœ… safe (within allowlist, under size cap)deliver_media_to_adapter(path, thread_id?)send_photo / files_upload_v2 / file sendN attachment(s) delivered, M skipped

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:

Diagram

Path Safety Checks

match

no match

match

no match

yes

no

no

yes

yes

no

File path

Resolve symlinks

System dir denylist?

โ›” Denied

Credential basename?

Over size cap?

strict mode?

โœ… Deliver

In allowlist roots
or recent mtime?

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.

python
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.

Diagram

Remote-sandbox media bridge

yes / http URL

no

no

yes

reject

accept

๐Ÿค– Agent
in remote sandbox

๐Ÿ“Ž MEDIA:/workspace/chart.png

๐Ÿ” Local file?

โœ… Deliver

๐Ÿงญ Resolver owns path?

โš ๏ธ Drop + warn

โฌ‡๏ธ fetch_to_local

๐Ÿ›ก๏ธ validate_media_delivery_path

End-to-end flow

Diagram
Bot Adaptervalidate_media_delivery_pathRemoteMediaResolver(sandbox backend)split_media_from_output_asyncAgent (remote sandbox)UserBot Adaptervalidate_media_delivery_pathRemoteMediaResolver(sandbox backend)split_media_from_output_asyncAgent (remote sandbox)User“Draw a Q1 sales chart”“Here it is.\nMEDIA:/workspace/sales.png”os.path.exists(“/workspace/sales.png”) โ†’ Falseowns_path(“/workspace/sales.png”)Trueawait fetch_to_local(“/workspace/sales.png”)“/tmp/agent-media/โ€ฆ/sales.png” (local copy)validate_media_delivery_path(local_copy)โœ… safemedia_urls=[local_copy], text=”Here it is.”“Here it is.” + sales.png attached

Behaviour

MEDIA path shapeResolver present?Behaviour
Local file that exists on the gateway hostanyDelivered as before
http:// / https:// URLanyDelivered as before
Sandbox path (e.g. /workspace/chart.png)โŒ noneDropped + 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:

python
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."""
        ...
MethodSignaturePurpose
owns_path(path: str) -> boolNon-blocking check: is this a path inside my sandbox?
fetch_to_localasync (remote_path: str) -> strPull 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 existing split_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, and recent_mtime_seconds window 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 variant split_media_from_output_async() is remote-aware.

When does this fire?

Diagram

yes

no

no

yes

no

yes

no

yes

Agent returned MEDIA:<path>

Path exists locally
or starts with http?

โœ… Deliver as before

Turn ran in a remote sandbox
with a resolver?

โš ๏ธ Drop + warn

Resolver owns_path?

โฌ‡๏ธ fetch_to_local

Guard accepts?


Configuration (media_delivery YAML block)

yaml
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.yml

OutboundMediaPolicy fields

FieldTypeDefaultDescription
enabledboolTrueMaster switch. Quoted "false" in YAML also disables
max_size_mbint25Per-file size cap in megabytes
strictboolFalseIf True, only deliver files inside allowlist_roots or with mtime within recent_mtime_seconds
allowlist_rootslist[str][]Strict-mode: only files under these roots are delivered
recent_mtime_secondsint600Strict-mode: only files modified within this window (seconds) are delivered
basename_denylistlist[str][]Extra basenames added on top of the built-in denylist

Platform Support

PlatformNative primitiveMedia types
Telegramsend_photo, send_documentImages, documents
Slackfiles_upload_v2Any file type
DiscordFile attachmentAny 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.

python
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)
Platformthread_id becomesIgnored on non-threaded adapters
Slackthread_ts on files_upload_v2โœ…
Telegrammessage_thread_id on send_photo / send_documentโœ…
DiscordThread 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.

Diagram

yes

no

never

discord:42:555

Is 555 a thread of 42?

๐Ÿงต Thread 555

๐Ÿ“ Parent channel 42

๐Ÿšซ Unrelated channel 555


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.

Diagram
Platform APIdeliver_media_to_adapterDeliveryRouterPlatform APIdeliver_media_to_adapterDeliveryRouterHonour Retry-After, wait 2sExponential backoffsend_media(path, thread_id?)upload (attempt 1)429 Too Many Requests (Retry-After: 2s)upload (attempt 2)503 Service Unavailableupload (attempt 3)200 OKโœ… delivered

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:

FieldDefault
initial_ms1000
max_ms10000
factor1.5
max_attempts3

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

OutcomeBehaviour
Adapter exposes no upload primitiveReturns False immediately โ€” no retry.
Missing optional discord packageLogged 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

python
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
    """,
)
yaml
media_delivery:
  enabled: true
  max_size_mb: 10
  strict: true
  allowlist_roots:
    - /tmp/agent-media
  recent_mtime_seconds: 300

Multi-tenant / hosted gateway (strict mode)

For hosted environments where multiple users share the same gateway, enable strict mode with an explicit output directory:

yaml
media_delivery:
  enabled: true
  max_size_mb: 5
  strict: true
  allowlist_roots:
    - /var/agent/output
  recent_mtime_seconds: 120
  basename_denylist:
    - config.yml
    - secrets.json

Disable media delivery

yaml
media_delivery:
  enabled: false

Or with quoted string (both are equivalent):

yaml
media_delivery:
  enabled: "false"

Summary Messages

When media delivery completes, BotOutboundMessenger.send appends a summary:

2 attachment(s) delivered, 1 skipped

Skipped 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.

Write remote-sandbox files to a well-known workspace path

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