Skip to content

feat(slack): support reading image attachments (ARM-139) - #79

Merged
stepandel merged 1 commit into
masterfrom
arm-139-slack-image-attachments
Apr 8, 2026
Merged

feat(slack): support reading image attachments (ARM-139)#79
stepandel merged 1 commit into
masterfrom
arm-139-slack-image-attachments

Conversation

@agent-vera

@agent-vera agent-vera Bot commented Apr 8, 2026

Copy link
Copy Markdown

Closes ARM-139.

What

Vera can now see screenshots and other image attachments shared in Slack — DMs, @-mentions, thread follow-ups, and snapshot-based session restarts. Previously she had no way to access them and had to ask the user to describe images in text.

How

The pi SDK already supports vision input via session.prompt(text, { images }) and Vera already runs on claude-opus-4-6 (multimodal). The Slack SlackEvent.files field was already populated by the webhook router. The only thing missing was the consumer that downloaded the files and threaded them through. This PR adds it.

Changes

  • SlackClient.downloadFile() (src/slack/client.ts) — fetches url_private_download with Authorization: Bearer <bot_token>, which is required for private Slack files (a plain fetch returns the HTML login page).
  • src/slack/files.ts — new downloadSlackImages() util:
    • Filters files to JPEG/PNG/GIF/WebP (the four mime types Anthropic vision supports).
    • Downloads each in parallel and base64-encodes.
    • Skips oversized files (>10 MB, based on the size Slack reports — saves a download).
    • Logs and skips failed downloads — never throws to the caller. Partial success is preferred over hard failure.
  • SlackEvent.files extracted into a typed SlackFile interface with id, mimetype, size, etc.
  • handler.tsprocessEvent, continueSession, and restartFromSnapshot now download images and pass them via session.prompt(text, { images }). A small buildPromptInputs() helper centralizes the logic.
  • Empty-text guards relaxed across the webhook router and handler so image-only messages aren't dropped at the gate. When there's no text body, a placeholder (image attached) is substituted — the model needs some text anchor.
  • recovery.resumeWithSnapshot() now accepts an optional images arg so attachments survive the snapshot-restart handoff.

Testing

  • 12 new unit tests in src/slack/files.test.ts cover:
    • Empty/undefined file lists
    • Filtering non-image attachments (PDF, video, text)
    • Single image, multi-image, and mixed (image + non-image) cases
    • Download failure on one of two images — surviving image is still returned, error logged
    • All-fail case — empty array, no throw
    • Files with no download URL — skipped silently
    • Fallback from url_private_downloadurl_private
    • Size cap enforcement (50 MB file → skipped without download attempt)
    • Unsupported mime types (image/svg+xml, image/heic)
  • All 469 pre-existing tests still pass.
  • bun typecheck, biome check: clean.

How to verify in Slack

  1. DM Vera a screenshot with no text — she should describe what she sees.
  2. @vera in a channel with text + a screenshot — she should answer using both.
  3. Post 3 screenshots in a single thread reply — all 3 should reach the model.
  4. Upload a .pdf alongside an image — the PDF is silently skipped, the image is processed.
  5. (Negative test) Upload a 50 MB image — Vera logs the skip and replies as if no image was attached.

Out of scope

PDFs, videos, and other non-image attachments are silently ignored — tracked separately if needed.

🤖 Generated with Vera

Vera can now see screenshots and other image attachments shared in
Slack messages — DMs, mentions, thread follow-ups, and snapshot-based
session restarts.

What changed:
- New `SlackClient.downloadFile()` — fetches `url_private(_download)`
  with the bot token (required for private Slack files).
- New `src/slack/files.ts` — `downloadSlackImages()` filters a Slack
  file array to JPEG/PNG/GIF/WebP, downloads each in parallel, and
  returns `ImageContent[]` ready for `session.prompt({ images })`.
  Oversized files (>10 MB) and download failures are logged and skipped
  rather than thrown — partial success is preferred over hard failure.
- `SlackEvent.files` extracted into a typed `SlackFile` interface
  with `id`, `mimetype`, `size`, etc.
- `handler.ts` now downloads images for every prompt path
  (`processEvent`, `continueSession`, `restartFromSnapshot`) and
  forwards them via `session.prompt(text, { images })`.
