Chat citation targets
Every chat citation now says what it points at. Each one carries a typedtarget with the identifiers needed to act on it: a URL, a ticker, or one of
the user’s own account ids. Before this, a citation was a source string and a
title, and a client had to guess what the label meant.
Why
Account citations were named by their display label.silvia://your-account/<slug> is a slug of whatever the SQL row showed as the
account’s name. So a client matching it to an account guesses, and guesses
wrong:
- “Credit card” names two accounts when the user has two cards.
- “Credit card” names none when the card is called “Sapphire Preferred”.
- “Checking” is ambiguous across banks.
- “Chase” is ambiguous across three Chase accounts.
The type
CitationTarget in types/chat.ts:
Citation.target and InlineCitation.target are optional. source, title
and cited_text keep their old values and meaning.
What the server cites, and the target each source gets
The targets are produced inapp/api/claude/citation-targets.ts, from the
search_result blocks toCitableContent writes (lib/claude/chat-turn.ts):
The three library readers share one executor
(
app/api/claude/tools/executors/corpus-document-reader.ts) and one
citation-block builder (lib/claude/corpus-citations.ts). Each read returns
the file with its .meta.json sidecar, and the block is sourced to the
sidecar’s source_url, passed through humanizeCitationUrl so eCFR XML and
US Code ZIP downloads open as readable pages.
Account ids
A row names its account through, in this order:source_account_id, the unified views’ account id (account_details,public_holding_details,holding_details). The provider is the row’ssourcecolumn (plaid,manual,snaptrade), or null whensourcewas not selected. Any othersource, such as a crypto wallet, means the row names no account.snaptrade_account_id(SnapTrade), thenmanual_account_uuidoraccount_uuid(manual).account_id, with provider null, because Plaid’s and SnapTrade’s tables both use that name.
- or _, and is not all digits. That rules out row numbers and labels.
The model writes the SQL, so ids are there only when it selects them. The v5
system prompt’s citation table (app/api/claude/config/system-prompt.ts) asks
it to select source and source_account_id whenever it reads those views per
account. The legacy prompt is frozen by its own header and was not changed. On
legacy turns an account citation is typed only when the model happened to
select an id column.
One meaning per source per turn
A citation echoes onlysource and title back to the server, so a source
string has to mean one thing for the whole turn:
- If a later tool call writes the same source again, its accounts are pooled into the target, because the citation cannot say which result it came from.
- Any other disagreement leaves the source untyped for the rest of the turn. That covers a label written once with ids and once without, or a URL cited as both a web page and a tax document.
citationTargets). A checkpoint
written before this change resumes with an empty registry, and its sources
stream untyped.
Conversation history is rebuilt as text only (app/api/claude/session-history.ts),
so a turn can only cite the sources that turn wrote.
Where clients read it
The raw
message SSE event (Anthropic’s final message) does not carry targets.
Web passes the field through without using it: hooks/use-claude-chat.ts
(live), contexts/rehydrate-citations.ts (reload), and
resolveInlineCitationChips / SourceBadge (chips and popovers). Web’s
rendering still reads source and title.
Always read the field through parseCitationTarget (lib/chat/citation-target.ts):
- It returns null for a kind it does not know or a malformed value. The client
then falls back to
sourceandtitle, as it does for rows saved before targets existed. - An unknown
providerbecomes null rather than discarding the account. accountIdopens web’s account page as/accounts/${encodeURIComponent(accountId)}(matched throughaccountIdCandidates).accountsmeans the citation covers all of the listed accounts.
Privacy
- An account id is the user’s own. Web already puts it in its URLs, and it
already reaches the client inside
cited_textwhenever the model selects it. - Rows come from the RLS-scoped query service, so only the user’s own accounts appear.
- No other identifier is read from the rows. That includes
clerk_id, item ids and tokens. providernames a vendor. It is for routing only. Never show it: vendor names stay out of user-facing text.- Targets are not logged. The existing
[Citations] block …line logs the citation as Anthropic sent it, before the target is attached.