Skip to main content

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

  1. 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).

  2. Attachment-directive protocol. When a real file is wanted, the model emits one HW_ATTACHMENT block per file in its reply:

    <<<HW_ATTACHMENT
    {"filename":"q3-report.pdf","format":"pdf","content":"# Q3 Report\n\nRevenue grew 24%..."}
    HW_ATTACHMENT>>>

    content is 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-tools conversation_draft path.

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

  4. 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-channel ChannelDispatcher sends them on Expert release — no channel changes.

3. Agent side

FileRole
agent/attachment_render.pyDirective parse (bounds: HW_ATTACHMENT_MAX_CONTENT_BYTES 256 KB, HW_ATTACHMENT_MAX_COUNT 5) + per-format renderers.
agent/main.pyExtract-then-render before payload parsing on /chat and /chat/stream; stream does not replay raw tokens on attachment turns.
agent/prompts_loader.pyATTACHMENT_PROTOCOL (all roles) + format clarification; gated by HW_ATTACHMENT_ENABLED.
agent/r2_client.pyOutbound content_base64 inline transport for no-shared-store delivery.
agent/requirements.txtPinned writers: python-docx, reportlab, openpyxl, python-pptx.

4. API side

FileRole
../api/src/common/agent.client.tsParse artifacts.outbound[]AgentOutboundArtifact[] (drops server path).
../api/src/runtime-control-plane/outbound-artifact-materializer.service.tsResolve 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.tsgetObjectBytesByKey — read an agent-generated object from the runtime bucket by raw key.
../api/src/conversations/conversations.service.tsAfter 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 by HUMANWORK_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 via getObjectBytesByKey and copies it to the client bucket. Requires bucket alignment (see R2_INTEGRATION.md).
EnvironmentAPI object storePath
Local containermust have S3/R2agent inlines → API writes client bucket
Remote devR2 present (hwork-dev); client + runtime buckets both resolve to hwork-deveither transport works end-to-end
ProdR2R2 key transport (large files), inline fallback for small

6. Security

  • run_artifacts / attachments scoped org × specialist (ADR-020); keys are orgs/{orgId}/attachments/....
  • scrubFilesystemPaths still 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

VarSideDefaultPurpose
HW_ATTACHMENT_ENABLEDagentonKill switch for prompt injection + extraction.
HW_ATTACHMENT_MAX_CONTENT_BYTESagent262144In-band directive content cap.
HW_ATTACHMENT_MAX_COUNTagent5Max attachments per turn.
HUMANWORK_OUTBOUND_INLINE_MAX_BYTESagent10 MiBOutbound inline cap.
ai_reply_attachments_enabledAPIoffPer-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.outbound under 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 dropped artifacts, so the API (which calls /v1/chat, not the legacy /chat) never saw generated files. Fixed by passing artifacts through the v1 ChatResponse; the streaming done event 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.