> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CHAT CITATION TARGETS

# 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`:

```ts theme={null}
type CitationAccountProvider = 'plaid' | 'manual' | 'snaptrade'

interface CitationAccountRef {
  accountId: string                         // source_account_id: Plaid account_id, manual uuid, SnapTrade id
  provider: CitationAccountProvider | null  // null when the rows did not say
}

type CitationTarget =
  | { kind: 'web'; url: string }
  | { kind: 'tax_reference'; url: string | null }
  | { kind: 'card_document'; url: string | null }
  | { kind: 'mortgage_reference'; url: string | null }
  | { kind: 'market_data'; ticker: string }
  | { kind: 'analysis' }
  | ({ kind: 'account' } & CitationAccountRef)
  | { kind: 'accounts'; accounts: CitationAccountRef[] }
  | { kind: 'portfolio' }
```

`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`):

| tool | source | target |
| - | - | - |
| `execute_sql` | `silvia://your-account/<label slug>` | `account` when every row behind the label names the same account; `accounts` when the label covers several; none when any row lacks an account id |
| `execute_sql` | `silvia://your-portfolio` | `portfolio` |
| `execute_python` | `silvia://market-data/finnhub/<TICKER>` | `market_data` with the ticker |
| `execute_python` | `silvia://market-data/finnhub` | `analysis`: a Finnhub payload printed without its ticker (web labels it "Live data") |
| `execute_python` | `silvia://analysis` | `analysis`: output with no Finnhub in the code or payload, e.g. a computation or a fetched page (web labels it "Analysis", CFO-9438) |
| `execute_python` | an Exa article's URL | `web` |
| `read_tax_authority` | the document's government URL | `tax_reference` with the URL |
| `read_tax_authority` | `silvia://tax-authority/<path>` | `tax_reference`, `url: null` |
| `read_credit_card_document` | the issuer's page URL | `card_document` with the URL |
| `read_credit_card_document` | `silvia://credit-card-corpus/<path>` | `card_document`, `url: null` |
| `read_mortgage_authority` | the regulator's or agency's page URL | `mortgage_reference` with the URL |
| `read_mortgage_authority` | `silvia://mortgage-code/<path>` | `mortgage_reference`, `url: null` |
| `search_web` | the result's URL / `silvia://search/search_web` | `web` / none |

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

| channel | path |
| - | - |
| SSE `citation` | `data.citation.target` |
| SSE `citations_summary` | `data.citations[i].target` |
| `ai_chat_logs.citations` / `inline_citations` | `[i].target` |
| `GET /api/chat/sessions/<id>` | `messages[i].citations[j].target` and `messages[i].inline_citations[j].target`, raw from the row |

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.