- Empty-text guards relaxed across the webhook router and handler so
  image-only messages flow through. A placeholder `(image attached)`
  text is substituted when there's no body — the model needs an anchor.
- `recovery.resumeWithSnapshot()` accepts an optional `images` arg
  so attachments survive the snapshot-restart handoff.

Tests: 12 new unit tests for `downloadSlackImages` covering filtering,
multi-file handling, mixed attachment types, download failures, size
caps, and unsupported mime types. All 469 existing tests still pass.

Out of scope: PDFs, videos, and other non-image attachments are
silently ignored — track separately if needed.
@linear

linear Bot commented Apr 8, 2026

Copy link
Copy Markdown
ARM-139 Support reading image attachments (screenshots) from Slack messages

Problem

Vera currently cannot read image attachments (screenshots, photos, etc.) shared in Slack messages. When a user posts a screenshot — e.g. of an error, a UI bug, a dashboard, or a design — Vera has no way to see it and must ask the user to describe or paste the content as text.

This is a major gap. Screenshots are one of the most common ways people share context in Slack, especially for bug reports and design feedback.

Expected behavior

When a user sends Vera a Slack message containing one or more image attachments:

  1. Vera should download the image(s) from Slack (using the bot token for auth on private file URLs).
  2. The image(s) should be passed to the model as vision inputs alongside the text of the message.
  3. Vera should be able to reference and reason about the image contents in her reply.

Should work for:

  • Direct messages
  • Channel mentions
  • Thread replies
  • Multiple images in a single message

Out of scope (for now)

  • Non-image files (PDFs, videos, etc.) — track separately if needed.
  • OCR / text extraction beyond what the vision model provides natively.

Notes

  • Slack file URLs require an Authorization: Bearer <bot_token> header to download — plain fetches return HTML login pages.
  • Confirm the model in use supports vision inputs; if not, this ticket also needs a model bump or per-request override.

Reproduction

  1. Send Vera a Slack DM with a screenshot and ask "what do you see in this image?"
  2. Vera responds that she cannot see images / asks for a text description.

Research Notes

Good news — most of the plumbing is already in place.

  • pi-coding-agent SDK already accepts images. AgentSession.prompt(text, options) takes options.images?: ImageContent[], where ImageContent = { type: "image"; data: string; mimeType: string } (data is base64-encoded bytes). Defined in node_modules/mariozechner/pi-ai/dist/types.d.ts. followUp() and steer() accept images directly as a second arg. No SDK changes needed.
  • The Slack SlackEvent type already carries files. src/slack/client.ts:11 already declares files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>, and the webhook router already populates it for app_mention (:89), DMs (:109), and channel threads (:119). The capture is half-built — only the downstream consumer is missing.
  • file_share subtype is already allowed through. client.ts:96 explicitly does not filter subtype === "file_share", so image-only messages already reach processEvent. Note: a screenshot pasted with no text body will fail the if (!text) return guard at handler.ts:271 — the guard needs to be relaxed when files are present.
  • Model supports vision. Both src/index.ts:200 and src/headless.ts:19 use anthropic/claude-opus-4-6. Claude Opus 4.x is multimodal — no model bump needed.
  • Slack file download. url_private_download requires Authorization: Bearer <bot_token>. Plain fetch() works — no SDK helper needed. The bot token is already passed into initSlackHandler as tokens.botToken (handler.ts:158). It needs to be threaded into processEvent / continueSession / restartFromSnapshot (or stashed on a closure/util) so the file fetcher can use it.
  • Existing base64 encoding pattern in src/tools/read.ts:165–179 shows the exact ImageContent shape produced for vision input — mirror it.

Entry Points

  1. src/slack/handler.ts:271processEvent(event) — main path for new mentions/DMs. Currently calls session.prompt(text). Must be extended to download event.files and pass { images }.
  2. src/slack/handler.ts:182continueSession(threadTs, event) — thread follow-up path. Same change needed.
  3. src/slack/handler.ts:217restartFromSnapshot(...) — snapshot-restart path (when an evicted session resumes). Same change needed.
  4. src/slack/client.ts:11SlackEvent.files — already typed; consider adding mimetype/size fields if helpful (Slack file objects expose mimetype, size, id).
  5. src/slack/client.ts:96file_share filter — already permissive; verify the empty-text guard at handler.ts:271 and :182 doesn't drop image-only messages.
  6. New file: src/slack/files.ts — small util that takes the bot token + a Slack files[] array, downloads each supported image, and returns ImageContent[]. Keeps handler.ts lean and gives a focused unit-test target.

