---
title: Chat attachments (PDFs, text docs, images, Office docs, pasted text) (part 2)
---

# Chat attachments (PDFs, text docs, images, Office docs, pasted text) (part 2)

## What it is

This is part 2 of the [Chat attachments (PDFs, text docs, images, Office docs, pasted text)](chat-attachments.md) page. It covers the machinery behind an attachment: how the send pipeline sorts each one and delivers it to the model, how non-Claude engines get the same content as files inside the project, and how a very large paste turns into a chip rather than flooding the box you are typing in.

## Where to find it

Nothing here has a screen of its own — it is what happens after you press send in the composer described in the [first half of this page](chat-attachments.md). The one visible consequence is where a document ends up: it is written into the session’s own project folder, and that file path is what the agent is handed.

## How it behaves

### How it works

Send pipeline (one round of `SESSION_SEND_RESPONSE`), implemented at [/src/main/services/session/session-service.ts](/src/main/services/session/session-service.ts) (`sendResponse` method — grep `async sendResponse(` for the current line):

1. **Partition.** [`partitionAttachments`](/src/main/services/attachment-partition.ts) walks the IPC payload's `images[]` array (the field is historically named `images` but actually carries any attachment type) and returns five arrays: `images`, `pdfs`, `textDocs`, `officeDocs`, `dropped`. Classification is MIME-first with extension fallback — `.pdf` extension counts as a PDF even if the MIME is generic, the text-doc set is whitelist-driven (exact MIMEs `text/markdown`, `text/plain`, `text/csv`, `text/html`, `text/xml`, `text/tab-separated-values`, `application/json`, `application/xml`, plus extensions `.md` `.markdown` `.txt` `.csv` `.json` `.html` `.xml` `.tsv` `.log`), and Office docs are routed by every office MIME/extension — the modern OOXML trio plus the anydoc-read set (`application/msword`, `application/vnd.ms-excel`, `application/vnd.ms-powerpoint`, the three `application/vnd.oasis.opendocument.*`, `application/epub+zip`, and `application/rtf`/`text/rtf`), all sourced from one `@shared/attachment-mime` module (`OPENXML_OFFICE_*` + `ANYDOC_EXTRA_DOC_*`) so the renderer allow-list and the main-process partition can't disagree on what an office file is. Anything else lands in `dropped` and is silently discarded — but in practice nothing reaches `dropped` because the renderer's allow-list and the IPC schema's enum already filter the input.

2. **Per-bucket handling.** For each image the main process calls `saveAttachment` (UI chip metadata) and pushes a `{ data, mimeType }` entry into `inlineImages`. For each PDF it does the same _plus_ saves the bytes to `<workdir>/.claude/amc-attachments/<sessionId>/<name>` via [`saveDocToWorkdir`](/src/main/services/workdir-attachment-store.ts) and pushes a parallel entry into `documentBlocks`. For each text doc it saves to workdir and records the absolute path in `savedDocPaths` (no inline content block — text docs are too cheap to embed when a path will do). For each Office doc it saves the binary as chip metadata in `userData` (so the user sees the original filename in the message row), then calls [`extractOfficeText`](/src/main/services/office-text-extract.ts) to extract Markdown via the anydoc reader (with the in-process mammoth/adm-zip extractor as a fallback for `.docx`/`.pptx`, and `.xlsx` on its own dedicated extractor), writes the extracted Markdown to workdir as `<original-basename>.md`, and pushes that `.md` path into `savedDocPaths` — the agent reads the `.md` (not the binary), and the prompt prefix points at the `.md`. On extraction error the binary stays as a chip, the `.md` is _not_ created, the filename goes onto a `failedOfficeExtractions` list, and after the bucket loop a `system`-source warning row is inserted in `conversation_messages` and pushed via `SESSION_OUTPUT` so the renderer renders it immediately.

3. **Prompt build.** Operator-attached doc paths and auto-injected `.claude/docs/` paths are deduped via a `Set` and prepended via [`buildPromptWithAttachments`](/src/main/services/image-store.ts) — operator paths come first so they read naturally above any project-wide knowledge files. If the operator sent only attachments and no text, the prompt becomes `DEFAULT_IMAGE_MESSAGE` ("What's in this image?") so the model has _something_ to act on, but the UI/DB store the empty `displayText` so the chat history looks the way the user typed it.

