Skip to content

feat: Outbound file sharing — extract code blocks from agent responses and upload to Slack #282

Description

@pavshulin

Summary

When an agent responds to a Slack user with structured data in fenced code blocks (CSV, JSON, etc.), the message router should extract those blocks, create files, and upload them to the Slack thread as native file attachments via the Slack V2 Upload API.

This is the outbound complement to inbound file sharing (#222). No agent-side changes required — extraction happens in the router from the agent's existing text response.


User Story

As a Slack user chatting with a Trinity agent, I want to receive data outputs (CSV reports, JSON exports, etc.) as downloadable Slack files instead of raw code blocks in the message, so I can open them directly in Excel/Numbers or process them programmatically.


Technical Overview

Flow

1. Slack user sends message (e.g., "Give me a CSV of Q1 revenue")
2. Agent executes with restricted tools (WebSearch, WebFetch — unchanged)
3. Agent responds with text containing fenced code blocks:
     "Here's the breakdown:
      ```csv
      name,revenue
      Acme,150000
      ```"
4. Router extracts code blocks → creates OutboundFile objects
5. Router strips extracted blocks from response text (clean message)
6. Router sends cleaned text to Slack via chat.postMessage
7. Router uploads each extracted file to Slack via V2 Upload API
8. Slack user sees: text message + downloadable file attachment(s)

Architecture

Extraction happens in message_router.py (channel-agnostic). Upload happens in slack_adapter.py / slack_service.py (Slack-specific). The ChannelResponse model gains a files field so Telegram/Discord can reuse the same extraction later.

message_router.py          → _extract_code_block_files(response_text)
    ↓                         returns (List[OutboundFile], cleaned_text)
adapters/base.py           → OutboundFile model, files field on ChannelResponse
    ↓
slack_adapter.py           → iterates response.files, calls upload
    ↓
slack_service.py           → upload_file() — V2 API (3 HTTP calls)
    ↓
Slack API                  → files.getUploadURLExternal
                           → PUT upload_url
                           → files.completeUploadExternal

File changes

File Change
services/slack_service.py Add upload_file() method + files:write OAuth scope
adapters/base.py Add OutboundFile model, files field on ChannelResponse
adapters/message_router.py Add _extract_code_block_files(), wire between step 9 and step 12
adapters/slack_adapter.py Upload files in send_response()
tests/unit/test_slack_file_outbound.py Unit tests

Security Analysis

Why this approach is secure

The agent runs with restricted tools (--allowedTools WebSearch,WebFetch) for Slack-triggered executions. This feature does NOT change the tool restrictions. The agent cannot:

  • Read .env, .mcp.json, or any credential files
  • Write files to disk
  • Execute shell commands
  • Access MCP tools

File content comes exclusively from the agent's text response, which is already visible to the Slack user as plain text. Extracting it into a file attachment is a presentation change, not a privilege escalation.

Risks considered

Risk Mitigation
Agent leaks sensitive data in response Same risk exists today with plain text — this feature doesn't change what the agent can access. Tool restrictions are the control.
Enormous code blocks exhaust backend memory Max 500KB per extracted block, max 5 files per response, max 2MB total
Regex backtracking (ReDoS) on malformed response Use re.DOTALL with non-greedy match, add timeout or max scan length
Slack upload API failure leaves partial state Upload failures are non-fatal — text message still delivered, upload error logged
Filename collision in Slack thread Each file gets a unique name: response_1.csv, response_2.json, etc.

Extraction Logic — Edge Cases & Potential Problems

Detection: When do we extract?

Rule: Fenced code block with a recognized language hint AND content > 100 characters.

✅ Extracted:     ```csv\nname,revenue\nAcme,150000\n...(200 chars)...```
✅ Extracted:     ```json\n{"data": [...large array...]}\n```
❌ Skipped:       ```csv\na,b\n1,2\n```                    (< 100 chars — too small, keep inline)
❌ Skipped:       ```\nsome unlabeled text\n```             (no language hint)
❌ Skipped:       ```python\nprint("hello")\n```           (< 100 chars)
❌ Skipped:       ```bash\nls -la\n```                     (not a data format — debatable)

Supported language hints → file extensions

Language hint Extension MIME type
csv .csv text/csv
json .json application/json
html .html text/html
xml .xml text/xml
yaml / yml .yaml text/yaml
sql .sql text/x-sql
python / py .py text/x-python
javascript / js .js text/javascript
typescript / ts .ts text/typescript
txt / text .txt text/plain

Unrecognized language hints (e.g., bash, shell, diff) are not extracted — they're likely code examples, not data outputs. This is configurable via EXTRACTABLE_LANGUAGES set.

Potential problems

  1. False positive extraction: Agent shows a CSV example to explain a concept, not as actual data → user gets a spurious file download. Mitigation: 100-char minimum filters out small examples. Could be refined with heuristics (e.g., skip if preceded by "for example" or "like this").

  2. Multiple blocks of same type: Agent responds with two CSV blocks → both extracted as response_1.csv, response_2.csv. Naming is positional, not semantic. The user won't know which is which without reading the surrounding text.

  3. Nested code blocks: Markdown inside markdown (```` containing ``` ) could confuse regex. Mitigation: match outermost ``` pairs only, greedy-then-lazy scan.

  4. Incomplete code blocks: Agent response truncated mid-block (timeout, token limit). Regex won't match an unclosed block → no extraction, block stays in text as-is. Correct behavior.

  5. Agent explains code vs returns data: \``json {"error": "not found"}```` — is this data to extract or an error example? We extract based on size only, not semantics. For MVP this is acceptable.

  6. Unicode / encoding: Agent may return non-ASCII data in CSV (names, addresses). Must encode as UTF-8 bytes for Slack upload. Python str.encode('utf-8') handles this.

  7. Stripped text feels incomplete: After removing a large CSV block, the text might read awkwardly: "Here's the data: Let me know if you need more." Mitigation: replace extracted block with a brief placeholder like "(see attached file: response.csv)".


Cleanup & Resource Management

  • No disk writes: Extracted file content is held in memory only (bytes). No temp files on the backend host.
  • Memory bound: Max 500KB per block × 5 blocks = 2.5MB worst case. Well within backend memory.
  • No container interaction: Files come from the response text string, not from the agent's filesystem. No Docker API calls for outbound.
  • Slack upload failure: If any upload step fails (getUploadURL, PUT, completeUpload), log the error and continue. Text message is already sent. User gets the text; file is missing but the response isn't lost.
  • Partial extraction: If 3 of 5 blocks extract successfully and 2 fail upload, user gets 3 files + text. No rollback needed.

Error Handling

Error Handling
files.getUploadURLExternal returns error Log warning, skip this file, continue with next
PUT to upload URL fails (network error) Log warning, skip file
files.completeUploadExternal fails Log warning — file uploaded but not shared to channel. Orphaned file in Slack (auto-cleaned by Slack after 30 days)
Slack files:write scope missing completeUploadExternal returns missing_scope error → log error with remediation message ("Reinstall Slack app for files:write scope")
Agent response is empty string No extraction attempted, no file sent
Agent response has no code blocks No extraction, text sent as-is (current behavior preserved)
Regex extraction raises exception Catch, log, fall back to sending full text without extraction
Bot token invalid/expired Slack API returns 401 → log error, message not delivered (same as current behavior for text)

Slack App Changes Required

Add files:write to the bot token OAuth scope. This requires reinstalling the Slack app to the workspace (same process as when files:read was added for inbound file sharing in #222).

Updated scope string in slack_service.py:

im:history,im:read,im:write,chat:write,chat:write.customize,
users:read,users:read.email,app_mentions:read,channels:history,
channels:read,channels:join,channels:manage,reactions:write,
files:read,files:write    ← NEW

Testing Plan

Unit tests (tests/unit/test_slack_file_outbound.py)

  • CSV block extracted correctly (content, filename, mimetype)
  • JSON block extracted correctly
  • Multiple blocks → multiple files with sequential naming
  • Small blocks (< 100 chars) not extracted
  • No code blocks → empty list, text unchanged
  • Unrecognized language hints not extracted
  • Cleaned text has blocks replaced with "(see attached file: ...)"
  • Max file count enforced (5)
  • Max block size enforced (500KB)
  • Unicode content handled correctly
  • Unclosed code blocks ignored gracefully
  • upload_file() makes 3 API calls in correct order
  • Upload failure doesn't prevent text delivery
  • Thread support (thread_ts passed through)

Integration test (manual)

  1. Slack user messages agent: "Give me a CSV with 5 rows of sample sales data"
  2. Agent responds with ```csv ``` block
  3. Verify: Slack shows text message + downloadable CSV file in thread
  4. Verify: CSV opens correctly in Excel/Numbers
  5. Verify: agent response text doesn't contain the raw CSV block

Acceptance Criteria

  • Agent text responses with fenced CSV/JSON code blocks (>100 chars) are extracted and uploaded as Slack files
  • Extracted blocks are removed from the text message and replaced with file reference
  • Text message is delivered even if file upload fails
  • No changes to agent tool restrictions or security model
  • Works in both DMs and channel threads
  • Unit tests pass
  • files:write scope added to OAuth URL

Out of Scope (Future)

  • Binary file support (images, PDFs) — requires Write/Bash tools, security redesign
  • Google Drive integration as file storage backend
  • Agent-initiated file sharing (outbox pattern) — blocked by tool restriction security model
  • Markdown table → CSV conversion
  • Configurable extraction rules per agent
  • Frontend UI changes

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions