diff --git a/backend/alembic/versions/20261021_1400_add_assistant_session_attachments.py b/backend/alembic/versions/20261021_1400_add_assistant_session_attachments.py
new file mode 100644
index 0000000..2b5b23a
--- /dev/null
+++ b/backend/alembic/versions/20261021_1400_add_assistant_session_attachments.py
@@ -0,0 +1,30 @@
+"""Material attached to an assistant conversation: files, notes and web pages, as text.
+
+A server default so the conversations that already exist read as having none.
+
+Revision ID: 8a4c2e9f1d37
+Revises: 5f1b8c3d2a90
+Create Date: 2026-10-21 14:00:00.000000+00:00
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+
+
+revision: str = '8a4c2e9f1d37'
+down_revision: Union[str, None] = '5f1b8c3d2a90'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table('assistant_session', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('attachments', sqlmodel.sql.sqltypes.AutoString(),
+ nullable=False, server_default='[]'))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('assistant_session', schema=None) as batch_op:
+ batch_op.drop_column('attachments')
diff --git a/backend/app/assistant/attachments.py b/backend/app/assistant/attachments.py
new file mode 100644
index 0000000..7795dd2
--- /dev/null
+++ b/backend/app/assistant/attachments.py
@@ -0,0 +1,450 @@
+"""Material a person hands the assistant to work from: files, notes, web pages, images.
+
+Each one is reduced to text when it is attached, and the text is kept on the
+conversation (`AssistantSession.attachments`), not fetched again per turn. So a turn
+reads exactly what the person attached and saw listed, a page that changes or a note
+that is edited later doesn't change a conversation under them, and a turn never waits
+on Notes or a website.
+
+Text, not files, for everything but images: a script, a transcript, an article or a
+set of notes is text anyway, and text is what every provider takes.
+
+Images are the exception, for a model that can see (the provider's `supports_images`).
+They are scaled down once, kept as files under the data directory (GVC keeps no media
+in its database, and a few hundred kilobytes per picture would bloat every read of the
+conversation), and sent as images at the start of each turn's conversation. They are
+something to look at (a storyboard, a style reference, a diagram to explain), not a
+picture for the video: those live in GAM, where the renderer can reach them.
+
+The text goes to the model as reference material inside the system prompt, fenced and
+labelled as data. A web page in particular is written by a stranger, and anything in it
+that reads like an instruction must be read as part of the page, not as the user.
+"""
+
+from __future__ import annotations
+
+import io
+import re
+import uuid
+import zipfile
+from html.parser import HTMLParser
+from typing import Any, Dict, List, Optional
+from xml.etree import ElementTree
+
+from app.clock import utcnow
+from app.config import settings
+from app.notes.block_markdown import note_to_markdown
+from app.providers.base import Image
+
+
+class Unreadable(Exception):
+ """The material can't be turned into text; the message is shown as it is."""
+
+ def __init__(self, code: str, message: str, status_code: int = 400):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.status_code = status_code
+
+
+TEXT_EXTENSIONS = {".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".srt", ".vtt",
+ ".log", ".yaml", ".yml", ".xml", ".rst", ".org"}
+HTML_EXTENSIONS = {".html", ".htm", ".xhtml"}
+IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
+IMAGE_TYPES = ("image/jpeg", "image/png", "image/webp", "image/gif")
+# What a URL may answer with: a page, plain text, a PDF, or a picture.
+URL_TYPES = ("text/html", "application/xhtml+xml", "text/plain", "text/markdown",
+ "application/pdf", "application/json", "text/csv") + IMAGE_TYPES
+
+# Anthropic scales anything larger down to about this long edge before the model sees
+# it, and OpenAI's high detail tiles at a similar size, so sending more only costs
+# upload time and tokens.
+IMAGE_MAX_EDGE = 1568
+# The chip's thumbnail, kept inline as a data URL so the panel needs no image route
+# (an can't send the bearer token this app authenticates with).
+THUMB_EDGE = 96
+
+# A .docx is a zip, so its document.xml can be far larger than the upload: the size is
+# checked before it is inflated.
+_DOCX_MAX_XML = 50 * 1024 * 1024
+_W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
+
+
+# ─── text from bytes ─────────────────────────────────────────────────────────
+
+def _decode(data: bytes, charset: Optional[str] = None) -> str:
+ for encoding in filter(None, (charset, "utf-8-sig")):
+ try:
+ return data.decode(encoding)
+ except (LookupError, UnicodeDecodeError):
+ continue
+ # Latin-1 decodes any byte, so a mislabelled file still reads, slightly wrong,
+ # rather than not at all.
+ return data.decode("latin-1")
+
+
+def _tidy(text: str) -> str:
+ lines = [re.sub(r"[ \t ]+", " ", line).strip() for line in text.splitlines()]
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
+
+
+class _PageText(HTMLParser):
+ """A page's readable text, its headings and lists kept as Markdown.
+
+ Not a readability engine (Notes uses trafilatura for its importer; a lexical
+ walk is enough for a prompt and adds no dependency). Navigation, scripts and forms
+ are dropped, and when the page marks its content with or , only
+ that is kept, which is most of what removing boilerplate buys.
+ """
+
+ SKIP = {"script", "style", "noscript", "template", "svg", "nav", "header", "footer",
+ "aside", "form", "button", "iframe", "canvas", "select", "head"}
+ BREAK = {"p", "div", "section", "br", "tr", "table", "blockquote", "pre", "dd", "dt",
+ "figcaption", "ul", "ol", "hr"}
+ FOCUS = {"article", "main"}
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.skipping = 0
+ self.focus = 0
+ self.in_title = False
+ self.title = ""
+ self.og_title = ""
+ self.all: List[str] = []
+ self.focused: List[str] = []
+
+ def _emit(self, text: str) -> None:
+ if self.skipping:
+ return
+ self.all.append(text)
+ if self.focus:
+ self.focused.append(text)
+
+ def handle_starttag(self, tag: str, attrs) -> None:
+ if tag == "meta":
+ values = dict(attrs)
+ if values.get("property") == "og:title" and values.get("content"):
+ self.og_title = values["content"]
+ return
+ if tag == "title" and not self.title:
+ # The first one: an