Chain-Cite
Use when the user wants to trace a single legal proposition backward through its citation graph to find the original source that first uttered it. Takes one proposition + one citation, walks backward hop by hop through CourtListener's opinion citations, annotates relationship (quoting / paraphrasing / holding) and semantic drift (broadens / narrows / rephrases) at each hop, and terminates when the chain hits a non-case source (statute, Restatement, constitution), a source CourtListener does not cover, an opinion that states the rule without citing a predecessor, or a cycle. Produces both a structured JSON chain and a human-readable Markdown report with a summary-first layout. Narrow-and-deep counterpart to cite-checking's wide-and-shallow document scan.
Chain-Cite
Prerequisites
This skill uses the citation taxonomy, short-form resolution rules, proposition-extraction rules, structured component schemas, flag vocabulary, and CourtListener API patterns defined in the citation-toolkit skill. Read citation-toolkit first; the stages below refer to its definitions by name rather than restating them.
When dispatching subtask subagents, follow the Model Tiers for Subtasks section of citation-toolkit: citation parsing, star-pagination lookup, and exact-quote localization are Haiku-tier; drift annotation, next-hop selection from string cites, and termination decisions are Sonnet-tier; the optional summary-critic pass is Opus-tier.
Overview
Given one proposition and one citation that supposedly supports it, this skill walks backward through the citation graph — at each hop locating the proposition in the current opinion, finding the outbound citation that supports it, and recursing into the predecessor — until the chain terminates naturally. The user gets the full lineage from the input citation back to whatever source CourtListener (or the user, at a checkpoint) can trace it to.
Compared to cite-checking, which scans an entire document's citations at shallow depth, chain-cite is narrow (one proposition, one seed) and deep (no hard cap on chain length; terminates only on natural end conditions).
The skill produces two artifacts: a canonical JSON chain and a human-readable Markdown report (summary first, lineage second, flags and methodology last). Neither the user's input nor any fetched opinion is modified.
Before Starting
Collect four inputs from the user before running any API calls:
-
Proposition — the statement being traced, verbatim. A single sentence is the sweet spot. If the user provides a full paragraph, ask them to narrow to the specific assertion; long propositions match poorly and inflate traversal cost. If the user provides a very short phrase (e.g., "strict scrutiny"), warn that short propositions fan out hard at every hop and suggest adding a clause for specificity.
-
Seed citation — the citation that supposedly supports the proposition. Parsed in Stage 2 using
citation-toolkit's Case schema. If the user provides a short form (Id.,supra), a statute, a rule, a regulation, or a non-US case, stop and explain: this PoC traces propositions through US case law only; those other categories either lack a usable citation graph or require sources outside CourtListener. -
CourtListener access. If the
claude.ai CourtListenerMCP server is available, no token is needed — Stage 2 and the per-hop fetches use the MCP directly. Otherwise, a CourtListener API token is required (free at courtlistener.com/sign-in/; authenticated accounts get 5,000 requests/hour). -
Sibling-branch tracing — when a hop encounters a string cite with multiple predecessors for the same proposition, how should chain-cite handle the siblings?
- Follow only the strongest-signal case (default — typically the first in the string per Bluebook 1.4; siblings are still recorded in the hop's JSON entry as
also_cited_for_same_propositionbut not recursed into) - Also trace sibling branches (each sibling becomes its own sub-chain, traced to termination; the output becomes a forest rather than a chain. Warning: tracing all branches may get expensive token-wise and consume much more of your CourtListener rate budget)
- Follow only the strongest-signal case (default — typically the first in the string per Bluebook 1.4; siblings are still recorded in the hop's JSON entry as
If the seed citation has multiple pincites (e.g., 576 U.S. 155, 163, 170), ask which is the intended pincite — the proposition should live at one specific passage.
Stage 1 — Intake
Validate the four inputs, abort-with-explanation on the out-of-scope cases listed above, and carry them through the rest of the pipeline. Initialize two empty structures that persist through the run:
visited_opinion_ids: set— for cycle detection (see Stage 4).chain: list[Hop]— the accumulating output (see Stage 6 for the Hop schema).
Stage 2 — Seed Resolution
Parse the seed citation using citation-toolkit's Case schema. Resolve to an opinion using citation-toolkit's CourtListener API section:
- MCP path (preferred): look up the seed via the components-only citation-lookup call:
mcp__claude_ai_CourtListener__call_endpoint(endpoint_id="citation-lookup", method="POST", body={"volume": "<vol>", "reporter": "<reporter>", "page": "<page>"}). The response gives you a cluster ID, not an opinion ID. Resolve to the correct sub-opinion viamcp__claude_ai_CourtListener__call_endpoint(endpoint_id="clusters", query={"id": <cluster_id>})→ pick the right entry insub_opinions(prefer020lead, fall back to010combined). Then fetch the opinion text viamcp__claude_ai_CourtListener__get_endpoint_item(endpoint_id="opinions", item_id=<opinion_id>, fields=["id", "type", "html_with_citations", "plain_text"]). Never pass the cluster ID directly toget_endpoint_item("opinions", ...)— it silently returns the wrong opinion. Do NOT useanalyze_citations— it accepts atextparameter, and we keep the bright-line rule that no document text crosses the network. Write the returned JSON to a temp file (e.g.,/tmp/opinion-<id>.json) — Stages 3–4 pass that path tochain_hop.py. - Fallback (no MCP): the equivalent curl POST to
/api/rest/v4/citation-lookup/withvolume/reporter/pageand a user-supplied token, then/api/rest/v4/opinions/<id>/— both documented incitation-toolkit's REST API section.
Sub-opinion selection. When the cluster's sub_opinions array has more than one entry, apply the Choosing the right sub-opinion rule in citation-toolkit: prefer the 020lead opinion unless the seed citation explicitly references a dissent or concurrence. The 020lead sub-opinion typically carries reporter-pagination (*N markers) from the Harvard CAP ingest, which is what the match ladder relies on.
Upfront pagination check. After fetching, run citation-toolkit's pagination-mode detection on the opinion's html_with_citations. Record pagination_mode on Hop 0 (and on every subsequent hop). When pagination_mode != "reporter", the per-hop pincite-page slicing in Stage 3 will fall through to whole-opinion semantic search — flag the hop accordingly so the output report surfaces the localization uncertainty.
Apply identity verification per citation-toolkit. If CourtListener fails, follow the escalation chain defined there (Justia → direct court → Google Scholar → ask user). If the chain fully exhausts, abort with a seed_citation_unverifiable message — there is no chain to trace if we can't find the starting opinion.
Once the seed opinion is fetched and written to disk, record it as Hop 0 with relationship_to_prior_hop: "seed". Proceed to Stage 3 to localize the proposition inside it.
Add the seed opinion's ID to visited_opinion_ids.
Per-Hop Helper Script
This skill ships with chain_hop.py alongside SKILL.md. It is the mechanical half of each hop — filtering an already-fetched opinion JSON to the pincite page and extracting outbound citation anchors. It does no network I/O and no scoring of which passage "best matches" the proposition: fetching is the skill's job (MCP preferred, REST fallback); semantic relevance is a Sonnet-tier judgment, not a regex one.
Each hop is a four-step cycle:
-
Fetch the current hop's opinion JSON. Use
mcp__claude_ai_CourtListener__get_endpoint_item(endpoint_id="opinions", item_id=<opinion_id>, fields=["id", "html_with_citations", "plain_text", "xml_harvard"])(or, without the MCP, the RESTopinions/{id}/endpoint). Write the response to a path like/tmp/opinion-<id>.json. Reuse an existing file if present — the script reads, never writes, this file. -
chain_hop.py page(Haiku-tier / direct script call). Returns the full plain-text of the pincite page plus a sentence-level breakdown. No ranking. -
Semantic selection (Sonnet-tier subagent). Given the proposition being traced and the page text, the subagent picks which sentence carries the same rule and produces the drift annotation (
nullfor verbatim, orrephrases:/broadens:/narrows:with a one-line note). If no sentence on the page carries the rule, the subagent reportsproposition_not_found_in_opinion. -
chain_hop.py anchors(Haiku-tier / direct script call). Given the sentence the Sonnet step chose, extracts the outbound citation anchors inside or immediately after that sentence and decides the next hop, terminal reason, or cycle.
Subcommand: page
python3 <skill-dir>/chain_hop.py page \
--opinion-json /tmp/opinion-<N>.json --pincite PAGE
Returns JSON with page_plain_text (the full page), sentences (sentence-level chunks), and page_start_offset / page_end_offset. If the page marker is not found, returns page_marker_found: false and a whole-opinion fallback.
Subcommand: anchors
python3 <skill-dir>/chain_hop.py anchors \
--opinion-json /tmp/opinion-<N>.json \
--sentence "verbatim passage the Sonnet step chose" \
--visited "id1,id2,..."
Returns next_hop, siblings, non_case_anchors, cycle_anchors, and — when there is no usable outbound citation — a terminal_reason drawn from Stage 4d. Pass the prior chain's opinion IDs in --visited for cycle detection. The opinion JSON must contain html_with_citations (request it explicitly in the fields allowlist when fetching).
Why no lexical scoring
An earlier iteration of this script scored candidate passages by term specificity and char-distance from the page marker. That misfires predictably: a pincite names a whole page (~400 words), and lexical proximity is not semantic relevance. The rule passage on page 280 might sit 2000+ chars after the *280 marker; a general term ("Eleventh Amendment") might appear 50 chars after it in irrelevant context. Any such heuristic will occasionally return a confident wrong answer. Semantic match is Sonnet's job (Tier 3 in citation-toolkit's match ladder) — do not re-introduce lexical scoring here. The script's role is to filter (slice the right page span; extract the right anchors); the judgment of which sentence carries the rule belongs to the semantic step.
Stage 3 — Proposition Localization (per hop)
On entering any hop, the current opinion text has already been fetched (at the end of the previous iteration, or at Stage 2 for the seed). Use html_with_citations as the primary field for both matching and citation-anchor extraction; fall back to plain_text only when HTML is empty. xml_harvard may be requested as an additional pagination source if available.
Apply citation-toolkit's match ladder to localize the proposition in the current opinion:
- When
pagination_mode == "reporter"and the hop has a known pincite page: Tier 1 (direct phrase match) first, then Tier 3 (pincite-page semantic match viachain_hop.py page). - When
pagination_modeis"slip_only"or"none": skip Tier 3 (no reliable pincite page); go straight to Tier 4 (whole-opinion semantic search). Flag the hop withnon_reporter_pagination_detectedorpincite_page_unresolvable.
Within whichever tier applies, the proposition can match in one of three drift-annotation modes:
- Exact or near-exact quote. If the proposition appears verbatim or with minor punctuation/formatting differences, anchor there. High-confidence match;
drift_annotation = null. - Semantic paraphrase. A passage expressing the same rule in different wording. Record the passage verbatim;
drift_annotation = "rephrases: <one-line note on how wording differs>". - Broader or narrower statement. The opinion addresses a generalization or a sub-case of the proposition. Record the passage;
drift_annotation = "broadens"or"narrows"with a one-line note. - No match. This hop is terminal. Emit the hop with
terminal_reason: "proposition_not_found_in_opinion". This is how the chain dies when the rule has mutated so far from its ancestor that the ancestor no longer states it.
For the seed (Hop 0), a "no match" outcome is treated specially: abort the whole run with proposition_not_in_seed — the seed opinion doesn't appear to contain the proposition, so either the citation is wrong or the proposition wording needs work. Suggest the user double-check both.
For non-seed hops, a "no match" is a clean natural termination, not an error.
Record the matched passage (typically 100–300 words of surrounding context), the resolved pincite page (or null if pagination_mode prevented page resolution), and the match_tier_used on the current hop.
Stage 4 — Next-Hop Selection (hybrid pincite-first)
Given the matched passage in the current opinion, find the outbound citation that supports the proposition.
4a. Pincite-anchored attempt (cheap path)
In html_with_citations, look for citation anchor tags inside or immediately after the matched passage's sentence. Three cases:
- Exactly one citation anchored there → that's the next hop. Log
next_hop_strategy: "pincite_anchor". - A string cite anchored there → take the first case in the string (strongest authority per Bluebook 1.4). Record the remaining cases in
also_cited_for_same_propositionon this hop (leaf entries, not recursed into — unless sibling-tracing is on, in which case each sibling spawns its own sub-chain traced to termination). Lognext_hop_strategy: "pincite_anchor". - No citation at the pincite, or the anchored citation is a signal cite pointing somewhere obviously unrelated (e.g.,
see alsoto a procedurally different case) → fall through to 4b.
4b. Fan-out fallback (expensive path)
Enumerate all outbound citations in the broader paragraph containing the matched passage (parsed from html_with_citations). Count the candidates, then:
- ≤ 5 candidates → evaluate all of them automatically. For each: fetch the opinion, apply Stage 3's match strategy, rank by match confidence (exact quote > semantic paraphrase > broadens/narrows). Pick the best-ranked candidate as the next hop.
- > 5 candidates → pause and ask the user. Show the candidate list (citation + one-line context of where each appears in the paragraph). Options: "first 5 only," "all N," "just these specific ones [checkboxes]," "none — treat this hop as terminal." This is a deliberate friction point to prevent unbounded cost growth; do not auto-select.
Log next_hop_strategy: "fanout_fallback" and include fanout_candidates_evaluated: N on the hop for transparency.
4c. Cycle detection
Before committing to a next hop, check whether the target opinion ID is already in visited_opinion_ids. If so, emit this hop as terminal with terminal_reason: "cycle_detected" and include both the current chain-path and the cycle target in a cycle_path field on the hop. This catches pathological oscillations (affirmance ↔ underlying opinion; rare but possible).
When sibling-tracing is on, visited_opinion_ids is shared across the whole forest — two sibling branches that converge on the same ancestor do not each refetch.
4d. Terminal conditions
The hop is terminal (do not recurse) if any of these apply:
- No outbound citation supports the proposition (both 4a and 4b found nothing usable) →
terminal_reason: "no_outbound_for_proposition" - The chosen outbound cite points to a statute, Restatement, constitutional provision, or other non-case source (recognized via
citation-toolkit's taxonomy) →terminal_reason: "cites_statute" | "cites_restatement" | "cites_constitution" | "cites_treatise" | "cites_law_review" | "cites_legislative_material", with the specific citation captured in the hop'sterminal_citationfield - CourtListener returns no cluster for the chosen citation, or the cluster has no opinion text after fallthrough →
terminal_reason: "courtlistener_missing" - Cycle detected (per 4c) →
terminal_reason: "cycle_detected"
If not terminal, fetch the predecessor opinion now (in preparation for the next iteration), add its ID to visited_opinion_ids, append the hop to chain, and recurse.
4e. Soft checkpoint
Every 8 opinions fetched across the whole run (counting the forest total when sibling-tracing is on, not the linear depth), pause and emit an interim summary:
- Chain rendered as prose through the last completed hop
- Opinions fetched so far; approximate remaining CourtListener budget for the hour (best-estimate based on 5,000/hr)
- "Continue? — continue one more block of 8 / continue to natural termination / stop now"
If the user stops, the last completed hop gets terminal_reason: "user_stopped_at_checkpoint" and the chain is written out through it. This is a pause, not a cap.
Stage 5 — Termination Handling
When a hop is emitted as terminal, attach a suggested next step derived mechanically from the terminal_reason:
terminal_reason | Suggested next step |
|---|---|
cites_statute | Consult the statute's legislative history (Congress.gov) and USC annotations (Cornell LII). |
cites_restatement | Consult the Restatement's Reporter's Notes (print volumes via ALI, or Westlaw/Lexis) for the case citations behind the rule. |
cites_constitution | Consult constitutional-history treatises; CourtListener does not trace constitutional provisions further. |
cites_treatise | Consult the treatise directly via HeinOnline, Google Books, or a law library. |
cites_law_review | Consult the article directly via SSRN, the journal's website, or HeinOnline. |
cites_legislative_material | Consult Congress.gov or GovInfo for the full legislative record. |
courtlistener_missing | CourtListener has no text for this opinion; try Justia, the issuing court's website, or a legal database (Westlaw/Lexis). |
no_outbound_for_proposition | This opinion states the rule without citing a predecessor for it. This may be the effective origin, or the origin may be implicit (e.g., common-law tradition). Consider consulting a hornbook or treatise for historical context. |
proposition_not_found_in_opinion | The predecessor does not appear to state this proposition. The rule may have first been articulated at the previous hop, or the chain-of-citation may be weak at that link. |
cycle_detected | Chain looped back to an already-visited opinion ({cycle_target}); this is often the effective origin when two cases cite each other. |
user_stopped_at_checkpoint | User stopped at this hop; resume by invoking the skill with this hop's citation as the new seed and the same proposition. |
network_error | Transient fetch failure; re-run the skill to resume. Partial chain through the last successful hop is preserved. |
Stage 6 — Output Assembly
Produce two files in the output directory the user specified (default: current working directory), with matching basenames:
chain-cite-<slug>-<YYYYMMDD>.json— canonical, machine-readablechain-cite-<slug>-<YYYYMMDD>.md— human-readable, rendered from the JSON
Where <slug> is derived from the seed citation's case name (e.g., smith-v-jones).
JSON schema
One Hop object per entry in chain:
{
"hop_index": 0,
"citation": "Smith v. Jones, 123 F.3d 456 (5th Cir. 2020)",
"opinion_id": 1234567,
"pincite_page": 460,
"quoted_passage": "…",
"relationship_to_prior_hop": "seed" | "quoting" | "paraphrasing" | "holding",
"drift_annotation": null | "broadens" | "narrows" | "rephrases: <note>",
"also_cited_for_same_proposition": [
{
"citation": "…",
"opinion_id": … or null,
"note": "leaf entry (sibling-tracing off)" | "recursed into — see branch below"
}
],
"flags": [ … ],
"next_hop_strategy": "pincite_anchor" | "fanout_fallback" | "terminal",
"fanout_candidates_evaluated": null | N,
"terminal_reason": null | "<one of the values in Stage 5>",
"terminal_citation": null | "<the cite that ended the chain, for statute/treatise/etc. cases>",
"suggested_next_step": null | "<text from Stage 5 table>",
"cycle_path": null | [opinion_id, opinion_id, …]
}
When sibling-tracing is on, also_cited_for_same_proposition entries become full Hop objects with their own recursive structure; the top-level chain becomes a tree rather than a linear list. Same schema either way — the default case is a degenerate tree with no branches.
Relationship annotation
Assigned based on what the current opinion does with the proposition relative to the cited predecessor:
| Value | When assigned |
|---|---|
seed | Hop 0 only |
quoting | The opinion uses explicit quote marks around the proposition and attributes to the predecessor |
paraphrasing | The opinion states the rule in its own words while citing the predecessor |
holding | The opinion states the rule as its own without attribution to any predecessor — the chain may continue via fan-out if an outbound cite supports the proposition, but this hop is a candidate "earliest original holding" |
Markdown report layout
Summary-first, so readers see the answer before scrolling through hop detail:
# Chain-Cite Report
**Proposition:** "[user's proposition, verbatim]"
**Seed citation:** [user's citation]
**Date:** [date]
**Total hops:** N
**Terminal reason:** [e.g., "cites 42 U.S.C. §1983 — chain terminates at statute"]
## Summary
[2–4 sentence narrative of the lineage: where the rule originated, where the earliest-original-holding hop is, how the wording drifted, where it terminated. Rendered from the lineage, but written first in the file.]
## Lineage (newest → oldest)
### Hop 0 (seed) — [citation]
**Relationship to prior:** seed
**Passage at pincite:**
> "[quoted passage]"
**Observation:** [optional — e.g., auto-surfaced "This is the earliest-original-holding hop for this proposition" when relationship_to_prior_hop == "holding"]
### Hop 1 — [citation]
**Relationship to prior:** [quoting / paraphrasing / holding]
**Drift:** [omit line entirely if null; otherwise broadens/narrows/rephrases with note]
**Passage at pincite:**
> "[quoted passage]"
**Also cited for same proposition:** [citation list, one-line each — omit line entirely if empty]
[… additional hops …]
### Hop N (terminal) — [citation or description of terminal source]
**Relationship to prior:** [value]
**Terminal reason:** [from Stage 5]
**Suggested next step:** [from Stage 5 table]
## Flags
[Bulleted list of per-hop flags: drift annotations, resolved_via_short_form, low_confidence_ocr, cycle_detected, etc. Omit the section entirely if there are no flags.]
## Methodology
- Traversal: hybrid pincite-first
- Sibling tracing: [on / off]
- Opinions fetched: N
- Checkpoints hit: N
- Fan-out fallbacks triggered: N
Implementation note on ordering. The Summary section must be rendered after the lineage is fully traced (because it summarizes the lineage), but written to the file before the Lineage section. Build the chain first internally, then render both sections, then write.
Optional: summary critic
After assembling the output, a single summary-level critic subagent may be dispatched to review the full chain end-to-end and report whether the lineage hangs together (i.e., whether the proposition actually survives across all the claimed drift annotations without losing its identity). This is one subagent call for the whole chain, not per hop. Default: off for the PoC. When on, the critic's output appears as a ## Critic Assessment section between Summary and Lineage.
Edge Cases
| Situation | Handling |
|---|---|
Seed citation is a short form (Id., supra) with no context | Cannot resolve — abort at intake, explain, ask for the full form |
| Seed citation is a statute/rule/regulation/constitutional provision | Out of scope for PoC — abort at intake with a clear message ("chain-cite traces propositions through case law; statutes don't have the kind of citation graph this skill walks") |
| Seed is a foreign / EU case | Out of scope for PoC — CourtListener is US-focused |
| Proposition is a whole paragraph | Ask user to narrow to a specific sentence |
| Proposition is a single word or phrase (e.g., "strict scrutiny") | Warn that short propositions fan out hard; suggest adding specificity |
| Opinion text is OCR'd and garbled | Attempt match on the primary field (html_with_citations stripped, or plain_text) anyway, flag the hop with low_confidence_ocr, continue |
| Opinion cites a predecessor by short form only (e.g., "See id.") | Use citation-toolkit's short-form resolution against the opinion's internal citation stack; flag resolved_via_short_form on that hop |
Multiple pincites in seed citation (576 U.S. 155, 163, 170) | Ask user which pincite is the intended one at intake |
| User's proposition appears multiple times in the same opinion at different pincites | Prefer the passage with the clearest outbound citation; flag multiple_proposition_locations and note which pincite was used |
| CourtListener returns a correct cluster but the opinion text is empty / missing | Fall through escalation chain per citation-toolkit (Justia → direct court → Google Scholar). If all fail, terminal with courtlistener_missing |
Error Handling
Philosophy (inherited from cite-checking): escalate, don't fail. Every failure mode has a terminal state with a reason; the chain never crashes mid-run, only stops cleanly and writes out what it has.
| Failure | Handling |
|---|---|
| Citation parsing fails at Stage 2 | Ask user for a cleaner version; if still unparseable, abort with a clear pre-chain message |
CourtListener citation-lookup returns no cluster for seed | Escalation chain per citation-toolkit. If exhausted, abort with seed_citation_unverifiable |
| Cluster found but identity check fails | Treat as no-match, fall through escalation |
| Proposition cannot be located in the seed opinion | Abort with proposition_not_in_seed; explain and suggest the user double-check citation and/or wording |
| Proposition cannot be located in a non-seed opinion | Terminal hop with proposition_not_found_in_opinion — natural termination, not an error |
Opinion fetch returns empty html_with_citations AND empty plain_text | If xml_harvard is populated, derive text from it; otherwise terminal with courtlistener_missing |
| CourtListener rate limit (HTTP 429) | Pause, show message, ask user to wait or retry. Respect Retry-After header if present. Interactive pause, not terminal |
| Network error mid-traversal | Retry once after 5s; if still failing, emit the partial chain with terminal_reason: "network_error" |
| User interrupts at checkpoint | Terminal user_stopped_at_checkpoint, chain fully written out through the last completed hop |
| Subagent errors (if summary critic is enabled) | Flag critic_unavailable on the report; the lineage itself is unaffected |
At every checkpoint and at every terminal emission, the skill writes both output files to disk — so a crashed or killed run leaves the partial work available rather than losing it. Re-running with the same seed produces a fresh output; there is no resume-from-state file in the PoC (users resume by invoking the skill with the last completed hop as the new seed).
Last updated: October 13, 2026
Files (3)
- Practice area
- General practice
- License
- MIT
- Author
- jslandau
Test Chain-Cite
Ask a legal question — get an answer generated live from this skill’s instructions.
Sign in to test this skill.