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:

  1. 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.

  2. 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.

  3. CourtListener access. If the claude.ai CourtListener MCP 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).

  4. 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_proposition but 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)

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 via mcp__claude_ai_CourtListener__call_endpoint(endpoint_id="clusters", query={"id": <cluster_id>}) → pick the right entry in sub_opinions (prefer 020lead, fall back to 010combined). Then fetch the opinion text via mcp__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 to get_endpoint_item("opinions", ...) — it silently returns the wrong opinion. Do NOT use analyze_citations — it accepts a text parameter, 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 to chain_hop.py.
  • Fallback (no MCP): the equivalent curl POST to /api/rest/v4/citation-lookup/ with volume/reporter/page and a user-supplied token, then /api/rest/v4/opinions/<id>/ — both documented in citation-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:

  1. 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 REST opinions/{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.

  2. 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.

  3. 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 (null for verbatim, or rephrases: / broadens: / narrows: with a one-line note). If no sentence on the page carries the rule, the subagent reports proposition_not_found_in_opinion.

  4. 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 via chain_hop.py page).
  • When pagination_mode is "slip_only" or "none": skip Tier 3 (no reliable pincite page); go straight to Tier 4 (whole-opinion semantic search). Flag the hop with non_reporter_pagination_detected or pincite_page_unresolvable.

Within whichever tier applies, the proposition can match in one of three drift-annotation modes:

  1. Exact or near-exact quote. If the proposition appears verbatim or with minor punctuation/formatting differences, anchor there. High-confidence match; drift_annotation = null.
  2. 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>".
  3. 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.
  4. 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_proposition on 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). Log next_hop_strategy: "pincite_anchor".
  • No citation at the pincite, or the anchored citation is a signal cite pointing somewhere obviously unrelated (e.g., see also to 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's terminal_citation field
  • 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_reasonSuggested next step
cites_statuteConsult the statute's legislative history (Congress.gov) and USC annotations (Cornell LII).
cites_restatementConsult the Restatement's Reporter's Notes (print volumes via ALI, or Westlaw/Lexis) for the case citations behind the rule.
cites_constitutionConsult constitutional-history treatises; CourtListener does not trace constitutional provisions further.
cites_treatiseConsult the treatise directly via HeinOnline, Google Books, or a law library.
cites_law_reviewConsult the article directly via SSRN, the journal's website, or HeinOnline.
cites_legislative_materialConsult Congress.gov or GovInfo for the full legislative record.
courtlistener_missingCourtListener has no text for this opinion; try Justia, the issuing court's website, or a legal database (Westlaw/Lexis).
no_outbound_for_propositionThis 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_opinionThe 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_detectedChain looped back to an already-visited opinion ({cycle_target}); this is often the effective origin when two cases cite each other.
user_stopped_at_checkpointUser stopped at this hop; resume by invoking the skill with this hop's citation as the new seed and the same proposition.
network_errorTransient 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-readable
  • chain-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:

ValueWhen assigned
seedHop 0 only
quotingThe opinion uses explicit quote marks around the proposition and attributes to the predecessor
paraphrasingThe opinion states the rule in its own words while citing the predecessor
holdingThe 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

SituationHandling
Seed citation is a short form (Id., supra) with no contextCannot resolve — abort at intake, explain, ask for the full form
Seed citation is a statute/rule/regulation/constitutional provisionOut 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 caseOut of scope for PoC — CourtListener is US-focused
Proposition is a whole paragraphAsk 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 garbledAttempt 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 pincitesPrefer 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 / missingFall 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.

FailureHandling
Citation parsing fails at Stage 2Ask user for a cleaner version; if still unparseable, abort with a clear pre-chain message
CourtListener citation-lookup returns no cluster for seedEscalation chain per citation-toolkit. If exhausted, abort with seed_citation_unverifiable
Cluster found but identity check failsTreat as no-match, fall through escalation
Proposition cannot be located in the seed opinionAbort with proposition_not_in_seed; explain and suggest the user double-check citation and/or wording
Proposition cannot be located in a non-seed opinionTerminal hop with proposition_not_found_in_opinion — natural termination, not an error
Opinion fetch returns empty html_with_citations AND empty plain_textIf 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-traversalRetry once after 5s; if still failing, emit the partial chain with terminal_reason: "network_error"
User interrupts at checkpointTerminal 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)

Jurisdiction
🇺🇸 United States
Browse legal databases
Practice area
General practice
License
MIT
Author
jslandau
Source
us/general/skills/chain-cite-jslandau

Test Chain-Cite

Ask a legal question — get an answer generated live from this skill’s instructions.

Sign in to test this skill.