4. **CLI delivery.** The 9-arg `processManager.writeToStdin(sessionId, prompt, isAutoResponse, attachments, displayText, images?, metadata?, documents?, pastedAttachments?)` call carries the partitioned arrays through to NDJSON write, where they become Anthropic Messages API content blocks in **documents → images → text** order (per Anthropic's vision best-practice: content blocks first, then the textual instruction). The 9th argument `pastedAttachments` carries the splice-range metadata for pasted-text chips that get spliced back into the prompt body.

5. **Failure cleanup.** If `writeToStdin` throws (bad NDJSON, stdin closed, etc.), the main process best-effort `unlink`s every path in `savedDocPaths` — so a retry from the user doesn't fight collision suffixes from a failed prior attempt.

The reason text docs go through path-injection rather than as `document` blocks is two-fold: first, the Anthropic API only accepts `document` blocks for PDFs (vision-rendered), not arbitrary text. Second, dropping a text file on the workdir gives the agent a real file it can `Read` _and edit_ — an inline content block would be read-only data the agent can quote but not modify. Workdir-saved docs are first-class repo files for the rest of the session.

Cleanup happens at the same point that purges all the rest of a session's data: when retention sweeps it via `purgeExpiredData` in [/src/main/db/queries-retention.ts](/src/main/db/queries-retention.ts). For each soft-deleted session past the retention cutoff, the function `JOIN`s against `projects` to recover the `folder_path`, resolves it through `resolveProjectWorkDir`, and `rm -rf`s `<workdir>/.claude/amc-attachments/<sessionId>/` alongside the existing `<userData>/attachments/<sessionId>/` cleanup. Sessions whose project row was already hard-deleted in the same transaction get logged and skipped — recovering the workdir requires the project row, and orphans are rare enough to be cleaned by hand.

Archiving (soft-delete) is separate: it does NOT remove attachment files. The bytes survive the session row until retention catches up — which is what allows un-archive to keep working.

### Non-Claude engines — attachments via workdir paths

The send pipeline above is the **Claude** path (Anthropic content blocks + inline images). Non-Claude engines have no inline image/document protocol, so they receive attachments as **files in the workdir with their paths injected into the prompt**: the shared [`prepareWorkdirAttachments`](/src/main/services/session/workdir-attachment-prep.ts) saves each attachment (images, PDFs, text docs, and office docs as extracted `.md`) under `<workdir>/.claude/amc-attachments/<sessionId>/`, and [`buildPromptWithAttachments`](/src/main/services/image-store.ts) prepends the paths so the agent opens them with its own file tools.

- **Codex** sends image paths on its dedicated `localImage` channel and injects doc paths into the prompt.
- **One-shot CLIs (Cursor, OpenCode, Antigravity)** have no separate image channel, so ALL paths (images + docs) ride the prompt; pasted-text chips splice in the same way. Before the cursor-attachments fix this send branch dropped `images` + `pastedTexts` entirely — they're now delivered and persisted on the operator row (provider-registry contract, **the-send-verb-is-one-shared-port**).
- **The slim ACP engines (Hermes, Kimi Code)** send an image inline as an ACP image block when their CLI advertised image support on its start-up handshake, and post a visible note in the session when it did not (the image is then withheld — never a silent drop). They do not deliver documents; the compose area warns before the send (engine-onboarding contract, **abilities-are-discovered-not-assumed**).

Because delivery is by file path, document/text/code attachments work for any file-capable agent; image _vision_ depends on the engine's own multimodal file handling (best-effort on a CLI that may not interpret an image file).

### How pasted-text attachments work

Pasted-text is the one bucket that doesn't come from the file system — it comes from a paste against the textarea (Ctrl+V, or Windows Win+V / the emoji panel / an IME). The composer's paste handler in [/src/renderer/src/features/sessions/useSessionPanel.ts](/src/renderer/src/features/sessions/useSessionPanel.ts) classifies the clipboard payload by length and routes it:

- **`raw.length > 5,000`** (`PASTE_AS_ATTACHMENT_THRESHOLD`, strict `>`) — chip path. `e.preventDefault()`, capture the textarea's `selectionStart` as the insert position, push a `PastedTextAttachment` row into `pendingPastedTexts`, render a chip with the first line as preview. The textarea content is unchanged — the user keeps typing around the chip.
- **5,000 chars or fewer** — fall through to the existing "trim-equality" branch (a no-op for clean text, a programmatic splice for trimmed text), then to the native browser paste, which inserts the text directly into the textarea.

The threshold is a compromise: small pastes feel natural inline (a function signature, an error line); huge pastes (a 50-line stack trace, a JSON blob, a chat transcript) would otherwise visually overwhelm the composer. 5,000 chars roughly corresponds to a screen of dense code — anything bigger gets the chip treatment.

**A second door — `beforeinput` (mobile AND desktop).** The handler above hangs off the React `paste` ClipboardEvent, which most desktop Ctrl+V fires with the full clipboard text. But several paste sources commit text through the editing path instead — a native `beforeinput` event (`inputType: insertText` / `insertFromPaste`) with **no `paste` event at all**: mobile soft keyboards (Android GBoard especially), AND on the desktop the Windows clipboard-history popup (Win+V), the emoji panel, and various IMEs. Without a second listener the chip path never runs for those: no chip, and the browser truncates/floods the big insert ("it just cuts it off on the chat box"). [`useLargePasteBeforeInput`](/src/renderer/src/hooks/useLargePasteBeforeInput.ts) listens at that `beforeinput` door — a single insert over the 5,000-char threshold is unambiguously a paste, so it cancels the truncating native insert and routes the text through the same `addPastedText` chip path. One further wrinkle (fixed 2026-07-10): some mobile IMEs _also_ truncate the text they hand that `beforeinput` event on `event.data` / `dataTransfer`, so chipping the event payload alone still lost text (the "capped at ~5,000 tokens on mobile" report). After `preventDefault()` the hook therefore recovers the authoritative full document from `navigator.clipboard.readText()` and prefers it whenever it is longer than the event payload — falling back to the payload if the clipboard read is unavailable / denied / shorter, so it is never worse than before. The session composer arms it on **every platform** (`enabled: true`): the earlier browser-only gate wrongly assumed `paste` always fires on the desktop app, so a large Win+V paste flooded the desktop composer instead of chipping (fixed 2026-08-04). It can't double-fire with the `paste` handler because a canceled `paste` event emits no follow-up `beforeinput`. The **Quick Launch** composers (the "Ask AMC" tab and the New Session box) now arm the same fallback too — each co-located with its own textarea, routing large inserts into the shared engine's `usePastedTextComposer.addChip` — so a big mobile paste chips there as well instead of truncating (fixed 2026-08-10; before this the QL boxes had the `paste`-path chip engine but not this second door). See [composer-paste-engine-contract.md](/.claude/memory/contracts/composer-paste-engine-contract.md).

**The clipboard-history picker is a third door.** Pasting a copy from Omniscio's clipboard-history picker (Win+V) into a session composer arrives over IPC rather than as a `paste` / `beforeinput` event, but a text over the same 5,000-char threshold still collapses into a chip: `handleClipboardInsertText` routes it through the same `addPastedText` path (in both composer modes) instead of dumping the whole block inline. See [clipboard-history.md](clipboard-history.md).

**Splice on send (no delimiter).** When the user finally hits send, [`spliceTypedTextWithPastedChips`](/src/shared/pasted-text.ts) walks the `pendingPastedTexts` array (sorted by `insertPosition`, ties broken by `ordinal`) and inserts each chip's content at its captured offset. There is **no wrapper, no `--- pasted ---` marker, no fenced code block** — the bytes land in the prompt body exactly where the user pasted them, indistinguishable from typed text. The function returns both the spliced string AND a parallel array of `{id, ordinal, start, end, preview}` range metadata; the splice points are pre-computed once so the renderer never has to walk the chips again.

**Race-safety on send failure.** `executeSend` clears the chip strip BEFORE awaiting the IPC, mirroring the existing `pendingImages` flow — the user's textarea snaps back to empty immediately so a fast re-paste doesn't double-fire. If `sendResponseAndNavigate` returns `success: false`, the chips (with their original ids, contents, and insert positions) are restored from the snapshot held in the closure. Without this restore the user would lose the only copy of what they pasted, which would be much worse than the cosmetic flicker.

**Per-session isolation.** `sessionPastedTextDrafts` (a `Map<sessionId, PendingPastedText[]>` outside the React tree) holds chips across panel unmounts the same way `sessionDrafts` and `sessionImageDrafts` hold textarea text and image chips — so mid-paste navigation between two sessions doesn't leak chips between them. Archive clears the per-session entry so unarchive doesn't bring stale chips back.

**Durable across reload.** As of 2026-06, a pending pasted image **or** pasted-text chip also survives a full app reload / mobile tab-reap — the same durability the typed text beside it already had. Both in-memory Maps are serialized to one JSON blob in a `sessions.draft_attachments` column (the sibling of `draft_text`), written debounced when attachments change, re-read when the session's panel mounts, and cleared the instant you send (restored if the send fails). localStorage is deliberately NOT used for this layer — base64 image data URLs blow its ~5 MB quota — so it's SQLite-only; the image's `base64` is dropped before storage and re-derived from its `dataUrl` on restore. This replaced the old **"Pasted attachments clear if you reload"** composer warning, which is now gone. Full mechanics + the test-locked invariants: [.claude/memory/contracts/attachment-draft-persistence-contract.md](/.claude/memory/contracts/attachment-draft-persistence-contract.md).

**Persistence in chat history.** The spliced prompt body is what Claude sees, and what gets stored in `conversation_messages.body`. Alongside it, the splice's range metadata is serialized to a new `pasted_attachments` JSON column on the same row (migration v121). When the message renders in history, the markdown viewer paints a chip overlay at each `[start, end]` range — collapsing the inline blob back into a `Quote`-icon chip the user can click to expand. This means the agent always sees the literal text in context, but the operator's view of their own message stays compact.

## Related

The [first half of this page](chat-attachments.md) covers the user-facing half — the attachment routes, the chips, the caps and the large-draft warnings. The composer surfaces that fire a send with attachments already in place are on the [quick responses](use-quick-responses.md) page, files dropped into a project’s docs folder ride the same delivery path and are covered in [project docs auto injection](project-docs-auto-injection.md), and what to do when a send appears to work but nothing comes back is in [session stuck in needs you](session-stuck-in-needs-you.md).
