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
-
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").
-
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.
-
Nested code blocks: Markdown inside markdown (```` containing ``` ) could confuse regex. Mitigation: match outermost ``` pairs only, greedy-then-lazy scan.
-
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.
-
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.
-
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.
-
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)
Integration test (manual)
- Slack user messages agent: "Give me a CSV with 5 rows of sample sales data"
- Agent responds with
```csv ``` block
- Verify: Slack shows text message + downloadable CSV file in thread
- Verify: CSV opens correctly in Excel/Numbers
- Verify: agent response text doesn't contain the raw CSV block
Acceptance Criteria
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
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
Architecture
Extraction happens in
message_router.py(channel-agnostic). Upload happens inslack_adapter.py/slack_service.py(Slack-specific). TheChannelResponsemodel gains afilesfield so Telegram/Discord can reuse the same extraction later.File changes
services/slack_service.pyupload_file()method +files:writeOAuth scopeadapters/base.pyOutboundFilemodel,filesfield onChannelResponseadapters/message_router.py_extract_code_block_files(), wire between step 9 and step 12adapters/slack_adapter.pysend_response()tests/unit/test_slack_file_outbound.pySecurity 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:.env,.mcp.json, or any credential filesFile 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
re.DOTALLwith non-greedy match, add timeout or max scan lengthresponse_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.
Supported language hints → file extensions
csv.csvtext/csvjson.jsonapplication/jsonhtml.htmltext/htmlxml.xmltext/xmlyaml/yml.yamltext/yamlsql.sqltext/x-sqlpython/py.pytext/x-pythonjavascript/js.jstext/javascripttypescript/ts.tstext/typescripttxt/text.txttext/plainUnrecognized language hints (e.g.,
bash,shell,diff) are not extracted — they're likely code examples, not data outputs. This is configurable viaEXTRACTABLE_LANGUAGESset.Potential problems
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").
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.Nested code blocks: Markdown inside markdown (
````containing```) could confuse regex. Mitigation: match outermost ``` pairs only, greedy-then-lazy scan.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.
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.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.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
Error Handling
files.getUploadURLExternalreturns errorfiles.completeUploadExternalfailsfiles:writescope missingcompleteUploadExternalreturnsmissing_scopeerror → log error with remediation message ("Reinstall Slack app for files:write scope")Slack App Changes Required
Add
files:writeto the bot token OAuth scope. This requires reinstalling the Slack app to the workspace (same process as whenfiles:readwas added for inbound file sharing in #222).Updated scope string in
slack_service.py:Testing Plan
Unit tests (
tests/unit/test_slack_file_outbound.py)upload_file()makes 3 API calls in correct orderIntegration test (manual)
```csv ```blockAcceptance Criteria
files:writescope added to OAuth URLOut of Scope (Future)
References
files.getUploadURLExternal→ PUT →files.completeUploadExternalmessage_router.py:219-222adapters/base.py