Feature: AI Reply Attachments
How AI-drafted replies deliver generated files (reports, spreadsheets, decks, documents) as attachments, in the format the client asked for. The AI drafts a reply, an Expert reviews it (with the attachment), and on release it is sent as the Specialist over the channel.
Related contracts: ../agent/R2_INTEGRATION.md,
../agent/AGENT_API_SPEC.md.
1. Why the LLM can't just emit the file
An LLM emits a probabilistic token stream; PDF/OOXML are binary containers requiring byte-exact structure (ZIP+DEFLATE, PDF xref offsets, object lengths) with internally consistent cross-references (Excel cell refs, PPTX masters, checksums) that must be computed, not "written". A token stream cannot produce that reliably — an approximation is a corrupt file. Plain-text formats (CSV/TXT/Markdown/HTML) are the exception and can be emitted directly.
Industry pattern (and ours): the LLM produces a semantic intermediate (Markdown / CSV / structured data); deterministic server-side code renders it to bytes. This is exactly how Claude code-execution and OpenAI Code Interpreter work (sandbox pre-loaded with reportlab/python-docx/python-pptx/openpyxl), and what document-conversion services (Pandoc, LibreOffice, Gotenberg) do.
2. Architecture
-
LLM emits content + structure, server renders bytes. The drafting model gets no terminal and writes no code (free-form terminal generation proved fragile: timeouts, sandbox restrictions, interpreter ambiguity).
-
Attachment-directive protocol. When a real file is wanted, the model emits one
HW_ATTACHMENTblock per file in its reply:<<<HW_ATTACHMENT
{"filename":"q3-report.pdf","format":"pdf","content":"# Q3 Report\n\nRevenue grew 24%..."}
HW_ATTACHMENT>>>contentis Markdown for pdf/docx/pptx (headings, bullets, pipe tables), CSV text for csv/xlsx, or raw text for html/md/txt. The agent extracts these blocks before JSON-contract parsing (so a large block cannot break the reply envelope), renders each into the outbound artifacts dir, and strips the block from the user-visible reply. This needs no toolset, so it works on the secure no-toolsconversation_draftpath. -
Rendering. Library-based today (reportlab→PDF, python-docx→DOCX, openpyxl→XLSX, python-pptx→PPTX, stdlib→CSV/TXT/MD/HTML). A Gotenberg microservice for high-fidelity HTML→PDF/DOCX is a planned additive upgrade.
-
Delivery reuses existing rails. The agent returns the files in
artifacts.outbound[]; the API re-homes them into the client attachment bucket, attaches them to the draft message, and the existing four-channelChannelDispatchersends them on Expert release — no channel changes.
3. Agent side
| File | Role |
|---|---|
agent/attachment_render.py | Directive parse (bounds: HW_ATTACHMENT_MAX_CONTENT_BYTES 256 KB, HW_ATTACHMENT_MAX_COUNT 5) + per-format renderers. |
agent/main.py | Extract-then-render before payload parsing on /chat and /chat/stream; stream does not replay raw tokens on attachment turns. |
agent/prompts_loader.py | ATTACHMENT_PROTOCOL (all roles) + format clarification; gated by HW_ATTACHMENT_ENABLED. |
agent/r2_client.py | Outbound content_base64 inline transport for no-shared-store delivery. |
agent/requirements.txt | Pinned writers: python-docx, reportlab, openpyxl, python-pptx. |
4. API side
| File | Role |
|---|---|
../api/src/common/agent.client.ts | Parse artifacts.outbound[] → AgentOutboundArtifact[] (drops server path). |
../api/src/runtime-control-plane/outbound-artifact-materializer.service.ts | Resolve bytes (inline base64, else R2 key) → write client bucket orgs/{orgId}/attachments/{convId}/{uuid}.{ext} → register run_artifacts (kind outbound_generated, message_id back-link) → map to NormalizedAttachment. |
../api/src/runtime-control-plane/run-artifact-lease-issuer.service.ts | getObjectBytesByKey — read an agent-generated object from the runtime bucket by raw key. |
../api/src/conversations/conversations.service.ts | After the draft message is saved, materialize outbound artifacts onto Message.attachments (fail-open). |
Expert/client visibility, private-key signing (#2559), and the media proxy are
inherited — AI-generated attachments use the same NormalizedAttachment +
storageKey shape as web/Expert uploads and inbound channel media.
5. Transports (local, remote dev, prod)
Bytes cross the API↔agent container boundary two ways:
- Inline base64 — the agent inlines outbound bytes (
content_base64) when they only live on its local volume (no R2), bounded byHUMANWORK_OUTBOUND_INLINE_MAX_BYTES(10 MiB). The API decodes and writes to the client bucket. Works wherever the API has object storage. - R2 key — with R2 configured the agent stages the file in the
runtime-artifacts bucket and returns a
key; the API reads it viagetObjectBytesByKeyand copies it to the client bucket. Requires bucket alignment (seeR2_INTEGRATION.md).
| Environment | API object store | Path |
|---|---|---|
| Local container | must have S3/R2 | agent inlines → API writes client bucket |
| Remote dev | R2 present (hwork-dev); client + runtime buckets both resolve to hwork-dev | either transport works end-to-end |
| Prod | R2 | R2 key transport (large files), inline fallback for small |
6. Security
run_artifacts/ attachments scoped org × specialist (ADR-020); keys areorgs/{orgId}/attachments/....scrubFilesystemPathsstill runs on the reply text; files are referred to by user-facing name only.- In-band content and attachment-count bounds prevent abuse.
- (Gotenberg phase) render with JS/network disabled; escape any embedded client data.
7. Failure semantics (fail-open / HITL)
- Directive extraction never throws; a malformed block is stripped, not leaked.
- Render failure flags the draft for Expert review with
[NEEDS ATTACHMENT] …; the text reply is still delivered. - API materialization is best-effort; a failed artifact is skipped, the draft still posts.
8. Configuration
| Var | Side | Default | Purpose |
|---|---|---|---|
HW_ATTACHMENT_ENABLED | agent | on | Kill switch for prompt injection + extraction. |
HW_ATTACHMENT_MAX_CONTENT_BYTES | agent | 262144 | In-band directive content cap. |
HW_ATTACHMENT_MAX_COUNT | agent | 5 | Max attachments per turn. |
HUMANWORK_OUTBOUND_INLINE_MAX_BYTES | agent | 10 MiB | Outbound inline cap. |
ai_reply_attachments_enabled | API | off | Per-tenant rollout flag for delivering files to clients. |
9. Testing
- Agent:
agent/evals/test_attachment_render.py(directive parse + per-format render),agent/evals/test_outbound_artifact_formats.py(transport matrix + generation capability). Run in the agent container. - API:
agent.client.spec.ts(outbound parse),outbound-artifact-materializer.service.spec.ts(both transports, key shape, run_artifacts, fail-open). - Validated end-to-end with a real Hermes run (claude-sonnet-4): CSV/PDF/XLSX/DOCX
generated and returned in
artifacts.outboundunder local (no-R2) fallback.
10. Acceptance (local container, real R2)
Validated end-to-end on the local stack with the API pointed at dev R2
(hwork-dev): Amy (client) sends a message requesting a PDF → pipeline →
/v1/chat → the real model generates it via the directive protocol → the API
materializes it into the client bucket and attaches it to the AI draft
(Message.attachments, key orgs/{org}/attachments/{conv}/{uuid}.pdf) → David
(Expert) fetches it via the media proxy: HTTP 200, application/pdf, valid
%PDF-1.4 bytes.
v1 passthrough fix (found in acceptance):
/v1/chat's response translation droppedartifacts, so the API (which calls/v1/chat, not the legacy/chat) never saw generated files. Fixed by passingartifactsthrough the v1ChatResponse; the streamingdoneevent still omits it (drafts use the non-stream path).
11. Rollout
Ship library rendering first (no new infra). Introduce Gotenberg later for
high-fidelity HTML→PDF/DOCX. Gate client delivery per tenant via
ai_reply_attachments_enabled; HW_ATTACHMENT_ENABLED is the agent-level kill
switch.