Skip to main content

Chat citation targets

Every chat citation now says what it points at. Each one carries a typed target 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 Expo app (MI-031, MI-081) resolved sources by label matching for exactly this reason.

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 in app/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:
  1. source_account_id, the unified views’ account id (account_details, public_holding_details, holding_details). The provider is the row’s source column (plaid, manual, snaptrade), or null when source was not selected. Any other source, such as a crypto wallet, means the row names no account.
  2. snaptrade_account_id (SnapTrade), then manual_account_uuid or account_uuid (manual).
  3. account_id, with provider null, because Plaid’s and SnapTrade’s tables both use that name.
A value counts as an id only if it is a string of 16 to 128 letters, digits, - 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 only source 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.
The registry travels in the slice checkpoint (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 source and title, as it does for rows saved before targets existed.
  • An unknown provider becomes null rather than discarding the account.
  • accountId opens web’s account page as /accounts/${encodeURIComponent(accountId)} (matched through accountIdCandidates).
  • accounts means 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_text whenever 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.
  • provider names 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.