For maintainers. Using T3 Code? See docs/user.
Inline context references let a user message point at a typed payload from an exact position in its prose: an image, a file, a terminal excerpt, a picked page element, a preview annotation, a review comment, a file mention, or a skill. This document covers the wire contract and the pure codecs. Editor, rendering, and clipboard behaviour land in later PRs and get their own sections here as they arrive.
- A context record is the payload. It lives in
message.context.records, keyed by acontextId. Records never contain bytes: image and file records bind to an existingChatAttachmentby id. - A context reference is one occurrence in the document. It is a Markdown link in
message.textthat carries only the kind and thecontextId. Two references can point at one record. A reference's label is display text and never identity.
composerContext.ts defines version 1 of the record union. Every record has
version, contextId, kind, and label, plus kind-specific fields with bounded lengths. The
union is open: a kind this build does not know decodes to UnknownContextRecord with its
payload preserved, and known kinds are excluded from that member so a malformed image record
fails its own schema rather than sliding through unchecked. OrchestrationMessageContext wraps
the records with ForwardCompatibleArray, so one undecodable record is dropped instead of failing
the whole message. The field is optional on OrchestrationMessage, both turn-start commands, and
ThreadMessageSentPayload. The decider and projector carry it through untouched.
ComposerContextId: durable payload identity. Branded. Values match[a-z0-9_-]+and do not require actx_prefix.ComposerContextReferenceId(ref_…): one document occurrence. Branded. Lives in editor state, not on the wire and is regenerated when canonical Markdown is reparsed.ChatAttachmentId: the existing server-owned attachment resource. A record'sattachmentIdis a binding, not the chip's identity, so upload normalization can rename the resource without rewriting references.
Clients mint context and reference ids. Shared code does not, because the Effect lint plugin
rejects direct crypto.randomUUID() there.
composerContextReferences.ts owns the grammar:
[label](t3-context://v1/<kind>/<contextId>)

The parser accepts exactly the t3-context: scheme, the v1 host, one kind segment matching
[a-z][a-z0-9-]{0,39}, and one id segment matching [a-z0-9_-]{1,128} case-insensitively. Query
strings, fragments, credentials, and extra segments are rejected. Labels are sanitized to survive
a Markdown link (no brackets or line breaks, at most 200 characters, never empty). Links that fail
to parse are ordinary text. collectComposerInlineTokens already rejects URI schemes for file
links, so a context link is never mistaken for a mention.
projectComposerContextForProvider({ text, records }) builds what the provider reads:
- Every reference becomes an in-place marker:
[Image: shot.png; ref=ctx_1]. - A trailing
<t3_context version="1">envelope holds one<context kind id>entry per unique referenced id, in first-reference order. Records that are never referenced are not emitted. A referenced id with no record becomes<context … unavailable="true"/>. Mention and skill records produce a marker but no entry. Unknown kinds emit their payload as JSON. - Captured text is data: any
<that would open or closet3_contextorcontextis escaped, so a terminal line or PR comment cannot forge a record.
Attachment bytes travel on the existing attachment channel; the envelope only carries metadata. Text without references is returned unchanged.
Messages sent before this feature carry trailing <terminal_context>, <element_context>, and
<preview_annotation> blocks, <review_comment> blocks, and U+FFFC terminal placeholders.
composerContextLegacy.ts upgrades them in memory:
- Review blocks become references in place. Blocks that trailed the original text are appended last, matching the old send order.
- Trailing blocks peel off the end in reverse send order (preview, element, terminal).
- Placeholders bind to terminal entries in order; entries without a placeholder are appended.
- Ids are deterministic (
legacy_<kind>_<n>) so re-running the upgrade is idempotent.
Event history is never rewritten. Existing web parsers in apps/web/src/lib/ stay until the
transcript renderer moves to records.
ComposerContextReferenceNode (apps/web/src/components/ComposerContextReferenceNode.tsx) is
the one inline Lexical node for every context kind. It stores kind, contextId, label, and a
per-occurrence referenceId, and its text content is the canonical link. The prompt string carries
payload identity and position, so rebuilding the editor restores equivalent chips; it does not
preserve the editor-local occurrence ids. Old drafts that used the U+FFFC ordinal placeholder
migrate on hydration: placeholders bind to the terminal contexts in array order, then any context
the prompt does not mention is prepended as a link.
Records stay in the draft store's typed arrays for now. The editor builds a Map keyed by
contextId from them (composerContextRecordsFromDraft) and provides it through
ComposerContextRecordsContext. ComposerContextReferenceChip looks the record up and renders
the kind's chip; an unknown kind or a missing record renders the unresolved chip instead of
vanishing. Removing a chip removes only that occurrence; the composer's change handler compares
the referenced ids against the draft array and drops records no chip points at.
Version 1 deliberately keeps kind presentation explicit in each client instead of exposing a runtime handler registry. The contract and codecs are shared; web/desktop render rich chips and mobile renders the readable label. Add a registry only when a third-party or runtime-defined kind must provide behaviour that cannot ship with the client. Likewise, a durable occurrence id belongs in the canonical reference syntax only if a future feature needs to address one occurrence across serialization boundaries.
Terminal context now follows the same send path as every other context record: the persisted
message keeps its canonical link and structured record, while the provider projection replaces
the link with a readable marker and includes the excerpt once in the context envelope. The legacy
trailing <terminal_context> form is parsed only when reading messages sent by older clients.
The composer sends message.text as canonical prose with reference links and
message.context.records built from the draft (buildMessageContext in
apps/web/src/lib/composerContextRecords.ts). Expired terminal excerpts are dropped from both.
The server projects provider text at turn start (ProviderCommandReactor), so the persisted
message stays readable and the provider receives markers plus one envelope.
Review comments and preview annotations enter the draft through store mutators. A mounted composer registers a context insertion handler so panel-originated references land at its current or last-known caret; when no composer is mounted, the store appends them. Terminal excerpts and attachments use the same caret-first behavior. Removing a chip in the editor removes the record; removing a preview screenshot thumbnail removes its annotation and chip.
The transcript resolves a message with resolveUserMessageContext: structured context is used as
is, older messages are upgraded in memory. ChatMarkdown renders t3-context:// links through
renderContextReference, which the timeline maps to chips through the web context-presentation
registry. The registry declares compact, details, and expanded capabilities for every known kind,
rejects duplicate surface handlers, and provides the unresolved fallback. Terminal excerpts,
elements, review comments, and preview annotations open structured details popovers; images and
videos use the shared media modal. Mobile renders context links as their labels.
Pull-request summaries currently travel as review-comment records with optional typed
pullRequest metadata. The metadata is a snapshot of the number, title, URL, branches, state, and
draft flag at attachment time. Web and desktop render the compact #number label and derive its
status tone from that snapshot. Hover shows the snapshot details; activation resolves the URL
against the current environment and opens the pull request in the thread's right panel. Records
written before the metadata was added retain their legacy details and neutral pull-request tone.
Image and file records use the draft attachment's local id as contextId and carry an
attachmentId binding. The composer sends the upload's pending id (or the local id on the
data-URL path, via the optional id on UploadChatImageAttachment); the server's Normalizer
rewrites every image and file record to the persisted id it assigns, so the stored message binds
records to real resources. Optimistic rows bind to local ids and are replaced by the server copy.
In the composer, attaching a file or image inserts a chip at the caret (appended when the editor cannot take input). Files exist only as chips: a file whose last chip is deleted is removed and its upload released, and drafts that predate references get a chip appended on hydration. Images keep the thumbnail shelf as their inventory; deleting a chip leaves the image, and removing a thumbnail that is still referenced asks for confirmation before removing both. Old drafts do not gain image chips.
In the transcript an image chip opens the gallery preview, a video chip opens the media preview, and another file chip opens or downloads the file. The gallery still shows every image; file rows remain only for files no chip references.
Every copy path writes the canonical Markdown as text/plain and, when the selection holds
chips, a structured fragment under web application/x-t3-context-fragment+json
(ComposerContextClipboardFragment: version, source environment/thread/message, records; no
bytes, no URLs). Composer copy and cut add it through a Lexical command listener; transcript
selection copy adds it from an onCopyCapture on the user message body while chips re-emit their
links through data-markdown-copy; the whole-message button writes both through ClipboardItem
and falls back to plain text.
On paste the composer decodes the fragment before the plain text. Records the draft does not already hold are imported: terminal excerpts and review comments as they are, preview annotations rebuilt from their record, and images or files re-fetched through the source environment's asset URL and attached under a fresh local id, with the pasted link rewritten to that id. A pasted binary reads as an unresolved chip until its bytes arrive; a fragment from another environment leaves binaries unresolved. Rendered chips and sent messages are never mutated by a paste.
Pasting across threads, projects, or environments uses the same path: the client mints an asset URL from the source environment, downloads the bytes, and attaches them here. There is no server-side clone; if the source is unreachable or the attachment is gone, a toast says so and the chip stays unresolved.
Producers keep their own id grammars; toComposerContextId folds anything outside
[a-z0-9_-] into a slug plus a hash, deterministically, at the reference and record boundary.
A preview annotation's context id is derived from annotation-<id> so it stays distinct from its
screenshot image, whose attachment id is the annotation id; the record links the two through
screenshotContextId.
projection_thread_messages.context_json persists records, so a restart or projection reload
keeps chips resolvable. Prompt stash entries carry records for terminal excerpts, review
comments, and preview annotations; stashing moves them out of the draft and restoring imports
them back through the same importer the paste path uses.
Context produced by other panels reaches the caret through setContextInsertionHandler: a
mounted composer registers an inserter for its draft and the store falls back to appending.