Skip to content

<!-- Written per the the-loop:writing skill. -->

Decision 135: a file a person attaches is fetched and forwarded, never interpreted — the harness reads the image, Slack supplies the transcript ​

  • Status: proposed
  • Date: 2026-09-21
  • Work item: issue-416
  • Deciders: MadaraUchiha-314 (owner, on the ticket: "I don't want ANY complicated STT or image parsing in the loop"); the-loop (design)
  • Refines: decision-103 (through the ledger, never around it), decision-086 (the excerpt is a field allow-list; nothing acts on prompt text), decision-133 (a record delivered with a preset frame names its text as untrusted data)

Context ​

A screenshot in a Slack thread, a voice note from a phone, an image dropped into a GitHub comment: none of them reached the session. The Slack channel read a message's text and nothing else — a message that was only an image was refused as empty — and the app the manifest describes could not fetch a file. A GitHub comment carried the asset's URL verbatim, which a session can open on a public repository and not on a private one. The ticket asked first whether any parsing is required at all, and drew the line at speech-to-text and image parsing inside the loop.

Decision ​

#What was chosenWhy
D1The loop fetches; it never interprets. A file is downloaded as opaque bytes to <state.root>/local/attachments/<slug>/, and the session is handed the path with a fixed line: kind, name, type, size, link. No OCR, no captioning, no model call on a file, anywhere.The harness that hosts a session — Claude Code in a tmux pane, the only kind the-loop hosts interactively — reads an image or a PDF from a path with its own tool. Anything the loop added would be a second, worse reader in front of a better one.
D2A voice note is forwarded as Slack's own transcript, verbatim and attributed. The clip's transcription block and its vtt captions are Slack's; the-loop strips cue timings from the captions and nothing more, and re-reads a still-processing transcript a bounded number of times.Slack already transcribes every audio clip before the-loop sees the message. Forwarding that text meets the owner's constraint exactly — there is no speech-to-text in the loop because there is none to add — and the section says whose transcript it is.
D3The daemon fetches, not the session. The Slack bot token and the daemon's GitHub token are sent by one function that checks the destination host against an allow-list before every request and again on every redirect hop, reads one byte past a 25 MiB cap, and refuses anything else.The session holds no Slack token and, on a private repository, no GitHub one; the daemon holds both. Handing a credential to a fetch is the one new trust boundary, so the fetch is written to be safe to hand one to.
D4Two renderings, two readers. The pane gets kinds and local paths under a frame that names everything UNTRUSTED; the ticket gets 📎 names, types, the Slack permalink or the asset URL, and the transcript quoted — never a path, never a private download URL.The ledger stays the whole story without carrying a path that means nothing off this machine (decision-103); the pane gets what the session can act on, framed the way every delivered record already is (decision-133).
D5Best-effort by contract. Every failure — no token, off-host, over the cap, a transfer error, past ten files — is a line naming the file and a fixed reason, and the words the file came with are delivered regardless.Nothing in this change may make a message undeliverable that was deliverable before. A file the loop could not fetch is still named and linked, and a GitHub URL is offered for the session to try itself.
D6The section rides outside the excerpt and outside the template. On the GitHub path it is appended after the rendered prompt, so $payload_excerpt keeps decision-086's contract and a custom routing.promptTemplate carries it unchanged.The excerpt is a field allow-list with a per-field cap; a screenshot's URL may fall past the cap in a long body, and the section — built from the full body — still names it.
D7Constants, not configuration; no retention. Ten files per message, 25 MiB per file, 30 s per request, three transcript re-reads; the fetched files accumulate under local/ until the operator deletes them.A config key is a promise to document, migrate and onboard for a knob nobody has asked to turn. A sweeper is a later work item if disk becomes a problem; the state page says so.

Consequences ​

Good. "Look at this" works from a phone: a screenshot in Slack reaches the session as a file it can open, a voice note reaches it as text, and a GitHub screenshot on a private repository reaches it at all. The ticket keeps a link to every file that shaped a conversation. Nothing in the loop reads a file, so there is nothing to get wrong about what an image shows.

Costs. A network call sits on the delivery path, bounded by the caps. Slack's transcript is imperfect and is forwarded as it is; the section says so. Files accumulate on disk. An existing app must be re-installed for files:read before a file is fetched rather than only named; channels status --probe names the gap.

What it does not change. The excerpt contract, the process graph, any config key, the Cursor adapter (which has no interactive session for a turn to reach), and the recording acts: a record-context snapshot names files as links, and a decision is the text typed.

Alternatives considered ​

  • Hand the session the URL and let it fetch. Rejected: the session holds no Slack token and, on a private repository, no GitHub one. The URL is still named, so a session on a public repository can try it itself.
  • Transcribe in the loop (a speech-to-text dependency, or a model call). Rejected by the owner, and unnecessary: Slack's transcript exists before the message is delivered.
  • Describe images in the loop (a vision model call, an OCR pass). Rejected by the owner, and worse than the harness's own reading.
  • Attach the bytes to the ledger record. Impossible on GitHub's API (no upload endpoint for comment assets) and unwanted: the permalink is the record.
  • A config block for the caps and the directory. Deferred: constants until somebody needs to turn one.

Released under the MIT License.