Definition of Done

  • User can DM Vera a screenshot (with or without accompanying text) and Vera describes/reasons about its contents in the reply.
  • Same works for @mention in a channel and for thread follow-ups in an existing session.
  • Multiple images in one message all reach the model (not just the first).
  • Empty-text-with-image messages are processed (not silently dropped by the if (!text) return guard).
  • Non-image attachments (PDFs, videos, .txt, etc.) are silently ignored — no crash, no error to the user. (PDFs are explicitly out of scope for this ticket.)
  • Download failures (403, network error, oversized file) are logged via deps.onInfo and the message is still processed with whatever images succeeded — never a hard error to the user.
  • Bot token is not logged or echoed back to Slack in any error path.
  • Reasonable size cap enforced (suggest 10 MB per image, skip larger ones with a log line — Anthropic's image size limit is ~5 MB after base64 expansion; resize is out of scope).
  • Unit tests added for the new files.ts util covering: filter to images only, base64 encoding, multi-file handling, and a download-failure case (mock fetch).
  • bun typecheck and bun test pass.

Test Cases

  1. Single screenshot DM, no text — user pastes only an image; Vera describes the image. (Verifies the empty-text guard fix.)
  2. Screenshot + text in @mention — "what's wrong with this UI? "; Vera answers using both text and image context.
  3. Multiple images in a thread follow-up — user posts 3 screenshots in one reply; all 3 reach the model.
  4. Mixed attachment types — user uploads a .png and a .pdf in one message; the PNG is included as vision input, the PDF is silently skipped.
  5. Download failure — mock fetch to return 403 on one of two images; the surviving image is still passed to the model and the error is logged but not surfaced to Slack.

Edge Cases & Gotchas

  • Snapshot restart path. If a session is evicted from memory and an image-bearing follow-up arrives, restartFromSnapshot (handler.ts:217) replays history and then processes the new prompt. Make sure the new image attachments from event are passed to the post-restart prompt — not lost in the recovery handoff.
  • Empty-text guard. handler.ts:271 has if (!text) return; and processEvent has another at :268. If event.files is non-empty, allow processing even if text === "". Construct a placeholder prompt like (image attached) so the model has something textual to anchor to.
  • Slack file mimetype field. Slack's file object includes mimetype (image/png, image/jpeg, image/gif, image/webp). Use it directly — don't sniff bytes. Filter to the four mime types Anthropic vision supports: image/jpeg, image/png, image/gif, image/webp.
  • Token plumbing. tokens.botToken is currently consumed only inside initSlackHandler's closure when constructing SlackClient. The cleanest fix is to either (a) capture it in the initSlackHandler closure and pass it into a helper, or (b) add a downloadFile(file) method on SlackClient itself — preferred, since SlackClient already owns the token. Add the method to client.ts and call it from files.ts.
  • In-flight work. No open PRs touch src/slack/handler.ts or src/slack/client.ts (verified against the 4 currently open PRs in stepandel/anton). Branch off main. ARM-37 ("Slack integration into channels") is In Review but unrelated — it predates this work.

Implementation Prompt

Add image-attachment support to the Slack handler in stepandel/anton: extend SlackClient (src/slack/client.ts) with a downloadFile(file) method that fetches url_private_download with the bot token, create a new src/slack/files.ts util that converts a SlackEvent.files[] array into ImageContent[] (filter to image/{jpeg,png,gif,webp}, base64-encode, skip oversized/failed downloads with a log), then thread the resulting images into session.prompt(text, { images }) from processEvent, continueSession, and restartFromSnapshot in src/slack/handler.ts — relaxing the empty-text guards so image-only messages flow through. Add unit tests in src/slack/files.test.ts covering filtering, base64 encoding, multi-file handling, and download failure.

@stepandel
stepandel merged commit 2edaba7 into master Apr 8, 2026
2 checks passed
@stepandel
stepandel deleted the arm-139-slack-image-attachments branch April 8, 2026 04:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant