<!-- Threadlinqs Intelligence MCP — agent skill (single-file build)
     Generated from https://intel.threadlinqs.com/mcp/catalog.json — 81 tools.
     Canonical: https://intel.threadlinqs.com/mcp -->

# Threadlinqs Intelligence MCP

81 tools over a curated threat-intelligence corpus. This skill exists because the naive
path through those tools is expensive and often wrong: an agent that reaches for
`get_threat` and then chains four follow-ups is doing by hand what one composite tool does
in a single call, and an agent that guesses an enum value gets an empty result rather than
an error and reports "nothing found" when the answer was there all along.

Read this before your first call. The routing table below is the part that matters.

> **Tool names may be prefixed by your host.** Claude Code exposes
> `mcp__threadlinqs-intel__get_threat`, Claude Desktop exposes `get_threat`, the remote
> connector uses another prefix again. This document writes bare names — match by suffix.

## Access, in one paragraph

Tool **calls** require a Purple or Gold key (tier ≥ 3) in `THREADLINQS_API_KEY`.
Introspection over MCP (`tools/list`) is gated too, but the HTTP twins
`GET /mcp/catalog.json` and `GET /mcp.md` are public, which is how registries index the
server without a key. There is no free tier for the data. If you have no key, say so and
point at `https://intel.threadlinqs.com/profile` — new accounts get a 7-day Purple trial.
See `references/setup.md` for per-host install.

## Before your first call

1. Call `get_started` once. It returns the tool catalog, categories and tiering, and costs
   no database round-trip.
2. Call `hunt_schema` **only if** an aggregate question is coming — then cache it for the
   session. It is the field grammar for `hunt`, and you cannot write a valid query without it.
3. Call `get_engine_status` **only if** you are about to reason over correlation output. A
   stale engine is a caveat on every correlation answer you give.

Do not reflexively open with `get_platform_stats` + `list_threat_categories` + `health`.
That is three calls of ceremony before you have learned anything.

## Routing — what to call first

| You have / user asks | First call | Then, only if needed |
|---|---|---|
| Threat ID + a hunting or scoping question | `get_threat_hunting_bundle` | `get_threat_enrichment`, `get_detection_detail` |
| Threat ID, just want the record | `get_threat` | `get_threat_bundle` |
| 2–20 threat IDs | `bulk_get_threats` | — |
| Actor name or alias | `get_actor_intelligence` | `get_attribution_evidence`, `export_attack_navigator` |
| A name you are not sure resolves | `resolve_entity` | then the matching pivot below |
| An exact IOC value | `get_ioc_intelligence` | `get_ioc_blast_radius`, `get_ioc_dns`, `get_infrastructure_pivots` |
| An IOC substring / partial | `search_iocs` | — (narrow it; paging is not honored) |
| CVE ID | `get_cve_intelligence` | `get_cwe`, `bulk_get_cves` |
| "What should we patch first?" | `search_vulnerabilities` with `kev` / `epss_min` | `get_cve_intelligence` on the top few |
| Malware family | `get_malware_intelligence` | `resolve_entity` if the name is ambiguous |
| Offensive tool | `get_tool_intelligence` | `resolve_entity` |
| Named campaign | `get_campaign_intelligence` | `get_graph_campaigns` for engine-derived clusters |
| ATT&CK technique | `get_mitre_technique` | `predict_mitre_transitions`, `get_technique_rules` |
| "What do we detect / not detect?" | `get_mitre_coverage` → `get_mitre_gap_analysis` | `search_detections` |
| "Are these two threats related?" | `explain_correlation` | `get_correlation_path` |
| "What is around this entity?" | `get_entity_profile` | `get_correlation_subgraph` at **depth 1** |
| Any count / group-by / cross-type question | `hunt_schema` once → `hunt` | — |
| Conceptual question, keywords already failed | `search_corpus_semantic` | — |
| "What happened today?" | `get_daily_intel_bundle` | `list_debriefs` → `get_debrief` |
| "Block this" | `generate_c2_blocklist` | `get_c2`, `get_c2_dns_intel` |
| "Feed my TIP / SIEM" | `export_stix`, `export_attack_navigator`, `export_detection` | — |

### `hunt` vs `search_threats` — the highest-value rule here

Use **`hunt`** when the question contains a count, a grouping, or crosses observation types
("which actors use Cobalt Strike *and* have domain IOCs"). Scoped observables compile to a
threat-level subquery, which is exactly why cross-type questions work at all. Append
`| stats count by <field>` for aggregates.

Use **`search_threats`** when you want matching threat rows back with structured filters.

Counting rows yourself from repeated `search_threats` calls is wrong twice over: it burns
calls, and your count is wrong at page boundaries. Full grammar in `references/hunt-tlql.md`.

## Composite tools — one call instead of five

These exist specifically to collapse a chain. Reaching for the parts when a composite
covers the whole is the most common failure on this server.

| Instead of chaining | Call |
|---|---|
| `get_threat` → `get_similar_threats` → `get_threat_simulations` → `get_infrastructure_pivots` | `get_threat_hunting_bundle` |
| threat + simulations + transcripts | `get_threat_bundle` |
| `get_actor` + attribution + correlations | `get_actor_intelligence` |
| `search_iocs` → N× `get_threat` → N× `get_actor` | `get_ioc_intelligence` |
| `get_cve` + exploitation velocity + detection coverage | `get_cve_intelligence` |
| debrief + stats + recent threats + correlations | `get_daily_intel_bundle` |
| a loop of `get_threat` / `get_cve` over known IDs | `bulk_get_threats` / `bulk_get_cves` (≤ 20) |
| N× `search_threats` then counting the rows | `hunt … \| stats count by …` |

## Budget discipline

Responses are capped at **90,000 characters**. On overflow the payload ends with
`... [truncated: response exceeded 90000 chars — narrow your query`. Recognise that string
and escalate in this order:

1. add a filter, 2. lower `limit`, 3. switch to a narrower tool,
4. for graph calls lower `depth` / `limit_nodes` / `limit_edges`, or use `get_entity_profile`
   instead of `get_correlation_subgraph`.

Retrying the identical call after truncation just burns the same budget again.

**Paging is honored** on `search_threats`, `get_recent_threats`, `get_detections`,
`search_xscan_indicators`. It is **not** honored on `search_iocs`, `list_debriefs`,
`search_actors` — narrow the query instead of incrementing an offset that does nothing.

## Errors — both terminal

| Code | Meaning | Do |
|---|---|---|
| `-32001` | No key, or the key is invalid | Say so, link `https://intel.threadlinqs.com/profile`, **stop** |
| `-32002` | Authenticated but tier < 3 | Report the tier and the upgrade path, **stop** |

Neither is retryable, and neither gets better by trying a different tool. Do not walk the
catalog hoping one is ungated — none are.

An **empty result is not an error**, and on this server it is most often a *guessed enum*.
Categories, severities, C2 views, correlation engines, node types and seed types are all
closed vocabularies where a wrong value returns nothing silently. Call the corresponding
list tool first. Full vocabulary in `references/vocabularies.md`.

## Honesty rules

The corpus is opinionated about what it knows, and the skill is what stops you overclaiming.

- **Attribution.** `get_attribution_evidence` distinguishes a researched, cited assessment
  from a pending intake stub. A stub is **not** an attribution — never restate one as a
  finding. Say "unattributed, pending research".
- **Pivotal entities.** The betweenness score from `get_pivotal_entities` is an ego-bridge
  *heuristic*; the payload says so itself. It ranks candidates, it does not prove centrality.
- **OSINT.** Everything from `get_osint`, `get_osint_trends`, `search_xscan_indicators` and
  `get_community_campaigns` is community-sourced. It corroborates; it is never authoritative.
  Campaign labels there are upstream AI output. Keep it labelled as community-sourced rather
  than merging it into corpus findings.
- **DNS.** `get_ioc_dns` returns stored enrichment, never a live lookup. Say when it was seen.
- **Shared infrastructure.** Shared hosting and CDN ranges are not adversary infrastructure.
  Call that out rather than listing it as a pivot.
- **Correlation strength.** A shared-technique edge is not attribution. `explain_correlation`
  returns the evidence behind an edge — quote which signal carries the claim.

Fuller treatment in `references/data-caveats.md`.

## Reference files

| File | Read it when |
|---|---|
| `references/tools.md` | You need a tool's exact arguments and return shape (all 73, generated from the live catalog) |
| `references/hunt-tlql.md` | Any aggregate or cross-observable question |
| `references/recipes.md` | You want a proven end-to-end workflow |
| `references/vocabularies.md` | Before passing any category, severity, view, engine or type |
| `references/setup.md` | Installing, or diagnosing auth and connectivity |
| `references/data-caveats.md` | Before making a claim the data may not support |

## Resources and interactive widgets

Beyond tools, the server exposes MCP **resources** — `threadlinqs://stats`,
`threadlinqs://threats/recent`, `threadlinqs://briefing/landscape`, plus templates for
individual threats, CVEs and actors. Hosts that render MCP Apps also get interactive
`ui://` widgets (ATT&CK matrix, correlation graph, threat and actor dossiers, C2 treemap,
hunt results). Hosts that do not render them ignore them safely — never depend on a widget
for correctness, and always state the answer in text as well.

Prompt playbooks ship with the server too; call `prompts/list` to see what the connected
version offers, since the set grows between releases.

---

# Data caveats — what the corpus does not support

Every tool here returns something. That is the problem: a populated field is not the same
as a supported claim. This file is the boundary between what the payload says and what you
are allowed to say on top of it.

Tool names are written bare. Hosts may prefix them (`mcp__threadlinqs-intel__get_threat`,
`mcp__claude_ai_threadlinqs__get_threat`) — match by suffix.

Read this before writing any sentence that names an actor, asserts a link, ranks an entity,
or reports a count.

---

## 1. The provenance ladder

Four kinds of content ship through the same JSON. They do not carry the same weight.

| Provenance | Produced by | Tools | How to speak about it |
|---|---|---|---|
| Curated | Threadlinqs research + editorial review | `get_threat`, `get_detections`, `get_cve`, `get_actor`, `get_debrief` | State it. Cite the threat ID. |
| Researched assessment | Analyst-run evidence chain with citations | `get_attribution_evidence` where `state: "assessed"` | State it, with confidence + scope + the citations. |
| Machine-derived | Nightly graph/correlation pipeline | `get_similar_threats`, `explain_correlation`, `get_correlation_path`, `get_pivotal_entities`, `get_graph_campaigns`, `get_correlation_subgraph`, `predict_mitre_transitions`, `get_technique_rules` | "The correlation engine links…", never "these are the same campaign". |
| Community-sourced | tweetfeed.live (CC0), reported by the public | `get_osint`, `get_osint_trends`, `search_xscan_indicators`, `get_community_campaigns` | Corroborating evidence. Never authoritative, never attribution. |
| Upstream AI output | tweetfeed's own clustering model | `get_community_campaigns` cluster names/labels | "The community feed labels this cluster X" — not our assessment. |
| Agent process artifact | Research agents' working notes | `get_threat_transcripts` | Reasoning that produced the record, not an independent source. Do not cite a transcript as evidence for its own conclusion. |

Anything from the bottom three rows needs an attributed source in the sentence. If you
cannot fit the attribution in, drop the claim.

---

## 2. Attribution: researched assessment vs pending intake stub

`get_attribution_evidence` returns a `verdict` column on **every** row, including rows
nobody has researched. The nightly intake stubs each unattributed threat as
`verdict: "assessed_unattributed"`, `reason_code: "pending_research"`, `method: "intake"`
purely so the backlog has a live denominator. The `state` field exists to keep you from
rendering "ASSESSED · no public attribution" over a row nobody has looked at.

| Field | Meaning |
|---|---|
| `state: "assessed"` | A researcher ran the check. `evidence` holds citations, `reasoning` holds the analysis. |
| `state: "pending_research"` | Queue placeholder. `evidence` and `signals` are `[]`. `queued_at` is an intake timestamp, not an assessment date. |
| `verdict: "attributed"` | Actor named for *this* activity by cited sources, with `confidence` and `scope`. |
| `verdict: "assessed_unattributed"` + `state: "assessed"` | A **documented** non-attribution — the check was performed and cited. |
| `suspected` | Near-miss candidates that failed the evidence bar. Leads, not conclusions. |

`get_attribution_coverage` splits the corpus three ways and the three do not mean the same
thing:

- `actor_labeled_at_ingest` — the source feed carried an actor name. Never independently
  assessed by this platform. `threats.threat_actor` being populated proves nothing about
  evidence quality.
- `assessed` = `rows_total − pending_research` — research-backed records.
- `uncovered` — cohort threats with no attribution row at all.
- `coverage_pct` divides by `denominator_total` (`rows_total + uncovered`), not by
  `rows_total`. Do not recompute it against `rows_total`; that reads 100% whenever the
  intake has not run.
- `latest_assessment_at` is the **research** clock. `last_intake_at` is the machine clock.
  A fresh `last_intake_at` with a months-old `latest_assessment_at` means the research lane
  is stalled, not that attribution is current.

| Tempted to say | Say instead |
|---|---|
| "TL-2026-0989 is unattributed." (from a stub) | "No attribution assessment has been performed yet — the record is a pending research stub." |
| "The platform assessed 1,787 threats." | "N threats carry a research-backed assessment; M more are labelled by the source feed at ingest and have not been independently assessed." |
| "Attributed to APT29." (from `threats.threat_actor`) | "Labelled APT29 by the source feed. `get_attribution_evidence` shows `state: pending_research`, so the label is not corroborated here." |
| "Suspected LockBit." (from `suspected`) | "LockBit was considered and rejected against the evidence bar; recorded as a near-miss candidate." |

**Never** present a stub as an attribution, and never upgrade `suspected` to attributed
prose.

---

## 3. `threat_similarity` is directional — absence is not dissimilarity

The similarity writer stores **top-N neighbours per source threat**. Row `(A,B)` exists
because B was in A's top-N; `(B,A)` may not exist. `get_similar_threats` queries both slots
and dedupes to max score, and `explain_correlation` tries `(a,b)` then `(b,a)`, so you do
not need to compensate — but the *semantics* still bite:

- A threat can be genuinely related to another and be **absent** from its neighbour list
  simply because both top-N slots were full of stronger links.
- The campaign / centrality layer only ingests edges with `similarity_score >= 0.45`.
  Anything weaker is invisible to `get_graph_campaigns` and `get_pivotal_entities`.
- `explain_correlation` returning 404 means **the engine has no edge**, not "these threats
  are unrelated". Same for `get_correlation_path` returning `found: false` — it tells you
  which reason (no edges vs different components).

| Tempted to say | Say instead |
|---|---|
| "These two threats are unrelated." | "The correlation engine holds no edge between them, so it offers no evidence either way." |
| "TL-A's five related threats are…" | "The engine's top-ranked neighbours for TL-A are… (a top-N list, not an exhaustive set)." |
| "Nothing connects this incident to that campaign." | "No path was found within N hops over the similarity graph." |

---

## 4. Correlation fidelity — what a low-fidelity edge does not mean

Co-occurrence edges (`technique_cooccurrence`, `ioc_threat`, `actor_technique`) score with
NPMI plus a support shrink: `fidelity = round(weight_norm × 100 × c/(c+2))`, where `c` is
the joint count. Two consequences:

- **Low fidelity usually means low support**, not a weak real-world relationship. A pair
  seen twice is shrunk by 0.5 before it is scored at all; pairs below the minimum support
  produce **no edge**, so they are absent rather than low.
- **High fidelity means "co-occurs far above chance in this corpus"** — a statement about
  the corpus, not about the adversary. A technique pair that is universal in the wild but
  rare across the corpus scores low. (Do not hard-code a corpus size here — call
  `get_platform_stats`; the nightly ingest moves it.)

Per-pair quality flags come back on `get_similar_threats` and `explain_correlation`:

| Flag | Set when | What it obliges you to do |
|---|---|---|
| `is_high_conf_low_signal` | score > 0.7 but fewer than 3 signals fired | Do not lead with the score. Name the one or two shared artifacts and let the reader judge. |
| `is_stale` | `decay_factor < 0.3` | Say the link rests on old activity. |
| `dominance` | `{signal, pct}` — which channel carried the score | If `pct` is high and the channel is `context`, the link is thematic, not technical. |

`get_correlation_subgraph` compares `min_fidelity` against a **0-100** stored column. A
value like `0.5` therefore filters essentially nothing. Check the `fidelity_score` values on
the returned edges before claiming you restricted the view to strong edges.

| Tempted to say | Say instead |
|---|---|
| "A low-fidelity edge shows the link is weak." | "The edge has low support — few co-occurrences — so the engine cannot separate it from chance." |
| "I filtered to high-fidelity edges only." | Quote the actual `fidelity_score` range you saw. |
| "Score 0.82 — strongly related." (with `is_high_conf_low_signal`) | "Score 0.82, but on fewer than three signals; the shared evidence is X and Y only." |

---

## 5. `get_pivotal_entities` betweenness is an ego-bridge heuristic

`betweenness_approx` is **not** Brandes betweenness. For each node the pipeline counts
non-adjacent pairs among its strongest neighbours (capped at 24, recorded in
`betweenness_basis` as `ego_bridge_full` or `ego_bridge_top24`). The response says so in
`note` — repeat it rather than dropping it.

- `role` (`bridge` / `hub` / `leaf`) is assigned by a **p10 threshold over the current
  corpus**. It is a relative rank, not an absolute property. A rebuild can flip a node's
  role without anything in the world changing.
- `pivot_rank` is a dense rank on weighted degree over the same snapshot.
- The metric is computed over threat nodes on the similarity graph, so "pivotal" means
  "structurally central *in our corpus*", never "central to the threat landscape".

| Tempted to say | Say instead |
|---|---|
| "T1059 has the highest betweenness centrality." | "T1059 ranks highest on the engine's approximate ego-bridge score — a heuristic for structural brokerage, not exact betweenness." |
| "This is a bridge node." | "The engine classes it `bridge` — top-decile ego-bridge score in the current snapshot." |
| "Detect here and you cover the most threats." | "The engine ranks this highest for coverage-per-detection in this corpus; validate against your own telemetry." |

---

## 6. OSINT is community-sourced, concentrated, and corroborating only

`get_osint`, `get_osint_trends`, `search_xscan_indicators` and `get_community_campaigns`
read the TL_OSINT_Scan layer over tweetfeed.live (CC0). Every response carries a `caveat`
block computed live from the producer rollup:

| Field | Meaning |
|---|---|
| `reporter_concentration` | Share of recent submissions from the single largest reporter (recently ~73%). |
| `top_reporter` | That reporter's handle. |
| `bus_factor_50` | How many reporters it takes to reach half the volume. |
| `active_producers` | Distinct reporters in the 7-day window. |

Read these from the payload; do not hardcode the percentage — it moves. When
`reporter_concentration` is high, "the community is reporting X" is really "one or two
accounts are reporting X".

Two fields are routinely misread:

- **`community_tag_volume` is not this threat's community IOC count.** It is the
  community's total volume for the *matched tags* — on one threat it read 4,294 against 15
  actually corroborated indicators, a ~280× overstatement. The corroboration number is
  `corroborated`.
- **`level`** grades the evidence: `full` (at least one curated indicator corroborated) ·
  `context` (no corroboration, but campaigns or new related indicators) · `tags_only`
  (family-tag overlap only) · `none` (scanned, found nothing). Only `full` supports
  "the community corroborated this".

### Lead time is bimodal — do not report a single average

`get_osint_trends.early_warning` returns the distribution deliberately, with its own
`interpretation` string:

- `within_7d` is the **operational early-warning cohort** — the community flagged the
  indicator days before our report.
- `over_90d` is **reused infrastructure resurfacing**, not advance warning. Folding it into
  a mean inflates "we get N days of warning" enormously.
- `mean_lead_days` ships, but quoting it alone is the misuse the field's own comment warns
  about.

Also: `search_xscan_indicators` has no family filter on purpose — the upstream AI family
field is populated on under 1% of rows. Filter by `tag`. "No results" from a family-shaped
query would be an artifact of the missing field, not evidence of absence.

| Tempted to say | Say instead |
|---|---|
| "The community independently corroborates this." | "N indicators were corroborated by community reports; the feed's recent volume is ~X% from a single reporter, so treat it as one source." |
| "4,294 community indicators for this threat." | "15 of this threat's indicators were corroborated; 4,294 is the community's total volume for the matched tags." |
| "We see threats ~40 days before disclosure." | "For the ≤7-day cohort (N threats) the community flagged indicators a median of X days before our report; a long tail over 90 days reflects reused infrastructure, not warning." |
| "OSINT attributes this to X." | The OSINT layer never attributes. Drop the sentence. |

---

## 7. Community campaign labels are upstream AI output

`get_community_campaigns` returns clusters the **upstream feed's own model** assembled —
`name`, `confidence`, `targeted_brand` are its labels, not Threadlinqs assessments. The
`source` string on the response says exactly that.

Failure mode matters: on a proxy failure the response carries `community_error:
"upstream_unavailable"` with `campaigns: null` rather than erroring. Report "the community
feed is unavailable", never "there are no community campaigns". Same rule applies to
`stale` / `stale_since` on that payload — a stale window is not a quiet week.

| Tempted to say | Say instead |
|---|---|
| "The 'ClickFix Payroll' campaign targets Workday." | "The community feed's clustering labels this group 'ClickFix Payroll' with `targeted_brand: Workday` — an upstream AI label, not our attribution." |
| "No community campaigns are active." | "The community campaign feed did not return data (`community_error: upstream_unavailable`)." |

---

## 8. DNS is stored enrichment, never a live lookup

`get_ioc_dns` reads previously-resolved records out of the platform dataset. It does **not**
resolve anything at call time. A domain that has since moved, been sinkholed, or been taken
down still returns its historical records.

- Always date the claim from the record's `first_seen` / `last_seen` / `enriched_at`.
- An empty result means "no stored enrichment for this indicator", not "does not resolve".
- `get_c2_dns_intel` reports `fidelity` derived purely from the count of distinct domains
  on a beacon IP: `dedicated` ≤5, `mixed` ≤50, `shared` >50. That is a hosting-shape
  heuristic. `dedicated` means "few domains observed", not "proven adversary-owned"; a
  fresh or under-enriched IP looks dedicated for the same reason a real one does.
- `compromised` on that tool is a TLD heuristic (`.gov`/`.edu`/`.mil`/`.int` and country
  equivalents), i.e. "looks like legitimate infrastructure", not a confirmed breach.
- `generate_c2_blocklist` takes **no arguments**. It compiles beacons seen in a recent
  window, and the beacon corpus today is Cobalt Strike only — it is not a complete C2 list,
  and an IP's absence is not a clean bill of health.

| Tempted to say | Say instead |
|---|---|
| "The domain currently resolves to 5.9.x.x." | "Stored enrichment (last seen YYYY-MM-DD) maps it to 5.9.x.x. This is cached data, not a live resolution." |
| "This IP is adversary-owned." | "Only N domains are observed on this IP, which the platform grades `dedicated` — consistent with dedicated infrastructure, not proof of ownership." |
| "That IP is not a C2." | "It is not in the current beacon blocklist, which covers Cobalt Strike beacons in a recent window only." |

---

## 9. Corpus recency and drift

The corpus is ingested nightly and derived layers are rebuilt on their own clocks. Nothing
here is real-time.

| Layer | Refresh | Consequence |
|---|---|---|
| `threats` | Nightly ingest | Counts drift within a day; never quote a total as fixed. |
| `threats.created_at` | Date-only, midnight UTC | It is a publication date, not an incident timestamp. Do not derive hours-level timing. |
| Similarity / centrality / campaigns | Nightly staged graph pipeline | Staged: a partial rebuild can leave a stage at the previous generation. |
| `hunt` index | Materialized nightly from the live tables | `hunt` results lag `search_threats` / `get_threat` by up to a rebuild. |
| Correlation engines (`get_correlations`) | Nightly | Each engine ages independently. |
| Community OSINT | Harness-driven, budgeted at 168h | "Stale" here is routine operator cadence, not an outage. |
| Attribution research | Manual/agent lane, budgeted at 168h | Backlog grows between runs by design. |

`get_engine_status` is the authority when freshness matters. Its per-engine `status` is
`ok` / `stale` / `empty` / `error` / `backlog`, with `stale_after_hours` and
`freshness_basis` so you can see what it graded against. Call it before reasoning over
correlation output in an answer that will be acted on, and say so when a layer is `stale`.

`get_platform_stats` and `health` return live counts. Prefer them over any number written
in documentation, including this file.

| Tempted to say | Say instead |
|---|---|
| "The platform tracks 1,787 threats." | "As of this call, `get_platform_stats` reports N threats." |
| "The graph shows no link." (engine `stale`) | "The similarity layer last computed YYYY-MM-DD and is currently graded `stale`; the absence of a link may reflect the lag." |
| "This threat emerged on the 14th at 03:00." | "The threat record is dated the 14th (publication date, day resolution)." |

---

## 10. "No data" vs "wrong argument" vs "under-tier"

Three different failures produce empty-looking output. Conflating them is how a skill
reports "nothing exists" about something that does.

| Signal | What it is | Correct response |
|---|---|---|
| JSON-RPC `-32001` | Missing or invalid API key. `data.upgrade_url` included. | **Terminal.** Surface the upgrade path. Do not retry, do not try other tools. |
| JSON-RPC `-32002` | Authenticated but under-tier — tool calls need Purple/Gold (tier ≥ 3). `data.your_tier` / `data.required_tier` included. | **Terminal.** Same. Trying a "lighter" tool does not help; `get_started` and `health` are gated too. |
| `tools/list` empty or refused | `tools/list` is tier-gated. | Read the unauthenticated twin: `GET /mcp/catalog.json` or `/mcp.md`. Never conclude the server has no tools. |
| `matched: false` (`resolve_entity`, `get_malware_intelligence`, `get_tool_intelligence`, `get_campaign_intelligence`) | HTTP 200, name did not resolve. `detail_hint` suggests the fix. | "No canonical match for that name" — then retry via `resolve_entity`, not "the actor does not exist". |
| Empty rows from `search_threats` with a `category` filter | Almost always an invented enum value. | Call `list_threat_categories` and re-run. Never report zero until the enum is confirmed. |
| `hunt` error `Unknown hunt field: X` | Field not in the hunt grammar. | Call `hunt_schema` once and re-issue. This is a syntax failure, not an empty result. |
| `search_corpus_semantic` 503 | The retrieval index is unavailable. | "Semantic search is unavailable", never "nothing matched". |
| `community_error` on an OSINT payload | Upstream proxy failure. | "Community feed unavailable", never "no community data". |
| `... [truncated: response exceeded 90000 chars — narrow your query` | The response hit the 90,000-character cap and was cut. | The tail is **missing**, not absent from the data. Narrow (tighter filters, lower `limit`, a more specific tool) and re-run. Never total, rank, or say "that's all" over a truncated payload. |
| Genuinely empty result, no marker, valid arguments | Real absence in this corpus. | "The corpus holds no record of X" — scoped to this corpus, never to the world. |

Paging note that changes what "all" means: `search_threats`, `get_recent_threats`,
`get_detections` and `search_xscan_indicators` honour `offset`/`cursor` and return
`has_more` + `next_cursor`. `search_iocs`, `list_debriefs` and `search_actors` do **not**
honour offset — you cannot page them. `has_more: true` there means "more may exist and you
cannot reach it by paging"; narrow the filter instead, and say so rather than presenting a
first page as the complete set.

---

## 11. Phrases to never write

- "comprehensive" / "powerful" — banned outright.
- "confirmed" for anything not `verdict: "attributed"` with citations.
- "the community verified" — the community reports; it does not verify.
- "betweenness centrality" without "approximate" / "ego-bridge heuristic".
- "currently resolves" / "live lookup" about `get_ioc_dns` or `get_c2_dns_intel`.
- "no X exists" where the honest claim is "this corpus holds no X".
- "unrelated" where the honest claim is "the engine holds no edge".
- Any count taken from a truncated response.
- Any author name. The organization is Threadlinqs Intelligence; the team is Threadlinqs Team.

## 12. The one-line test

Before any assertion, ask: *if the reader acted on this and it was wrong, which field in the
payload already told me so?* If a field did — `state`, `note`, `caveat`, `level`,
`community_error`, `is_high_conf_low_signal`, `is_stale`, `matched`, `has_more`,
`freshness_basis`, the truncation marker — put it in the sentence.

---

# TLQL — the `hunt` query grammar

`hunt` runs a deterministic SIEM-style query over `hunt_index`, a pre-joined observation
table (~106k rows). One row = one **observation**, not one threat: a threat with 4 tools
and 30 IOCs contributes 34 rows. Every count you report must account for that.

Tool names here are bare. Hosts may prefix them (`mcp__threadlinqs-intel__hunt`) — match by
suffix.

**Call `hunt_schema` once per session before your first `hunt`.** It returns the field list
derived from the server's own field map (so it cannot go stale), the display columns, and
`rebuilt_at` / `row_count` for index freshness. The index is materialized by the nightly
cron — it is as fresh as the last rebuild, not live.

Both tools need tier ≥ 3 (Purple/Gold), like every tool call. `-32001` = missing/invalid
key, `-32002` = authenticated but under-tier. Both terminal.

---

## Query shape

```
<filter expression> [ | stats <func> [by <field>[, <field>[, <field>[, <field>]]]] ]
```

Without a `| stats` pipe you get matching observation **rows**. With one you get
aggregated **groups**. The split happens on the first `| stats` (case-insensitive);
anything before it is the filter.

| Limit | Value | Enforced where |
|---|---|---|
| Query string | 2000 chars | `query` arg |
| Leaf terms per query | 48 | parser (`Query too complex (max 48 terms)`) |
| Values per `IN (…)` | 100 | parser |
| Value length | 200 chars (silently truncated) | compiler |
| `by` fields | 4 (extras dropped) | stats parser |
| `limit` arg | default 50, clamped to 100 | MCP layer |
| Response | 90,000 chars, then a `... [truncated: response exceeded 90000 chars — narrow your query` marker | MCP layer |

`limit` caps **rows** in row mode and **groups** in `stats … by` mode (the tool description
says it is ignored in stats mode — that is true only for a bare `| stats count` with no
`by`). Rows are ordered `ts DESC`; groups are ordered `count DESC`.

## Fields (37 names, aliases share a column)

| Field name(s) | Column | Case | Scoped | Multi | Notes |
|---|---|---|---|---|---|
| `actor` | `actor_lc` | ci | | | Attributed actor, denormalized onto every row of the threat |
| `actor_uuid` | `actor_uuid` | exact | | | MISP UUID |
| `tool`, `malware`, `arsenal` | `tool_lc` | ci | ✅ | | **One column.** `tool=` and `malware=` are the same filter — separate them with `type = tool` / `type = malware` |
| `ioc`, `ioc_value` | `ioc_value` | ci | | | Raw indicator |
| `ioc_type` | `ioc_type` | ci | | | Values come from the corpus — discover with `\| stats count by ioc_type`, never guess |
| `cve` | `cve` | UPPER | ✅ | | |
| `mitre`, `technique` | `mitre_technique` | UPPER | ✅ | | Technique **id** (`T1059.001`), not the name |
| `tactic` | `mitre_tactic` | ci | ✅ | | |
| `nation`, `nationstate`, `country` | `nation_state` | ci | | | `country` is the **threat's** nation-state attribution, *not* the C2 beacon's geo column |
| `severity` | `severity` | ci | | | |
| `category` | `category` | ci | | | Use `list_threat_categories` for the vocabulary |
| `type`, `obs` | `obs_type` | ci | | | `tool malware ioc mitre cve attribution c2_beacon dns infra` |
| `source` | `source_table` | ci | | | `threat_tools threat_malware threat_iocs threat_mitre threats threat_attribution c2_beacon_snapshots dns_enrichment ioc_infrastructure_links` |
| `threat`, `threat_id` | `threat_id` | **exact** | | | Case-sensitive — `"TL-2026-0989"` uppercase, quoted |
| `sector`, `sectors`, `target_sector`, `target_sectors` | `target_sectors` | ci | | ✅ | Target sector |
| `region`, `regions`, `target_region`, `target_regions` | `target_regions` | ci | | ✅ | Target region |
| `seen`, `first_seen`, `ts` | `ts` | time | | | Epoch seconds |
| `c2_ip` | `c2_ip` | exact | | | tier-3 obs only |
| `watermark` | `c2_watermark` | exact | | | tier-3 obs only |
| `domain` | `dns_domain` | ci | | | tier-3 obs only |
| `dns_ip` | `dns_ip` | exact | | | tier-3 obs only |

Any other name is rejected with `Unknown hunt field: <name>`.

**Row output columns** (21): `obs_type threat_id actor tool ioc_type ioc_value cve
mitre_technique mitre_tactic nation_state target_sectors target_regions c2_ip c2_watermark
dns_domain dns_ip severity category confidence ts_raw label`.

### Scoped observables — why cross-type questions work

`tool` / `malware` / `arsenal` / `cve` / `mitre` / `technique` / `tactic` are **scoped**.
Those values live only on their own `obs_type` row (`tool_lc` is NULL on an IOC row), so a
same-row match could never be crossed with a group-by on a different observation type.
A scoped predicate is therefore re-compiled as a threat-level subquery:

```sql
threat_id IN (SELECT threat_id FROM hunt_index WHERE <predicate>)
```

It means **"threats that have this observable"**. That is exactly why
`malware = "qakbot" | stats count by ioc_type` works: the filter selects threats, the
group-by reads their IOC rows.

The unscoped fields are already denormalized onto every row of the threat
(`actor`, `nation`, `severity`, `category`, `sector`, `region`) or are per-row facts
(`ioc`, `ioc_type`, `c2_ip`, `domain`, …). `ioc` is **not** scoped, so
`ioc = "x" | stats count by tool` returns nothing — the IOC row has a NULL `tool` and
NULL groups are dropped. Put the scoped field on the filter side.

### Multi-value columns

`sector*` and `region*` hold several values in one delimited lowercased string.
There, `=` compiles to a **contains** match (`LIKE '%value%'`) and `!=` to `NOT LIKE`.
Consequence: short values over-match against the surrounding JSON text. Use the full
label — `sector = "healthcare"`, not `sector = "care"`.

## Operators

| Operator | Applies to | Compiles to |
|---|---|---|
| `=` | all | equality; **contains** on multi fields |
| `!=` | all | inequality; `NOT LIKE` on multi fields |
| `CONTAINS` | text | `LIKE '%value%'` |
| `LIKE` | text | `LIKE value` with `*` translated to `%` (`actor LIKE "lazarus*"`) |
| `IN (a, b, …)` | all | `IN` list, ≤ 100 values |
| `NOT IN (a, b, …)` | all | `NOT IN` list — **see the scoped-negation trap below** |
| `>` `>=` `<` `<=` | time fields | epoch comparison |
| `>` `>=` `<` `<=` | other fields | `CAST(col AS REAL)` comparison — meaningless on text; `severity > "high"` casts both sides to 0 |

There is **no `~` operator**. `hunt_schema`'s `operators` array lists `~ (contains)`; the
tokenizer rejects the character. Write `CONTAINS`.

### Boolean composition

`AND`, `OR`, `NOT`, and parentheses. Keywords are case-insensitive. Adjacent terms with no
keyword between them are **implicitly AND-ed**. Precedence is strictly left-to-right — the
parser folds left, so `a OR b AND c` parses as `(a OR b) AND c`. **Always parenthesize a
mixed AND/OR expression.**

`NOT` binds to the following term: `NOT (cve IN ("CVE-2024-3400"))`.

### The scoped-negation trap

For a scoped field, `NOT IN` is wrapped *inside* the subquery:

```
cve NOT IN ("CVE-2024-3400")     →  threats having SOME cve that is not CVE-2024-3400
NOT (cve IN ("CVE-2024-3400"))   →  threats having NONE of those CVEs
```

To exclude, negate the whole term. The same applies to `!=` on any scoped field.

### Bare quoted strings = fuzzy

A quoted string with no field is a fuzzy term across five columns —
`actor_lc`, `tool_lc`, `ioc_value`, `cve`, `label` — as `LIKE '%term%'`:

```
"cobalt strike"
"cobalt strike" AND severity = "critical"
```

An **unquoted** bare word is parsed as a field name and fails with `Unknown hunt field`.

### Quoting rule

Single or double quotes; `\` escapes the delimiter. Unquoted values are allowed only for
bare alphanumeric/underscore/dot words (`type = ioc`, `severity = critical`,
`mitre = T1059.001`). **Anything containing a hyphen or a space must be quoted** — the
tokenizer splits `CVE-2024-3400` into an identifier and a negative number and the query
dies with `Trailing token`. Safest habit: quote every value.

`//` line comments and `/* */` block comments are stripped. Only `| stats` is a supported
pipe — any other `|` in the filter is silently discarded (and a following `search` word
with it), so it will not do what an SPL habit expects.

### Relative time

```
seen > "7d"     within the last 7 days
seen > "24h"    last 24 hours
seen < "365d"   older than a year
seen > 1750000000   bare epoch seconds also accepted
```

Units are `s` `m` `h` `d` only — no `w`, no `y`; use `"30d"`, `"90d"`, `"365d"`.
The relative form **must be quoted** (`seen > 7d` parses `7` as a number and `d` as a
field name). A relative value resolves to `now − N`, so `>` means "more recent than".

Honesty: for most observation types `ts` is derived from `threats.created_at` — the date
the threat entered the corpus (date-only, midnight UTC), not when the indicator was first
seen in the wild. Only `c2_beacon`, `dns` and `infra` rows carry a real first-seen /
discovered-at timestamp.

## `| stats`

```
| stats count
| stats count by <field>
| stats count_distinct(<field>) by <field>[, <field>…]
| stats dc(threat) by actor          // dc is an alias for count_distinct
```

| Form | Meaning |
|---|---|
| `count` | `COUNT(*)` — number of **observation rows** |
| `count_distinct(threat)` / `dc(threat)` | distinct threats — what you almost always want |
| `count_distinct` with no argument | defaults to distinct `threat_id` |

`by` takes 1–4 comma-separated fields and produces a pivot table. Grouping on a time field
buckets by **day** (`date(ts,'unixepoch')`). Rows whose group column is NULL or blank are
dropped — otherwise a single empty-keyed group would swallow the whole count.

## When `hunt`, when `search_threats`

| Use `hunt` | Use `search_threats` |
|---|---|
| "how many / which is most common" — anything that ends in a number or a ranking | "show me the threats about X" — you want readable summary rows |
| Crossing observation types (tool → IOC type, technique → nation) | Free-text over title/summary/description |
| You would otherwise fire N `search_threats` calls and count the rows yourself | You need pagination through a result set (`hunt` has no cursor) |
| Grouping by actor / nation / sector / region / day | You want the fields `get_threat` would then expand |

`hunt` returns observations, not threat cards. Once a hunt names the threats you care
about, pivot with `bulk_get_threats` (≤ 20 ids) or `get_threat_hunting_bundle`.
Resolve aliases with `resolve_entity` **before** hunting on a name — `actor = "fancy bear"`
matches the literal string, not APT28.

---

## Worked queries

**Anything in the corpus mentioning Cobalt Strike, any observation type**
```
"cobalt strike"
```

**Threats that use Cobalt Strike as tooling**
```
tool = "cobalt strike"
```

**Every IOC recorded for one threat**
```
threat = "TL-2026-0989" AND type = ioc
```

**Critical healthcare threats from the last 30 days**
```
severity = "critical" AND sector = "healthcare" AND seen > "30d"
```

**Actor spelling unknown — prefix match**
```
actor LIKE "lazarus*"
```

**North-American or European targets attributed to a nation-state, excluding one CVE**
```
(region = "north america" OR region = "europe") AND nation != "" AND NOT (cve IN ("CVE-2024-3400"))
```

**How many IOC observations at each severity**
```
type = ioc | stats count by severity
```

**Which actors drive the critical corpus — distinct threats, not observation rows**
```
severity = "critical" | stats count_distinct(threat) by actor
```

**Daily volume of new threats over the last quarter**
```
seen > "90d" | stats count_distinct(threat) by seen
```

**Two-dimensional pivot: actor × region inside healthcare**
```
sector = "healthcare" | stats count_distinct(threat) by actor, region
```

**Cross-type — what kinds of indicator do QakBot threats carry?**
(`malware` is scoped, so the filter selects threats and the group-by reads their IOC rows)
```
malware = "qakbot" | stats count by ioc_type
```

**Cross-type — which nation-states use PowerShell execution (T1059.001)?**
```
mitre = "T1059.001" | stats count_distinct(threat) by nation
```

**Cross-type — Cobalt Strike threats with an observed beacon, grouped by watermark**
```
tool = "cobalt strike" AND type = c2_beacon | stats count by watermark
```

**Coverage question — which threat categories exercise the Execution tactic?**
```
tactic = "execution" | stats count_distinct(threat) by category
```

**Ransomware volume by nation and month-scale day bucket, top 100 groups**
```
category = "RANSOMWARE" AND seen > "180d" | stats count_distinct(threat) by nation, seen
```
(with `limit: 100` — otherwise you see only the top 50 groups)

**Multi-tool OR set, excluding a sector, aggregated**
```
(tool = "sliver" OR tool = "brute ratel" OR tool = "cobalt strike") AND NOT (sector = "government") | stats count_distinct(threat) by tool
```

## Failure modes and what they mean

| Error / symptom | Cause |
|---|---|
| `Unknown hunt field: X` | Not in the field table — or an unquoted bare word used as a fuzzy term |
| `Trailing token` | Unquoted value containing a hyphen (`CVE-2024-3400`, `TL-2026-0989`) |
| `Expected operator after <field>` | Field with no comparison |
| `Query too complex (max 48 terms)` | > 48 leaf terms — aggregate instead of enumerating |
| `IN list exceeds 100 values` | Split the query or filter differently |
| `Unsupported stats pipe` | Only `count` / `count_distinct` / `dc`, optional `by` with ≤ 4 fields |
| Empty result, filter looks right | Group-by field is NULL on the filtered rows — the filter field is unscoped (see *Scoped observables*) |
| One group with a blank key holding everything | Older behaviour; blank groups are now dropped — re-run and check the filter is scoped |
| `... [truncated: response exceeded 90000 chars` | Add predicates, lower `limit`, or switch to `| stats` |
| `tier_gated_included: false` in the response | The caller was below tier 3, so `c2_beacon` / `dns` / `infra` rows were excluded. MCP callers are tier ≥ 3 by definition, so this should read `true` |

If a query returns nothing, relax **one** predicate at a time rather than rewriting it —
that identifies which term is empty. Report the exact TLQL you ran alongside the answer.

---

# Recipes — end-to-end playbooks

Ordered playbooks for the Threadlinqs Intelligence MCP server. Each one gives the trigger,
the call sequence with real argument names, what to do when a step comes back empty, and how
to write the result up honestly.

The server also ships these as MCP prompts (call `prompts/list` to see the current set —
`triage_cve`, `hunt_ioc`, `build_c2_blocklist`, …). Most hosts never surface prompts, so every
recipe below is written to be followed without them. Where a recipe mirrors a server prompt,
the prompt name is given so the two surfaces stay in step.

## Before any recipe

| Rule | Detail |
|---|---|
| Names | Tool names are written bare. Hosts may prefix them (`mcp__threadlinqs-intel__get_threat`) — match by suffix. |
| Tier | Every tool call needs Purple/Gold. `-32001` = missing/invalid key, `-32002` = authenticated but under-tier. **Both are terminal.** Surface the upgrade path; do not retry with a different tool. |
| Discovery | `tools/list` is tier-gated. Unauthenticated twins: `GET /mcp/catalog.json` and `/mcp.md`. |
| Truncation | Responses cap at **90,000 characters** and end with `... [truncated: response exceeded 90000 chars — narrow your query`. If you see that string, narrow (filters, `limit`, lower `depth`) — do not stitch guesses over the cut. |
| Hunt | `hunt_schema` once per session before the first `hunt`. |
| Paging | Honored on `search_threats`, `get_recent_threats`, `get_detections`, `search_xscan_indicators` (`limit` + `offset`/`cursor`). **Not** honored on `search_iocs`, `list_debriefs`, `search_actors` — narrow instead. |
| Enums | Never guess a category. `list_threat_categories` first. Never pivot on a raw alias — `resolve_entity` first. |
| Bulk | `bulk_get_threats` / `bulk_get_cves` take up to 20 ids per call. Never loop `get_threat` over a known list. |

## R0 — Orient (start of a session)

**Trigger:** first question of a session, or you do not know what the corpus covers.
**Prompt:** `orient` · **Lane:** L0

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_started` | none | Catalog, categories, tiering. Cheap — no D1 round-trip — but still tier-gated. |
| 2 | `get_platform_stats` | none | Corpus size and shape. |
| 3 | `list_threat_categories` | none | The only valid vocabulary for `search_threats` `category`. |
| 4 | `hunt_schema` | none | TLQL field grammar. Required before `hunt`. |

**Empty/failed:** if step 1 returns `-32001`/`-32002`, stop — this is an access problem, not a data problem. Report the tier requirement and the upgrade path.
**Present:** state in one line what the server can and cannot answer, then aim the real question.

## R1 — Triage a CVE

**Trigger:** "is CVE-2024-3400 a problem for us?", a CVE id pasted from a scanner.
**Prompt:** `triage_cve` · **Lanes:** L5, L2

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_cve_intelligence` | `cve_id="CVE-2024-3400"` | One call: detail + linked threats + exploitation velocity + detections. |
| 2 | `get_cve` | `cve_id` | Only if step 1 is unavailable. |
| 3 | `get_threat` | `id="TL-…"` | Only for linked threats that need depth. Two or more ids → `bulk_get_threats`. |
| 4 | `get_cwe` | `cwe_id="CWE-77"` | The weakness class behind the CVE. |

**Anti-pattern:** `get_cve` + a velocity call + a detections call. That is what `get_cve_intelligence` composes.
**Empty:** no linked threats means the corpus has no reporting on exploitation of this CVE — say that, do not read it as "not exploited". Check `kev` and EPSS from the CVE record before concluding anything about urgency.
**Present:** severity, exploitation status (KEV / in-the-wild), corpus exposure, prioritized remediation. Distinguish "high CVSS" from "actually being exploited".

## R2 — Sweep the vulnerability feed

**Trigger:** "what should we patch this week?", a vendor-scoped question.
**Prompt:** `sweep_vulnerabilities` · **Lane:** L5

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `search_vulnerabilities` | `kev=true`, `epss_min=0.1`, `vendor="Fortinet"`, `window=7`, `severity="CRITICAL"`, `sort="priority"` | `window` is a **number of days**, not "7d". `severity` here is upper-case (`CRITICAL\|HIGH\|MEDIUM\|LOW`); `search_threats` severity is case-insensitive. `sort` ∈ `trending\|latest\|priority\|cvss\|epss`. |
| 2 | `bulk_get_cves` | `cve_ids=["CVE-…","CVE-…"]` | One call, ≤20. Never loop `get_cve`. |
| 3 | `get_cve_intelligence` | `cve_id` | Only the two or three that matter. |
| 4 | `get_cwe` | `cwe_id` | On the recurring weakness ids, to name the systemic class. |

**Empty:** drop the narrowest filter first (`epss_min`, then `window`), not all of them at once.
**Present:** a patch-priority list ordered by exploitation likelihood and corpus exposure. Call out explicitly where a high CVSS is *not* urgent.

## R3 — Profile an actor

**Trigger:** "what do we know about APT29 / Lazarus / this alias?"
**Prompt:** `profile_actor` · **Lanes:** L3, L7

| # | Call | Args | Note |
|---|---|---|---|
| 0 | `resolve_entity` | `name="fancy bear"`, `type="actor"` | Aliases live in a JSON column; the raw string often will not match. |
| 1 | `get_actor_intelligence` | `name="APT28"` | Composite: profile + cross-actor attribution correlations. |
| 2 | `get_actor` | `name` | Lean fallback if step 1 is unavailable. |
| 3 | `get_attribution_coverage` | `actor="APT28"` | How much of the attribution is *researched* vs labelled at ingest vs uncovered. |
| 4 | `get_mitre_coverage` | `tactic="execution"` (optional) | ATT&CK context for the technique rollup. |

**Empty:** if `resolve_entity` finds nothing, try `search_actors` — it returns the full roster and accepts `tool` / `malware` / `sector` filters. It is not paginated; narrow with a filter.
**Present:** TTPs, targeting, notable campaigns, cited by TL- id. State the researched-vs-stub split from step 3 rather than implying every labelled threat is an assessment.

## R4 — Hunt an IOC

**Trigger:** an IP, domain, hash or URL from an alert or a customer.
**Prompt:** `hunt_ioc` · **Lane:** L4

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_ioc_intelligence` | `value="185.…"` | The dossier: linked threats, actor attribution, related IOCs, enrichment. |
| 2 | `get_ioc_blast_radius` | `value`, `depth=2` | Sizes what else it reaches. `depth` 1=threats, 2=+techniques, 3=+actors & sibling IOCs. |
| 3 | `get_similar_threats` | `id="TL-…"`, `limit=10` | Only from a linked threat that matters. |

**Anti-pattern:** `search_iocs` → N × `get_threat` → N × `get_actor`. That is exactly what `get_ioc_intelligence` replaces. Use `search_iocs` only when you have a **substring**, not an exact value — and remember it does not page.
**Empty:** nothing linked means the corpus has not seen this indicator. Say so plainly; consider R11 (OSINT) for community corroboration, clearly labelled as community-sourced.
**Present:** which threats and actors it ties to, consensus confidence, recommended block or detect action.

## R5 — Answer an aggregate question (TLQL)

**Trigger:** "how many", "which is most common", "grouped by", or a question crossing two observation types ("threats using Cobalt Strike that also have DNS observations").
**Prompt:** `hunt_corpus` · **Lane:** L1

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `hunt_schema` | none | Once per session. You cannot write valid TLQL without it. |
| 2 | `hunt` | `query='tool = "cobalt strike" AND sector = "healthcare" \| stats count by nation'`, `limit=50` | `limit` caps rows (clamped to **100**) and in `\| stats` mode caps the number of **groups** returned. The tool description saying otherwise is wrong. |

**Anti-pattern:** N × `search_threats` and counting the rows yourself. That is slower, tier-caps differently, and the count will be wrong.
**Empty:** relax **one** predicate at a time and re-run; do not rewrite the query wholesale. If the field name was the problem, re-read the grammar from step 1 rather than guessing an alias.
**Present:** the answer, the exact TLQL you ran, and whether the result hit a row cap.

## R6 — Research an open question

**Trigger:** conceptual or paraphrased question where the exact keywords will not appear.
**Prompt:** `research_question` · **Lanes:** L1, L8, L2

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `search_corpus_semantic` | `query="ransomware crews pivoting to data-theft-only extortion"` | Vector + rerank. Rate-limited; it ranks, it does not count. |
| 2 | `resolve_entity` | `name`, `type` | On any named entity the results surface. |
| 3 | `get_entity_profile` | `node_type="actor"`, `node_id="APT28"` | `node_type` ∈ `threat\|technique\|actor\|ioc\|cve`. Best token-per-call ratio in the graph lane. |
| 4 | `get_threat_bundle` | `threat_id="TL-…"`, `include="full"` | `include="summary"` returns just the threat record. |

**Empty:** if semantic search returns nothing, fall back to `search_threats` with structured filters — the concept may exist under a category rather than a phrase.
**Present:** cite TL- ids. State what the corpus does **not** cover instead of filling the gap from general knowledge. If the question turned out to be aggregate, switch to R5.

## R7 — Map a threat's detections to ATT&CK

**Trigger:** "what do we detect for TL-2026-0042?"
**Prompt:** `map_detections_to_mitre` · **Lanes:** L2, L6, L7

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_threat` | `id="TL-2026-0042"` | Techniques as published. |
| 2 | `get_detections` | `threat_id="TL-2026-0042"`, `type="sigma"`, `limit=50` | `type` ∈ `spl\|kql\|sigma`. Pages via `offset`/`cursor`. |
| 3 | `get_threat_enrichment` | `id` | The enrichment layer the base record does not carry. |
| 4 | `get_mitre_technique` | `technique_id="T1059"` | For any technique you need to describe. |

**Empty:** no detections on a threat is itself the finding — it is detection debt, not an error.
**Present:** a technique → rule table, then the uncovered techniques ranked by what an attacker reaches for first.

## R8 — Review detection gaps

**Trigger:** "where should the detection team spend next sprint?"
**Prompt:** `review_detection_gaps` · **Lanes:** L6, L7

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_mitre_gap_analysis` | `tactic="persistence"` (optional), `limit=20` | Debt score = threat exposure + KEV count + EPSS. |
| 2 | `get_mitre_coverage` | `tactic` (optional) | Gives the gaps a denominator. |
| 3 | `get_technique_rules` | `limit=50` | Technique **pairs** with support/confidence/lift — what travels together. Also stops you recommending a rule that already exists. |

**Empty:** a tactic with no gaps is a real answer. Report coverage rather than widening until something looks broken.
**Present:** a ranked "write these next" list; each entry names the technique, why it scores high, and the example threats driving the score.

## R9 — Write a detection

**Trigger:** "give me a Sigma rule for T1059 / for this threat."
**Prompt:** `write_detection` · **Lanes:** L6, L7

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `search_detections` | `query="powershell encoded command"`, `type="sigma"`, `limit=25` | Ground in what exists. Pages via `offset` (limit max 200). |
| 2 | `get_detection_detail` | `detection_id="…"` | Full logic of the closest match. |
| 3 | `get_mitre_technique` | `technique_id="T1059"` | Procedures. |
| 4 | `get_threat` + `get_detections` | `id` / `threat_id` | When the request is threat-scoped. |
| 5 | `export_detection` | `detection_id`, `format="sigma"` | `format` ∈ `spl\|kql\|sigma\|json`. Deployable form. |

**Empty:** no near neighbour means you are writing from scratch — say so.
**Present:** the rule, plus the log sources it assumes. If a field you need is not evidenced anywhere in the returned data, name the gap rather than inventing a telemetry source.

## R10 — Plan a purple-team exercise

**Trigger:** "can we exercise TL-2026-0042?"
**Prompt:** `plan_purple_team` · **Lanes:** L6, L2

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_threat_simulations` | `threat_id="TL-2026-0042"` | Step-by-step emulation commands by platform. |
| 2 | `list_simulations` | `limit=50` | Only if step 1 is empty — find the closest analogue. |
| 3 | `get_threat_transcripts` | `threat_id` | Observed execution detail from the research agents. |
| 4 | `get_detections` | `threat_id` | The rules that *should* fire. |

**Present:** pair each simulation step with the detection expected to trip and the technique it exercises, so a miss is attributable. These are live attack commands — state that they belong in an authorized lab or an approved engagement, never against production without sign-off.

## R11 — Review an attribution

**Trigger:** a threat names an actor and someone is about to act on it.
**Prompt:** `review_attribution` · **Lane:** L3

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_attribution_evidence` | `threat_id="TL-2026-0042"` | Returns `state` — **researched assessment vs unresearched intake stub** — plus verdict, confidence, scope, evidence chain, suspected alternatives. |
| 2 | `get_threat` | `id` | The claim as published. |
| 3 | `get_actor` | `name` | If an actor is named. |
| 4 | `get_attribution_coverage` | `actor` | The wider evidence base for that actor. |

**Honesty rule (non-negotiable):** a pending stub is **UNATTRIBUTED**. Never restate a stub as a finding, and never let `threats.threat_actor` alone stand in for an assessment.
**Present:** a verdict — evidence-backed, thin, or unassessed — naming the specific evidence carrying it.

## R12 — Explain a correlation

**Trigger:** "why are these two linked?", two TL- ids side by side.
**Prompt:** `explain_link` · **Lane:** L8

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `explain_correlation` | `threat_a="TL-…"`, `threat_b="TL-…"` | Per-channel decomposition, dominant channel, shared artifacts, staleness flags. |
| 2 | `get_correlation_path` | `from="TL-…"`, `to="TL-…"`, `max_hops=6` | Shortest evidence path. 6 is the schema default (range 1-8); lower it to make the search stricter, raise it only after a `found:false`. |
| 3 | `get_threat` | `id` (each end) | Context. |

**Empty:** `found:false` carries a reason (no edges vs different components). Report the reason — it is not the same as "unrelated but probably connected".
**Present:** same campaign / shared infrastructure / merely a shared technique / unrelated, and which signal carries the claim. Never upgrade a weak shared-technique edge into attribution.

## R13 — Map a campaign

**Trigger:** a campaign or cluster name, or "what else is part of this?"
**Prompt:** `map_campaign` · **Lanes:** L9, L8

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_campaign_intelligence` | `name="Snowflake campaign"` | Looks up a **named** campaign mentioned in threat text. |
| 2 | `get_graph_campaigns` | `limit=20` | Engine-assembled clusters (components + label propagation). Use when the name does not resolve. |
| 3 | `get_correlation_subgraph` | `seed_type="threat"`, `seed_id="TL-…"`, `depth=1`, `limit_nodes=40` | `seed_type` ∈ `threat\|technique\|actor\|ioc\|cve` — **there is no campaign seed**; seed from a member threat or the actor. |
| 4 | `get_pivotal_entities` | `node_type="ioc"`, `limit=20` | Hubs and bridges worth monitoring. |
| 5 | `get_correlations` | `engine="adversary-infra"` | `engine` ∈ `overview\|mitre-heatmap\|adversary-infra\|ioc-consensus\|cve-velocity\|attribution\|detection-debt\|enrichment`. |

**Anti-pattern:** `get_correlation_subgraph` at `depth=3`. Each hop multiplies nodes and blows the 90k cap. Start at 1 and expand only where the topology is the point — for a single entity `get_entity_profile` is cheaper and usually what you wanted.
**Honesty rule:** `get_pivotal_entities` betweenness is an **ego-bridge heuristic**, not exact Brandes — its own payload says so. Do not present it as exact betweenness.
**Present:** member threats, shared infrastructure, pivotal entities, attribution confidence.

## R14 — Pivot on infrastructure

**Trigger:** an IP/domain from a beacon, or "what else does this threat's infrastructure touch?"
**Prompt:** `pivot_infrastructure` · **Lane:** L4

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_ioc_dns` | `value="1.2.3.4"` | **Stored enrichment, not a live lookup.** Date it in the write-up. |
| 2 | `get_ioc_blast_radius` | `value`, `depth=2` | What the indicator reaches. |
| 3 | `get_infrastructure_pivots` | `threat_id="TL-…"` | Cross-threat shared IPs/domains and DNS overlaps. |
| 4 | `get_c2_dns_intel` | `fidelity="dedicated"`, `compromised=false`, `limit=50` | `fidelity` ∈ `dedicated\|mixed\|shared`. Separates adversary-owned from shared hosting. |

**Empty:** no DNS enrichment stored ≠ no DNS history. Say the platform has none cached.
**Present:** high-confidence vs incidental pivots. Shared hosting and CDN ranges are **not** adversary infrastructure — call that out instead of listing it as a finding.

## R15 — Build a C2 blocklist

**Trigger:** "give me something to drop into the firewall."
**Lane:** L4

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `generate_c2_blocklist` | **none** | Takes no arguments at all. Firewall-ready active C2 IPs. |
| 2 | `get_c2` | `view="operators"`, `limit=50` | `view` ∈ `beacons\|configs\|operators\|watermarks\|correlations\|timeline\|stats`. Use `beacons` for underlying detail. |
| 3 | `get_c2_dns_intel` | `compromised=false` | Folds in unmasked C2 domains, excluding victim hosts. |
| 4 | `search_iocs` | `value="…"`, `type="network"`, `limit=100` | Only for indicators outside the live C2 feed. Does not page. |

**Present:** one deduplicated list of IPs and domains with the threats or operator clusters they map to, **and the age of the data** — a stale entry must never be blocked blind.

## R16 — OSINT sweep

**Trigger:** "is anyone else seeing this?", coverage/lead-time questions.
**Prompt:** `osint_sweep` · **Lane:** L10

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_osint` | `threat_id="TL-…"` *or* `ioc_value="1.2.3.4"` | Per-threat corroboration block, or the community lookup for an indicator. |
| 2 | `get_osint_trends` | none | Trending tags, novelty, corpus corroboration, `early_warning` lead-time distribution. |
| 3 | `get_community_campaigns` | `limit=20` | Community clusters. |
| 4 | `search_xscan_indicators` | `tag="asyncrat"`, `type="domain"`, `asn="AS14061"`, `country="RU"`, `min_threats=1`, `limit=50`, `offset=0` | `type` ∈ `ip\|domain\|url\|sha256\|md5`. There is deliberately **no family filter** — use `tag`. Pages. |

**Honesty rules:** OSINT is community-sourced — corroborating, never authoritative. `get_community_campaigns` cluster labels are **upstream AI output**, not a Threadlinqs assessment. Keep both labelled as such; never merge them into corpus findings silently.
**Present:** what the community knows that the corpus does not, and vice versa.

## R17 — Malware / tool dossier

**Trigger:** a family or offensive-tool name.
**Prompt:** `malware_dossier` · **Lanes:** L9, L8

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `resolve_entity` | `name="Cobalt Strike"`, `type="tool"` | Learn *what kind* of entity it is. `type` ∈ `actor\|malware\|tool\|sector\|region\|technique\|campaign`. |
| 2 | `get_malware_intelligence` *or* `get_tool_intelligence` | `name` | Try the other if the first is empty — the split is not always obvious. |
| 3 | `get_entity_profile` | `node_type`, `node_id` | Graph context. |
| 4 | `search_actors` | `tool="Cobalt Strike"` | Which actors use it. |

**Present:** actors and campaigns using it, the threats it appears in, observed capabilities, and the detections that cover it. A commodity tool shared by many actors is not attribution.

## R18 — Predict the next move

**Trigger:** "we saw T1566 — what comes next?"
**Prompt:** `predict_next_move` · **Lane:** L7

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `predict_mitre_transitions` | `technique_id="T1566"`, `direction="forward"`, `top_n=10` | `direction` ∈ `forward\|reverse`. Sequence. |
| 2 | `get_mitre_technique` | `technique_id` | What the technique actually does. |
| 3 | `get_technique_rules` | `limit=50` | Co-occurrence (what appears alongside), the complement of step 1. |

**Present:** the progression with the probability **and observation count** behind each step. A prediction with a low observation count is a weak signal — say so rather than stating it flatly.

## R19 — Assess exposure to a CVE, actor or technique

**Trigger:** "are we exposed to X?" where X is a CVE, an actor, or a technique.
**Prompt:** `assess_exposure` · **Lanes:** L5, L3, L7, L6

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `get_cve` / `get_actor` / `get_mitre_technique` | `cve_id` / `name` / `technique_id` | Whichever was provided. |
| 2 | `search_threats` | `cve="CVE-…"` or `threat_actor="APT28"` or `mitre_technique="T1059"`, `limit=50` | All filters AND-combine. Pages via `offset`/`cursor`. |
| 3 | `get_detections` | `threat_id` | Per linked threat that matters. |

**Present:** covered vs uncovered, and the recommended actions. Exposure the corpus cannot see is not exposure that does not exist — bound the claim to the corpus.

## R20 — Daily brief / period review

**Trigger:** "what happened today?", "brief me on last week."
**Prompts:** `daily_brief`, `period_review` · **Lane:** L11

Daily:

| # | Call | Args |
|---|---|---|
| 1 | `get_daily_intel_bundle` | `date="2026-08-01"` (omit for latest), `top_n=5` |
| 2 | `get_threat_level` | none |
| 3 | `get_daily_theme` | none |
| 4 | `get_landscape_briefing` | none |
| 5 | `get_recent_threats` | `limit=10`, `offset=0` |

Period: `get_latest_debrief` (or `get_debrief` with `date="YYYY-MM-DD"`) → `list_debriefs`
with `limit=30` for surrounding context. `list_debriefs` does **not** page — raise `limit`.

**Empty:** a missing debrief date means nothing was published that day; check `list_debriefs` for the real dates rather than inventing a gap narrative.
**Present:** what changed — new threats, escalations, what a defender should do differently this week versus last. Anchor every claim to a TL- id or a debrief date.

## R21 — Bulk enrich a list of ids

**Trigger:** a pasted list of TL- ids or CVE ids.
**Prompt:** `bulk_enrich` · **Lanes:** L2, L5

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `bulk_get_threats` | `threat_ids=["TL-2026-0042","TL-2026-0099"]` | ≤20 per call. Returns `{threats, missing, count}`. |
| 2 | `bulk_get_cves` | `cve_ids=["CVE-2024-3400"]` | ≤20 per call. |
| 3 | `get_threat_bundle` | `threat_id`, `include="full"` | Only the entries that need depth. |
| 4 | `get_threat_hunting_bundle` | `threat_id` | When the depth needed is *hunting* — detail + similar threats + simulations + infra pivots in one call. |

**Anti-pattern:** looping `get_threat`, or chaining `get_threat` → `get_similar_threats` → `get_threat_simulations` → `get_infrastructure_pivots`. The second is exactly `get_threat_hunting_bundle`.
**Present:** a table keyed by id, and list the `missing` ids explicitly rather than dropping them.

## R22 — Export for downstream tooling

**Trigger:** "get this into our TIP / SIEM / Navigator."
**Prompt:** `export_for_tooling` · **Lane:** L12

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `export_stix` | `threat_id="TL-…"` *or* `actor="APT29"` *or* `cve_id="CVE-…"`, `include_osint=false` | At least one selector. `include_osint=true` adds community `sighting` objects — only if the consumer wants community-sourced data mixed in. |
| 2 | `export_attack_navigator` | `actor="APT29"` *or* `all=true` | Enterprise-attack layer, capped at 600 techniques. |
| 3 | `search_detections` → `export_detection` | `query` → `detection_id`, `format="spl"` | `format` ∈ `spl\|kql\|sigma\|json`. |

**Present:** the artifacts with a one-line note on what each is for and which tool ingests it. If `include_osint=true`, say so — the bundle then carries community-sourced sightings.

## R23 — Platform status (freshness caveats)

**Trigger:** before you trust any correlation answer, or when a result looks off.
**Prompt:** `platform_status` · **Lane:** L0

| # | Call | Args | Note |
|---|---|---|---|
| 1 | `health` | none | Liveness. |
| 2 | `get_engine_status` | none | Per-engine row counts, last-compute times, staged graph-pipeline progress, `degraded` flag, recent failures, latest held-out AUC. |
| 3 | `get_enrichment_overview` | none | Enrichment completeness and coverage gaps. |
| 4 | `get_platform_stats` | none | Corpus size. |
| 5 | `get_changelog` / `get_roadmap` | `limit=10` / none | What shipped, what is coming. |

**Present:** whether the data is currently trustworthy for analysis, and name anything that would make a result misleading right now. A stale engine is a caveat on **every** correlation answer you give in that session.

## Failure handling, in order

1. **`-32001` / `-32002`** — terminal. Report the tier requirement and the upgrade path. Do not
   probe other tools; the gate is checked before tool existence, so a different name proves nothing.
2. **Truncation marker** — narrow the query (tighter filters, lower `limit`, lower `depth`,
   `include="summary"`), then re-run. Never summarize across a truncation boundary as if complete.
3. **Empty result** — relax one predicate at a time. Empty is often the answer; report it.
4. **Unknown enum / no match** — go back to the vocabulary tool (`list_threat_categories`,
   `hunt_schema`, `resolve_entity`) rather than guessing another spelling.
5. **Composite unavailable** — fall back to the named single-purpose tool in the recipe, and say
   the answer was assembled from parts.

---

# Setup — installation and access

Two transports reach the same 81 tools, the same resources, and the same prompt set. Pick one;
running both at once just gives the host duplicate tool names.

| | Local stdio | Remote Streamable-HTTP |
|---|---|---|
| Endpoint | `npx -y intelthreadlinqs-mcp` (subprocess) | `https://intel.threadlinqs.com/mcp` |
| Auth | `THREADLINQS_API_KEY=tl_…` env var | `Authorization: Bearer tl_…`, or OAuth 2.1 + PKCE |
| Needs Node ≥ 18 | yes | no |
| Best for | Claude Code, Cursor, VS Code, any stdio host | hosts with a remote-connector UI; zero install |
| Optional override | `THREADLINQS_API_URL` (defaults to `https://intel.threadlinqs.com`) | — |

The stdio package is a thin proxy: it forwards every call to the same worker endpoint. Tool
names, schemas and behaviour are identical across transports, so nothing else in this skill
changes based on which one you install.

**Tool names in this skill are bare** (`get_threat`, `hunt`). Hosts commonly prefix them —
`mcp__threadlinqs-intel__get_threat`, `mcp__claude_ai_threadlinqs__get_threat`. Match by
suffix after the last `__`; the prefix is the host's server label, not part of the name.

---

## 1. Get a key first

Nothing below works without one. **Tool CALLS require tier ≥ 3 (Purple or Gold).**

1. Sign up at <https://intel.threadlinqs.com>.
2. Verify the email — an unverified account is tier 0 and every MCP method is refused.
3. Go to **Profile → API Key** and create a key. It starts with `tl_`.
4. **Signing up alone does not grant MCP access.** A new account lands on Blue (tier 1);
   tool calls need tier >= 3, so they return `-32002` until you upgrade. The 7-day Purple
   trial is offered at checkout on the pricing page, not on signup.

| Tier | Name | MCP tool calls |
|---|---|---|
| 0 | Unverified | no |
| 1 | Blue (free) | no |
| 2 | Red | no |
| 3 | Purple ($11.99/mo) | yes — all 81 tools |
| 4 | Gold (team) | yes — all 81 tools |

The gate is enforced server-side on every call. It is not a client setting, and no
configuration flag bypasses it. When the trial lapses, in-flight sessions start returning
`-32002` on the next call.

Treat the key as a secret: it is a bearer credential with your account's full read scope. Put
it in an env var or the host's secret store, never in a file you commit.

---

## 2. Host configuration

### Claude Code — stdio

```bash
claude mcp add threadlinqs-intel \
  -e THREADLINQS_API_KEY=tl_your_key_here \
  -- npx -y intelthreadlinqs-mcp
```

Add `-s user` to register it for every project instead of the current one.

### Claude Code — remote

```bash
claude mcp add --transport http threadlinqs-intel \
  https://intel.threadlinqs.com/mcp \
  --header "Authorization: Bearer tl_your_key_here"
```

Omit `--header` to let the host run the OAuth 2.1 + PKCE flow instead (section 4).

### Claude Desktop

`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
`%APPDATA%\Claude\claude_desktop_config.json` (Windows):

```json
{
  "mcpServers": {
    "threadlinqs-intel": {
      "command": "npx",
      "args": ["-y", "intelthreadlinqs-mcp"],
      "env": {
        "THREADLINQS_API_KEY": "tl_your_key_here"
      }
    }
  }
}
```

Restart Claude Desktop after editing. On Windows, `command` may need to be `npx.cmd`.

### Cursor

`.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):

```json
{
  "mcpServers": {
    "threadlinqs-intel": {
      "command": "npx",
      "args": ["-y", "intelthreadlinqs-mcp"],
      "env": {
        "THREADLINQS_API_KEY": "tl_your_key_here"
      }
    }
  }
}
```

### VS Code

`.vscode/mcp.json` — note the key is `servers`, not `mcpServers`, and the entry declares its
`type`:

```json
{
  "servers": {
    "threadlinqs-intel": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "intelthreadlinqs-mcp"],
      "env": {
        "THREADLINQS_API_KEY": "${env:THREADLINQS_API_KEY}"
      }
    }
  }
}
```

Using `${env:…}` keeps the key out of a file that may be committed.

### Generic stdio host

Any client that can launch a subprocess and speak MCP over stdio:

```
command: npx
args:    ["-y", "intelthreadlinqs-mcp"]
env:     THREADLINQS_API_KEY=tl_your_key_here
         THREADLINQS_API_URL=https://intel.threadlinqs.com   # optional
```

`THREADLINQS_API_URL` must be `https://` or `http://localhost` — the server refuses anything
else at startup. It exists for pointing at a dev worker; leave it unset in normal use.

Startup behaviour worth knowing: with no key, or a key below Purple, the server still starts
and still lists its tools. It writes a warning to stderr and every call fails. A host that
shows the tools but errors on use is in exactly this state.

### Alternatives

| Package | What it is | When to use it |
|---|---|---|
| `.mcpb` bundle | Desktop extension — bundles the server, prompts for the key in a dialog, stores it in the OS keychain | Claude Desktop users who do not want to hand-edit JSON |
| Claude Code plugin | `plugin/.claude-plugin/plugin.json` + `plugin/.mcp.json`, declares the same stdio server; reads `THREADLINQS_API_KEY` from the environment | `/plugin marketplace add threadlinqs-cmd/intelthreadlinqs-mcp` then `/plugin install threadlinqs-intelligence`, or `claude --plugin-dir <repo>/mcp-server/plugin` for local work |

Both are the same server with the same gate. Neither changes tiering.

---

## 3. Verify the install

Two cheap calls, in order:

1. `get_started` — returns the server info, tiering block, tool catalog and category map
   **inline**, with no downstream database round-trip. It is the cheapest tool on the server,
   but it is still tier-gated: if it returns `-32001`/`-32002`, the key is the problem, not the
   transport.
2. `health` — round-trips the backend. A pass here means the data path works.

Then, before any aggregate work, call `hunt_schema` **once** to load the TLQL field grammar.
`hunt` queries written without it guess field names and return empty.

For the prompt playbooks, call `prompts/list` rather than assuming a count — the set is
actively growing and any number written down here goes stale.

---

## 4. Remote transport details

Endpoint: `POST https://intel.threadlinqs.com/mcp` (Streamable HTTP, JSON-RPC 2.0).
Protocol version `2025-11-25`; `2025-06-18`, `2025-03-26` and `2024-11-05` are also accepted.

**Static key.** Send `Authorization: Bearer tl_…`. Simplest option, and the one to use for
scripts and CI.

**OAuth 2.1 + PKCE.** For hosts with a connector UI. Discovery is standard:

| Document | URL |
|---|---|
| Protected-resource metadata (RFC 9728) | `/.well-known/oauth-protected-resource` (also `…/mcp`) |
| Authorization-server metadata (RFC 8414) | `/.well-known/oauth-authorization-server` (also `…/mcp`) |
| Authorize | `https://intel.threadlinqs.com/oauth/authorize` |
| Token | `https://intel.threadlinqs.com/oauth/token` |

Constraints the flow enforces:

- **PKCE `S256` is mandatory.** `plain` and a missing `code_challenge` are both rejected at
  `/oauth/authorize`.
- Scope is `mcp`. Grants are `authorization_code` and `refresh_token`.
- Token-endpoint auth is `none` (public client).
- Client identity uses a **client-ID metadata document** — there is no dynamic client
  registration endpoint. Hosts that only implement DCR should use the static Bearer key.
- The OAuth endpoints are rate-limited to 40 requests/min per IP.

An unauthenticated request that carries no `Authorization` header gets HTTP 401 plus a
`WWW-Authenticate: Bearer` header pointing at the protected-resource metadata — that is the
signal for a host to begin the flow.

Whichever path is used, the resulting identity is resolved to a live account tier on every
call. OAuth does not grant a different tier than the same account's `tl_` key.

---

## 5. What is readable without a key

`tools/list` **is tier-gated** — an unauthenticated caller gets `-32001`, not an empty list.
Some hosts render that as "this server has no tools". The unauthenticated twins exist for
exactly this case:

| Surface | Content |
|---|---|
| `GET https://intel.threadlinqs.com/mcp/catalog.json` | All 81 tools with full `inputSchema` + annotations, the prompt list, 14 resources, 3 resource templates, category map, server instructions |
| `GET https://intel.threadlinqs.com/mcp.md` | The same documentation as Markdown |
| `resources/read` on a `ui://…` URI | Renderer shells only — inline HTML/CSS/JS with zero threat content, served before the gate so widgets do not render blank |

Everything else — `tools/call`, `prompts/get`, and `resources/read` on a `threadlinqs://` URI
— requires Purple or Gold.

`catalog.json` is the authority on names, arguments and enums. When a tool name from a README,
a blog post or model memory disagrees with the catalog, the catalog is right.

---

## 6. Runtime limits that look like setup bugs

| Limit | Behaviour | What to do |
|---|---|---|
| 90,000 characters per response | The payload is cut and the string `... [truncated: response exceeded 90000 chars — narrow your query (add filters, lower limit, or use a more specific tool) and retry]` is appended | Detect that marker, then narrow: add filters, lower `limit`, or switch to a more specific tool. Do not parse the truncated JSON — it is cut mid-structure |
| Paging honored | `search_threats`, `get_recent_threats`, `get_detections`, `search_xscan_indicators` accept `offset`/`cursor` and return `has_more` + `next_cursor` | Page these when you genuinely need more rows |
| Paging **not** honored | `search_iocs`, `list_debriefs`, `search_actors` | Narrow the query instead — a second call with an offset returns the same rows |
| `hunt` without `hunt_schema` | Invented field names match nothing; result set is empty, not an error | Call `hunt_schema` once per session, first |

---

## 7. Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| JSON-RPC error `-32001` | No key, malformed key, revoked key, or the header never reached the server | Set `THREADLINQS_API_KEY` (stdio) or the `Authorization: Bearer tl_…` header (remote) and restart the host. **Terminal — do not retry other tools.** Surface the sign-up/upgrade path instead |
| JSON-RPC error `-32002` | Authenticated, but tier < 3 — free/Blue/Red account, or a lapsed Purple trial or subscription | Upgrade at <https://threadlinqs.com/#pricing>. The `data` block carries `your_tier` and `required_tier`. **Terminal — no other tool will succeed.** Do not probe the rest of the catalog |
| Query returns `results: []` with no error | A guessed enum value. Filters are matched literally, so an invalid `category` or `severity` silently matches nothing | Call `list_threat_categories` for the live category vocabulary; use `resolve_entity` before pivoting on an actor/malware/tool alias. Never invent enum values |
| Response ends in the truncation marker | Result exceeded 90,000 characters | Narrow — filters, lower `limit`, a more specific tool, or a `hunt … \| stats count by …` aggregate instead of pulling rows |
| Host shows an empty tool list | `tools/list` was refused (`-32001`), or the process is offline/sandboxed | Fix auth first. Since 8.1.0 the npm package falls back through authenticated `tools/list` → public `/mcp/catalog.json` → a **catalog snapshot bundled in the package**, so a network-isolated sandbox still lists the real tools. An older installed version returns an empty array here — pin `intelthreadlinqs-mcp@latest` |
| Tools list fine, every call errors | Server started without a valid Purple/Gold key — this is by design so registries can introspect | Check stderr for the startup warning, then fix the key |
| `spawn npx ENOENT` | Node is not on the host's PATH | Install Node ≥ 18; on Windows use `npx.cmd`; or use an absolute path to `node` with the `.mcpb` bundle |
| Server refuses to start, complains about the URL | `THREADLINQS_API_URL` is set to something that is neither `https://` nor `http://localhost` | Unset it — it defaults correctly |
| Duplicate tool names in the host | Both transports registered at once | Remove one connector |

---

## 8. Links

- Interactive docs: <https://intel.threadlinqs.com/mcp> (Markdown twin: `/mcp.md`)
- Machine-readable catalog: <https://intel.threadlinqs.com/mcp/catalog.json>
- Key + trial: <https://intel.threadlinqs.com/profile>
- Pricing: <https://threadlinqs.com/#pricing>
- Changelog: <https://intel.threadlinqs.com/changelog>

Maintained by the Threadlinqs Team, Threadlinqs Intelligence.

---

# Tool reference — all 81 tools

GENERATED FILE — do not edit by hand. Regenerate with `npm run build:skill`.
Source: `https://intel.threadlinqs.com/mcp/catalog.json`.

Arguments marked **\*** are required. Tool **calls** need a Purple or Gold key (tier ≥ 3);
introspection does not. Your host may prefix these names — match by suffix.


## L0 · Orient

Cheap situational calls. Do not open every session with all of these.

### `get_started`

Start here. Returns the Threadlinqs Intelligence tool catalog, categories, tiering, and usage guidance. No API call — read this before using other tools.

**Arguments:** _takes no arguments_

### `health`

Lightweight liveness probe: confirms the API is reachable and your key is valid, and returns platform counts + the latest debrief date.

**Arguments:** _takes no arguments_

### `get_platform_stats`

Get aggregate platform statistics: threat, detection, IOC, MITRE technique, and actor counts.

**Arguments:** _takes no arguments_

### `get_engine_status`

Is the intelligence pipeline healthy? Per-engine row counts, last-compute times and derived ok/stale/empty status; the nightly graph pipeline's staged progress, current stage and degraded flag; recent failures; and the latest held-out accuracy eval (AUC). Check this before reasoning over correlation output if freshness matters.

**Arguments:** _takes no arguments_

### `get_enrichment_overview`

Health and coverage of the enrichment LANES feeding the platform, one row each: cve_details, threat_iocs, detections, mitre_attack, c2_intel, dns_enrichment, tl_osint_scan, osint_blocklist_liveness. EPSS and KEV are NOT separate lanes - they are fields inside cve_details (whose hit_rate is the share of CVEs carrying both CVSS v3 and EPSS), and there is no IOC-reputation lane; the nearest signals are osint_blocklist_liveness and tl_osint_scan. Each lane's hit_rate has its OWN definition (they are not comparable to each other) and coverage_basis distinguishes 'measured_zero' from 'not_measured' - c2_intel really is 0% corpus coverage, because c2_correlations_v2 has no populated threat_id. This is a nightly snapshot: read as_of/stale_hours before treating it as current.

**Arguments:** _takes no arguments_

### `get_changelog`

Get the platform changelog (new threats, detections, features), strictly newest-first by date. Pass limit (default 20, max 100). NOTE: this is a hand-maintained release feed, not a deploy log — it stopped being written in Feb 2026, so the response carries latest_entry_date / stale_days / a warning. For what the platform actually looks like NOW use get_platform_stats (live corpus counts) and get_engine_status (pipeline state); do not answer "what changed recently" from this alone.

**Arguments:** `limit` _number_ — Max entries (default 20, max 100)

### `get_roadmap`

Get the Threadlinqs Intelligence platform roadmap: `completed`, `in_progress` and `ideas` (planned), with `counts`, `generated_at` and `as_of`. WARNING: this table is hand-maintained and has not been updated since Feb 2026 — the response carries stale_days and a warning, and several items still marked "planned" have since shipped. Do not report an item as unbuilt on this basis; confirm with get_started (live tool catalog), get_platform_stats or get_engine_status.

**Arguments:** _takes no arguments_


## L1 · Find

Four different search modes. They are NOT interchangeable — boolean-exact, vector-rerank, SIEM-aggregate, alias-canonicalise.

### `search_threats`

The deterministic threat-catalog query tool. Returns LEAN summary rows {id, title, severity, category, status, threat_actor, nation_state, affected_products, cves, cvss_score, summary, created} — call get_threat for full detail on a specific id. Combine a free-text `query` with any structured filters; ALL filters AND-combine and apply together (e.g. query="supply chain" + threat_actor="TeamPCP" + category="SUPPLY_CHAIN" returns only matching rows, with affected_products inline). Paginated: limit (default 20, max 100) + offset/cursor; result carries total, has_more, and next_cursor.

**Arguments:** `query` _string_ — Free-text term across title/summary/description/CVE/actor (optional; AND-combined with filters)<br>`severity` _string_ — critical | high | medium | low<br>`category` _string_ — e.g. SUPPLY_CHAIN, MALWARE, RANSOMWARE, VULNERABILITY, APT, PHISHING, ZERO_DAY (call list_threat_categories for the full vocabulary)<br>`status` _string_ — Threat status filter (e.g. active)<br>`threat_actor` _string_ — Filter by attributed actor name/alias (e.g. "TeamPCP", "APT29")<br>`nation_state` _string_ — Filter by nation-state (e.g. Russia, China, Iran)<br>`motivation` _string_ — Filter by motivation (e.g. financial, espionage)<br>`target_sector` _string_ — Filter by targeted sector (e.g. Healthcare, Finance)<br>`target_region` _string_ — Filter by targeted region (e.g. APAC, Europe)<br>`affected_product` _string_ — Filter by affected product/vendor (e.g. "npm", "Microsoft", "VS Code")<br>`tag` _string_ — Filter by exact tag (e.g. "supply-chain-compromise")<br>`mitre_technique` _string_ — Filter by MITRE technique id (e.g. T1059)<br>`cve` _string_ — Filter by CVE id (e.g. CVE-2026-45321)<br>`malware` _string_ — Filter to threats deploying a malware family (e.g. "LockBit", "Vidar")<br>`tool` _string_ — Filter to threats using a tool (e.g. "Cobalt Strike", "Mimikatz")<br>`campaign` _string_ — Filter to threats in a named campaign/operation (e.g. "Snowflake campaign")<br>`os` _string_ — Filter to threats affecting an operating system (e.g. "Windows", "Linux", "VMware ESXi")<br>`sector` _string_ — Filter by grounded industry sector (e.g. "Healthcare", "Government")<br>`created_after` _string_ — ISO date — only threats created on/after (e.g. 2026-05-01)<br>`created_before` _string_ — ISO date — only threats created on/before<br>`limit` _number_ — Max results (default 20, max 100)<br>`offset` _number_ — Row offset for pagination (default 0).<br>`cursor` _string_ — Opaque pagination cursor (next_cursor from a prior result); ignored when offset is given.

### `search_corpus_semantic`

Vector + rerank retrieval over the whole corpus, returning ranked source cards. Use when keyword search fails — conceptual or paraphrased questions where the exact terms do not appear in the text. Complements search_threats, which is boolean/exact over structured filters. Depends on the AI Search binding and is rate-limited; a 503 means the index is unavailable, not that nothing matched.

**Arguments:** `query`**\*** _string_ — A natural-language question or concept.

### `hunt`

Run a deterministic SIEM-style query over the pre-joined observation index (~106k rows across tool, malware, ioc, mitre, cve, attribution, dns and infra observations). Use this INSTEAD of chaining many search_threats calls when the question is an aggregate ("how many X grouped by Y") or crosses observation types ("threats using tool A that also have IOC type B"). Append "| stats count by <field>" to aggregate; without it you get matching rows. A threat contributes one row per observation, so for "how many THREATS" use "| stats count_distinct(threat) by <field>" — plain count double-counts. Operators are = != > < >= <= LIKE CONTAINS IN "NOT IN"; quote any value with a hyphen (cve = "CVE-2024-3400"). Call hunt_schema first if you do not know the field names.

**Arguments:** `query`**\*** _string_ — TLQL, e.g. `tool = "cobalt strike" AND sector = "healthcare" | stats count by nation`<br>`limit` _integer_ — Row cap for non-stats queries (default 50, max 100). In stats mode it caps the number of GROUPS returned. Check `has_more` in the response.

### `hunt_schema`

The hunt query grammar: every filterable field and alias, which fields are scoped observables vs denormalized, the operators, the stats-pipe form, worked examples, and how fresh the index is. Call once before writing a hunt query.

**Arguments:** _takes no arguments_

### `resolve_entity`

Normalize an actor / malware / tool / sector / region / technique name or alias to its canonical reference form + stable UUID (e.g. "fancy bear" → "APT28"). Call this BEFORE pivoting (get_actor / get_malware_intelligence / get_tool_intelligence / search_threats) when unsure of the canonical name. Optional type narrows the lookup.

**Arguments:** `name`**\*** _string_ — Name or alias to resolve<br>`type` _string_ — Optional: actor|malware|tool|sector|region|technique|campaign

### `get_entity_aliases`

Every alias the reference set maps to ONE entity — the reverse of resolve_entity, which only walks a single alias forward. Use it to check alias collision: the commonest reason two "different actors" turn out to be one. IMPORTANT: the source (MISP) OVER-MERGES — it folds subgroups into their parent, so "andariel" and "bluenoroff" both appear under "Lazarus Group". An alias entry is a MAPPING, not an assertion of identity. Each alias therefore carries `relation` (canonical|subgroup|alias), `other_entities` (how many other entities claim the same label) and `corpus_threats` (how many threats this platform files under that exact label); `corpus_uses_separately` lists labels the corpus treats as their own actor despite the merge. Accepts a name or a uuid.

**Arguments:** `name` _string_ — Name or alias to expand (e.g. "andariel"). Resolved exact-then-fuzzy, same ladder as resolve_entity.<br>`uuid` _string_ — Entity uuid, if you already have one from resolve_entity. Skips resolution.<br>`type` _string_ — Optional: actor|malware|tool|sector|region|technique|campaign — narrows name resolution.

### `list_threat_categories`

List every threat category with its threat count across the whole corpus. Use to discover valid category filters for search_threats.

**Arguments:** _takes no arguments_

### `get_recent_threats`

List the most recently published threats. Paginated: pass limit (default 15, max 100) and offset to page through older threats; the result includes has_more and an opaque next_cursor (reusable as offset/cursor).

**Arguments:** `limit` _number_ — Max results (default 15, max 100)<br>`offset` _number_ — Row offset for pagination (default 0). Or pass cursor from a prior result.<br>`cursor` _string_ — Opaque pagination cursor (next_cursor from a prior result). Decoded to an offset; ignored when offset is given.


## L2 · Threat dossier

Prefer a bundle over a chain.

### `get_threat`

Get the detail for a single threat by its ID (e.g. TL-2026-0042): overview, attribution, MITRE techniques, IOCs, the detection roster, timeline, targets and tags. RETRIEVABLE IN PARTS — pass `sections` to choose what comes back; the response echoes `_sections` telling you what was returned and what was omitted. By default detection RULE BODIES (SPL/KQL/Sigma) are omitted — they are ~40% of a heavy dossier and have dedicated tools (export_detection / get_detection_detail) — and `description` is trimmed to 1500 chars with `description_chars` disclosing the true length. Pass sections="all" and max_description=-1 for the complete record. For malware families, tools, sectors/regions, OS and campaigns, call get_threat_enrichment.

**Arguments:** `id`**\*** _string_ — Threat ID (e.g. TL-2026-0042)<br>`sections` _string_ — CSV of sections to return. Any of: overview, attribution, mitre, iocs, detections (WITH rule bodies), detections_meta (roster only, the default), timeline, tags, targets, remediation, references (full bibliography), references_compact (ranked primary-first + corroboration summary, in the default), similar, updates, osint, flow, entities — or "all", or "row" (exclusive: a ~500-byte identity row plus section counts). Default: overview,attribution,mitre,iocs,detections_meta,timeline,tags,targets,similar,flow,osint,references_compact<br>`max_description` _number_ — Char budget for the narrative description. Default 1500; 0 drops it; -1 returns it whole.

### `get_threat_enrichment`

Reference-grounded enrichment for one threat by ID: the malware families and tools used, targeted sectors/regions, affected operating systems, named campaigns, AI/ML (ATLAS) techniques, and per-technique mitigations + detection data sources. Complements get_threat (overview/MITRE/IOCs/detections) — call this for the "what malware/tools were used and who was targeted" view. Read `coverage` before acting on an empty array: grounded = the normalised association tables, threat_record = fell back to the threat's own target_sectors/target_regions, unmapped = NOT MAPPED, which is not the same as not targeted.

**Arguments:** `id`**\*** _string_ — Threat ID (e.g. TL-2026-0042)

### `get_threat_bundle`

One-shot dossier for a threat: threat detail plus its simulations and pipeline transcripts (include="summary" returns just the threat). Fewer round-trips than get_threat + get_threat_simulations + get_threat_transcripts separately. The threat is the same bounded projection get_threat returns by default — call get_threat(id, sections="all") for the unabridged record.

**Arguments:** `threat_id`**\*** _string_ — Threat ID (e.g. TL-2026-0042)<br>`include` _string_ — "full" (default) bundles simulations + transcripts; "summary" returns just the threat<br>`simulation_limit` _integer_ — Playbooks to embed, 1-20 (default 3). Each carries full command text; raise only if you need more than the newest few.

### `get_threat_hunting_bundle`

Flagship one-call hunting dossier for a threat: threat detail + similar threats + simulations + infrastructure pivots, composed server-side. Best single tool to scope a hunt around one threat. Every section is bounded so the bundle fits in one response — raise simulation_limit / pivot_limit, or call get_infrastructure_pivots and get_threat directly, when you need the full lists.

**Arguments:** `threat_id`**\*** _string_ — Threat ID (e.g. TL-2026-0042)<br>`simulation_limit` _integer_ — Playbooks to embed, 1-20 (default 3).<br>`pivot_limit` _integer_ — Infrastructure pivots to embed, 1-200 (default 25). One threat can carry 150+; get_infrastructure_pivots returns the full set.<br>`shape` _string_ — "v1" (default, unchanged nested envelope) | "canonical" (flat, stably-named, SMALLER — recommended for programmatic consumers) | "both". In canonical: technique ids are a top-level `ttps` array, sectors/regions use ONE spelling, and every array key is ALWAYS an array (never null, never an object). In v1, `similar_threats` and `infrastructure_pivots` are handler envelopes (objects) while `threat.similar_threats` is a flat array — if you are writing a parser, use canonical.

### `bulk_get_threats`

Fetch up to 20 threats by ID in one call. Returns LEAN rows by default (identity, severity, attribution, CVEs, targets + `counts` for MITRE/IOCs/detections/timeline/tags), which is what makes 20 ids actually fit in one response. `count` is the number of rows actually present in threats[]; unresolvable ids come back in `missing`. detail="full" returns whole dossiers but is capped at 3 ids per call (a full dossier is ~68 KB) — the overflow is named in `deferred_ids`, never silently dropped. Drill into any row with get_threat(id).

**Arguments:** `threat_ids`**\*** _array_ — Threat IDs (max 20)<br>`detail` _string_ — "summary" (default, all 20 fit) | "full" (whole dossiers, max 3 ids per call)

### `get_threat_transcripts`

Get the PIPELINE PROVENANCE record for a threat — how its profile was produced, not analysis of the threat itself. Returns the 7 pipeline phases (HUNT/RESEARCH/SIMULATION/DETECT/PUBLISH/CORRELATION/TRANSCRIPT) with per-phase timing, the quality gates and their PASS/FAIL metrics (tag/technique/IOC/reference counts), an overall quality score, the inter-agent handoff log, and correlation_highlights (related threat ids and the high-frequency IOCs that linked them). The handoff messages are routing stubs and file pointers, NOT analyst prose — do not expect written reasoning about the adversary. Use this to judge how thoroughly a threat was worked and to find its related-threat/IOC leads; use get_threat or get_threat_enrichment for the analysis, and get_attribution_evidence for why it is attributed.

**Arguments:** `threat_id`**\*** _string_ — Threat ID (e.g. TL-2026-0042)


## L3 · Actor & attribution

Always check evidence state before repeating an attribution.

### `get_actor`

Get a lean threat-actor profile by name or alias: actor metadata, attribution counts, attributed-threat summary rows, MITRE tactic rollup + technique ids, IOC category counts (no raw values), CVE/CWE/tool summaries, and relationships. Tactic keys are canonicalised (the corpus writes both "Credential Access" and "credential-access") and the rollup counts DISTINCT TECHNIQUES per tactic; mitre.technique_ids is capped at 60 against mitre.techniques_total. relationships splits into shared_infra / shared_techniques / shared_tooling with the type re-derived from the value, because corr_attribution types tool names as hashes. An empty actor_relationships means UNRECORDED, not unrelated — see actor_relationships_coverage. For heavy detail use the follow-up tools: get_threat(id) for a full threat, search_detections / get_detection_detail for detection bodies, search_iocs for IOC values, get_infrastructure_pivots for shared infrastructure.

**Arguments:** `name`**\*** _string_ — Actor name or alias (e.g. 'APT29', 'Lazarus Group')

### `search_actors`

List attributed threat actors with aggregate stats (threat_count, severity levels, categories, nation_state). Returns the full roster in one call, or narrow it with the optional tool / malware / sector filters (e.g. tool="Cobalt Strike" → only actors that used it). Use get_actor for a single actor's full profile. Not paginated; the response carries a total count. Names are REPAIRED before they are returned: the corpus stores threat_actor as free text and comma-split it, so prose fragments and truncated parentheticals used to appear as adversaries — `label_hygiene` reports what was dropped and repaired. Two per-row flags matter: `role: "co-mention"` means the corpus never files a threat under that name as its primary attribution, and `also_grounded_as` means the corpus also records the name as a malware family or tool (e.g. SNOWLIGHT). Check both before reporting a row as an operator.

**Arguments:** `tool` _string_ — Only actors with a threat using this tool (e.g. "Cobalt Strike")<br>`malware` _string_ — Only actors with a threat deploying this malware family (e.g. "LockBit")<br>`sector` _string_ — Only actors with a threat targeting this sector (e.g. "Healthcare")

### `get_actor_intelligence`

Composite intelligence picture for a threat actor: the actor profile (threats, MITRE, IOC counts, CVEs, tooling, targets), the known actor_relationships baseline, plus cross-actor attribution SCOPED TO THIS ACTOR — only shared entities whose actor list names it or one of its aliases, ranked by attribution belief (shared malware/infrastructure outranks commodity TTPs). Use it to answer "who else does this actor overlap with, and on what evidence".

**Arguments:** `name`**\*** _string_ — Threat-actor name or alias (e.g. "APT29")

### `get_actor_relationships`

The platform's baseline of ALREADY-DOCUMENTED links between threat actors (subgroup / overlaps / similar / linked-to) with both uuids and the source. Call this before claiming any actor-to-actor relationship is new — it is the only surface that says whether a link is public knowledge. CRITICAL: this table is sparse, covering a small minority of the corpus's actor labels (the response carries live coverage numbers). An empty result means the link is UNRECORDED, never that it was disproved or that the actors are unrelated. Use it to rule a claim out of NOVELTY, never to rule a relationship out of EXISTENCE. Matches both slots, so it also returns rows where your actor is the target — get_actor's relationship list does not.

**Arguments:** `actor` _string_ — Optional: only rows naming this actor on either side (e.g. "Lazarus Group"). Omit to list the whole baseline.<br>`rel_type` _string_ — Optional: filter by relationship type. The response's coverage.rel_types lists what is actually present.<br>`limit` _integer_ — Default 50, max 200.

### `get_attribution_evidence`

Why a threat is attributed to an actor: the verdict, canonical actor, confidence, scope, the cited evidence chain, which signals fired, suspected alternatives and the analyst reasoning. Crucially it also reports `state` — whether this is a researched assessment or an unresearched intake stub — which threats.threat_actor alone cannot tell you.

**Arguments:** `threat_id`**\*** _string_ — Threat ID (e.g. TL-2026-0989).

### `get_attribution_coverage`

Corpus-level attribution honesty: how many threats are genuinely assessed vs merely actor-labelled at ingest vs uncovered, broken down by confidence, scope and reason code, plus the research backlog, contradictions, top actors and the research clock (last real assessment, not last nightly intake).

**Arguments:** `actor` _string_ — Optional — scope the `recent` list to one actor.


## L4 · IOC & infrastructure

Exact value → intelligence; substring → search.

### `search_iocs`

Search indicators of compromise (IPs, domains, hashes, URLs). Filter by value substring and/or category. Pass limit (default 25, max 100); the result includes has_more (true when the page is full, so more may exist). NOTE: the indicator endpoint does not yet honor offset — narrow with a more specific value/type substring rather than paging.

**Arguments:** `value` _string_ — Substring to match against IOC values<br>`type` _string_ — IOC category (e.g. network, file, behavioral)<br>`limit` _number_ — Max results (default 25, max 100)

### `get_ioc_intelligence`

Get the composite intelligence dossier for one indicator: linked threats, actor attribution, related IOCs, and enrichment context in a single call. Prefer this over search_iocs when you already have an exact indicator value and want its full story.

**Arguments:** `value`**\*** _string_ — Exact indicator value (IP, domain, hash, or URL)

### `get_ioc_dns`

Return stored DNS enrichment for an IP or domain indicator (reverse-IP and subdomain records previously resolved and cached in the platform dataset). This reads stored data — it is NOT a live lookup at call time. Use get_ioc_intelligence for the full stored dossier.

**Arguments:** `value`**\*** _string_ — IP address or domain (stored DNS enrichment lookup)

### `get_ioc_blast_radius`

Map the blast radius of one indicator: the threats that contain it, the MITRE techniques those threats use, and the actors + sibling IOCs in the same campaigns. Matching is case-insensitive, and for a CVE the threat ring is the UNION of indicator rows and threats.cve_list (the authoritative CVE-to-threat mapping) — `coverage` reports which of the two produced how many, so a count can be traced to its source. `edge_scores` is a sampled graph_edges projection for fidelity context, never the denominator. Use this to scope impact of a single IOC; for a richer multi-source dossier on one indicator use get_ioc_intelligence instead.

**Arguments:** `value`**\*** _string_ — Indicator value (IP, domain, hash, URL, or CVE-XXXX-NNNN)<br>`depth` _number_ — Traversal rings to expand (1–3, default 3): 1=threats, 2=+techniques, 3=+actors & sibling IOCs

### `get_infrastructure_pivots`

For a given threat, surface cross-threat infrastructure links — shared IPs/domains/hashes and DNS-derived overlaps that tie it to other campaigns. Derived from the SAME indicator universe the correlation graph scores, so a pivot here and an ioc-basis edge in explain_correlation agree. Each pivot carries value_threat_count (2 = the value is nearly unique to this pair; 40 = commodity such as a Run key) and a rarity-derived confidence — read those before calling an overlap a link. `coverage` gives the denominator: pivot_count 0 with scorable_iocs > 0 is a REAL negative, pivot_count 0 with scorable_iocs 0 only means the threat carries no pivotable indicator. Labels (malware family, tool name, technique) are deliberately not pivotable — two write-ups naming the same family observed nothing in common. Use get_similar_threats for TTP/actor-based similarity instead.

**Arguments:** `threat_id`**\*** _string_ — Threat ID (e.g. TL-2026-0042)

### `get_c2`

Query the live C2 (command-and-control) intelligence center. Pick a view: 'beacons' (active C2 beacon snapshots — default), 'configs' (full extracted C2 configs), 'operators' (operator clusters), 'watermarks' (Cobalt Strike watermark index), 'correlations' (cross-C2 correlations), 'timeline' (activity over time), 'stats' (aggregate counts). Use generate_c2_blocklist when you want firewall-ready output rather than raw records.

**Arguments:** `view` = `beacons` \| `configs` \| `operators` \| `watermarks` \| `correlations` \| `timeline` \| `stats` — Which C2 dataset to return (default 'beacons')<br>`limit` _number_ — Max records for the record-bearing views — beacons (default 50), configs (default 25), correlations (default 25). Max 100.<br>`offset` _number_ — Skip this many records — 'correlations' only; the aggregate summary block stays whole across pages.

### `get_c2_dns_intel`

Reverse-DNS unmasking of C2 beacon infrastructure: which domains ride on each beacon IP, infrastructure fidelity (dedicated / mixed / shared), compromised-host flags and sample domains. Answers "what else lives on this C2 infrastructure". Filter by fidelity to separate adversary-owned infrastructure from shared hosting.

**Arguments:** `fidelity` = `dedicated` \| `mixed` \| `shared`<br>`compromised` _boolean_ — Only hosts flagged as compromised rather than adversary-owned.<br>`limit` _integer_ — Default 40, max 100.

### `generate_c2_blocklist`

Compile a firewall-ready C2 blocklist of active command-and-control IPs observed recently. Returns deduplicated network indicators ready to drop into a denylist. Use this for actionable blocking; use get_c2 with view="beacons" when you need the underlying beacon detail.

**Arguments:** _takes no arguments_


## L5 · Vulnerability

Rank by exploitation, not CVSS.

### `search_vulnerabilities`

Query the real-time CVE feed — validated against CVE.org and enriched from open sources (CVSS, EPSS exploitation probability, CISA KEV, public PoCs/exploits, nuclei detection templates, affected products/packages, plus platform-native trending/priority). Sort by trending|latest|priority|cvss|epss; filter by severity, kev (KEV-only), has_poc, nuclei, epss_min, window (days), vendor, cwe, or free-text query. Returns COMPACT cards {cve_id, severity, cvss, epss_percentile, is_kev, has_poc/exploit/nuclei, priority, trending, vendors, age} — call get_cve(id) for full detail.

**Arguments:** `query` _string_ — Free-text: a CVE id or keyword (matches id + description)<br>`sort` _string_ — trending (default) | latest | priority | cvss | epss<br>`severity` _string_ — CRITICAL | HIGH | MEDIUM | LOW<br>`kev` _boolean_ — Only CISA KEV (known-exploited) CVEs<br>`has_poc` _boolean_ — Only CVEs with a public proof-of-concept<br>`nuclei` _boolean_ — Only CVEs with a nuclei detection template<br>`epss_min` _number_ — Minimum EPSS exploitation probability, 0-1<br>`window` _number_ — Only CVEs published within the last N days<br>`vendor` _string_ — Affected vendor/product substring<br>`cwe` _string_ — Weakness id, e.g. CWE-79<br>`limit` _number_ — default 30, max 100

### `get_cve`

Look up a CVE by identifier (e.g. CVE-2024-3400): description, CVSS v2/v3/v4, EPSS, CISA KEV status, exploitation flags (PoC/exploit/nuclei), weaknesses (CWE ids), affected products, references, and linked threats. `threat_ids` is the LIVE join over threats.cve_list (threat_ids_source says so; threat_ids_note appears when the stale derived index disagreed, and any ids only that index held are surfaced separately in threat_ids_derived_only rather than merged) — it is the same linkage get_cve_intelligence reports. Enumerated pre-release CPEs are collapsed into ranges (affected_products_raw_count gives the uncollapsed count). enrichment_status distinguishes a fully enriched record from one merely seeded from KEV, where blank CVSS/EPSS means NOT MEASURED rather than measured-absent. For linked threats WITH titles/actors/detections/velocity in one call, prefer get_cve_intelligence.

**Arguments:** `cve_id`**\*** _string_ — CVE identifier (e.g. CVE-2024-3400)

### `get_cve_intelligence`

Composite CVE dossier in one call: the enriched CVE detail, the threats that reference it (authoritative join — cve.threat_ids is reconciled to it), the attributed actors and MITRE techniques behind those threats, weaponization velocity for THIS CVE with its rank against the corpus, CWE-sibling related CVEs, and detections that name it.

**Arguments:** `cve_id`**\*** _string_ — CVE identifier (e.g. CVE-2024-3400)

### `bulk_get_cves`

Fetch up to 20 enriched CVEs by ID in one call. Returns {cves, missing, count}.

**Arguments:** `cve_ids`**\*** _array_ — CVE IDs (max 20)

### `get_cwe`

Look up a CWE by identifier (e.g. CWE-79): weakness name, description, abstraction, MITRE likelihood_of_exploit, common consequences, detection methods, phased mitigations, related CWEs, the corpus threats that carry it, and related_cves - the corpus CVEs mapping to this weakness (top 20 by KEV then CVSS, with related_cve_count giving the true total; use search_vulnerabilities with cwe=<id> for the full list). NOTE: CWE has no CVSS severity - likelihood_of_exploit is the ontology's own High/Medium/Low rating, and severity_note says so; for scored severity read the related_cves rows or call get_cve. related_cves is null (never []) if the join could not run.

**Arguments:** `cwe_id`**\*** _string_ — CWE identifier (e.g. CWE-79)


## L6 · Detection & purple team

Only some of these return actual rule text.

### `get_detections`

List detection logic (Splunk SPL, Microsoft KQL, Sigma). Optionally filter by threat_id or detection type. Paginated: pass limit (default 15, max 100) and offset to page; the result includes has_more and an opaque next_cursor (reusable as offset/cursor).

**Arguments:** `threat_id` _string_ — Filter detections for a specific threat ID<br>`type` _string_ — Detection type: spl, kql, or sigma<br>`limit` _number_ — Max results (default 15, max 100)<br>`offset` _number_ — Row offset for pagination (default 0). Or pass cursor from a prior result.<br>`cursor` _string_ — Opaque pagination cursor (next_cursor from a prior result). Decoded to an offset; ignored when offset is given.

### `search_detections`

Keyword search across detection logic (SPL/KQL/Sigma) by rule text, technique, or threat. Optionally filter by type (spl|kql|sigma) or severity. Paginated via limit (default 25, max 200) + offset.

**Arguments:** `query`**\*** _string_ — Search term (rule text, CVE, technique, etc.)<br>`type` _string_ — Detection type: spl, kql, or sigma<br>`severity` _string_ — Filter by severity: critical, high, medium, low<br>`limit` _number_ — Max results (default 25, max 200)<br>`offset` _number_ — Row offset for pagination (default 0)

### `get_detection_detail`

Get the full detail for one detection rule by its ID, including the complete query text (SPL/KQL/Sigma), metadata, and the threat it maps to.

**Arguments:** `detection_id`**\*** _string_ — Detection ID

### `export_detection`

Export one detection rule in a specific format. format=spl|kql|sigma returns the raw query text for that flavor; format=json returns the full detection object.

**Arguments:** `detection_id`**\*** _string_ — Detection ID<br>`format`**\*** _string_ — spl, kql, sigma, or json

### `get_threat_simulations`

DEPRECATED (generation stopped 2026-08-03; ~5,362 archived rows still served, no new ones are produced) — the response carries deprecated:true and the caveat inline. Adversary-emulation playbooks attached to a threat. Most were synthetic-telemetry generators against local mocks rather than real execution, so they could not trigger the platform's own detections. One threat's playbooks can run 30 KB+ of command text: narrow with platform (see available_platforms) and limit. An empty result carries a `reason` distinguishing "this threat never had any" from "none for that platform". Prefer get_attack_flow, which returns the analyst-authored causal DAG that replaced them.

**Arguments:** `threat_id`**\*** _string_ — Threat ID (e.g. TL-2026-0042)<br>`platform` _string_ — Return only this platform: windows, linux, python (also aws, macos, multi on a few rows). Omit for all.<br>`limit` _number_ — Max playbooks to return (max 50). Omit for all — bodies are large.

### `list_simulations`

DEPRECATED (generation stopped 2026-08-03; archived rows only). List adversary-emulation simulation scenarios across the platform (atomic test commands grouped by threat). `total` / `total_simulations` are CORPUS-wide; the page you got is `returned` / `page_simulations`, and `has_more` + `next_offset` walk the rest. The `correlation` block (shared_techniques, attack_chain_similarities, platform/tactic coverage) is computed over the RETURNED PAGE only — it carries scope:"page" and changes with limit. Pass limit (default 50, max 200) and offset. include_facets=true adds the corpus filter vocabulary (~20 KB of actor/nation/category strings) — off by default. Prefer get_flow_coverage and get_attack_flow — ATT&CK Flows replaced simulations.

**Arguments:** `limit` _number_ — Max threats in this page (default 50, max 200)<br>`offset` _number_ — Row offset for paging (default 0). Use next_offset from the previous response.<br>`include_facets` _boolean_ — Include the corpus filter vocabulary (filter_meta). Default false — it is ~20 KB and does not shrink with limit.

### `get_mitre_gap_analysis`

Prioritized list of MITRE ATT&CK techniques with the weakest detection coverage (detection debt), ranked by debt_score = attributed threats / (1 + detection rules mapping the technique or its parent), scaled by exploitability (max EPSS / KEV of the CVEs its threats weaponize) and rarity. Higher = more exposure with less rule coverage. Filter by tactic (spelling-insensitive: "execution", "Execution" and "Execution (ICS)" all match) and cap with limit; summary.gaps/coverage_pct describe the FILTERED corpus, not the returned page.

**Arguments:** `tactic` _string_ — Filter to one ATT&CK tactic (e.g. "execution", "defense-evasion", "command-and-control"). An unmatched value returns summary.available_tactics.<br>`limit` _number_ — Max techniques (default 20, max 100). Rows come back debt_score-descending, so limit=N is the top-N worst.


## L7 · MITRE ATT&CK

predict_mitre_transitions is sequence; get_technique_rules is co-occurrence.

### `get_mitre_coverage`

Get MITRE ATT&CK coverage across the platform. Optionally filter by tactic.

**Arguments:** `tactic` _string_ — Filter by ATT&CK tactic (e.g. "initial-access")

### `get_mitre_technique`

Get details for a specific MITRE ATT&CK technique by ID (e.g. T1059 or T1059.001): authoritative name, tactics, matrix, revocation status, mitigations, detection data sources, and the corpus threats using it. The threat list is paged — a heavily-used technique attaches hundreds of threats, so pass include_threats=false for the definition alone (a few KB), or limit/offset to page. threat_count always reports the true total.

**Arguments:** `technique_id`**\*** _string_ — Technique ID (e.g. T1059 or T1059.001)<br>`include_threats` _boolean_ — Set false to return the technique definition WITHOUT the threat list (default true). Use this when you only need the name/tactic/mitigations.<br>`limit` _number_ — Max threats to return (default 50, max 500)<br>`offset` _number_ — Threat-list row offset for paging (default 0)

### `predict_mitre_transitions`

Predict the MITRE ATT&CK techniques most likely to follow (or precede) a given technique, with probabilities and example threats. Each row carries order_basis: 'attack_flow' rows come from analyst-authored ATT&CK Flow DAGs (real asserted causality), 'mitre_canonical' rows are inferred by sorting a threat's flat technique set by canonical tactic rank, and 'blended' rows have both. Prefer attack_flow evidence: ~24% of real transitions run against canonical tactic order and are inexpressible by the canonical basis. Use forward to anticipate the next step; reverse to infer what came before.

**Arguments:** `technique_id` _string_ — Source technique ID (e.g. T1059 or T1059.001)<br>`direction` = `forward` \| `reverse` — 'forward' = techniques that typically follow (default); 'reverse' = techniques that typically precede<br>`top_n` _number_ — Max transitions to return (default 5, max 20)<br>`basis` = `any` \| `attack_flow` \| `blended` \| `mitre_canonical` — Filter by evidence basis. Default 'any' (unfiltered, byte-identical to previous behaviour). Use 'attack_flow' for authored-causality rows only.

### `get_technique_rules`

MITRE ATT&CK technique PAIRS mined from the corpus with support, confidence and lift — which techniques travel together far above chance. Each rule carries both technique ids AND their ATT&CK names (technique_a_name / technique_b_name), so a rule is readable without a follow-up lookup. Complements predict_mitre_transitions exactly: that answers sequence (what follows what), this answers co-occurrence (what appears alongside what). Rules come back lift-descending; `summary.total_rules` reports how many exist in total, so `has_more` tells you whether the tail was cut. Note minSupport 0.02 — a pair must appear in ~2% of threats to be mined at all, so rare-technique pairs are structurally absent rather than uncorrelated.

**Arguments:** `limit` _integer_ — Default 50, max 100.


## L8 · Correlation graph

Start shallow. Depth multiplies node count.

### `get_similar_threats`

Threats similar to a given threat, with the EVIDENCE behind each link: per-channel score breakdown, which signal dominates, the concrete shared techniques / IOCs / CVEs, and quality flags for stale or high-confidence-low-evidence links. Use explain_correlation for a full decomposition of one specific pair.

**Arguments:** `id`**\*** _string_ — Source threat ID (e.g. TL-2026-0042)<br>`limit` _number_ — Max results (default 10, max 20 — the handler caps here).

### `explain_correlation`

Why two threats are linked: the per-channel similarity decomposition (techniques, IOCs, CVEs, products, CWEs, context), which channel dominates and by how much, the concrete shared artifacts, the signal count, and quality flags for high-confidence/low-evidence and stale links. Use when get_similar_threats gives a score and you need the evidence behind it. Pass the pair in either order. Returns 404 when the engine has no edge between them.

**Arguments:** `threat_a`**\*** _string_ — First threat ID.<br>`threat_b`**\*** _string_ — Second threat ID.

### `get_correlation_path`

Shortest evidence path between two threats across the similarity graph: the intermediate threats, each hop's dominant linking signal and shared artifacts, and the weakest-link strength of the whole path. Answers "is this incident connected to that campaign, and through what". Returns found:false with a reason (no edges vs different components) rather than an empty array.

**Arguments:** `from`**\*** _string_ — Starting threat ID.<br>`to`**\*** _string_ — Target threat ID.<br>`max_hops` _integer_ — Search depth, 1-8 (default 6).

### `get_entity_profile`

One-call dossier for any node in the intelligence graph — threat, technique, actor, IOC or CVE. Returns its centrality/pivot rank, top graph neighbours with edge fidelity, and type-specific rollups (linked threats, techniques with risk scores, IOCs with consensus and rarity, campaigns, related CVEs). Best token-per-call ratio in the graph family: replaces five or six separate lookups. Neighbours on the NPMI edge types also carry raw pmi, co_occurrence_count and the marginals {c, df_actor, df_tech, N}. For technique and ioc nodes the response includes `rarity` — corpus_df (how many threats mention it) against corpus_n — which is what separates a distinctive shared technique from background noise; check `top_neighbors_truncated` and raise `limit` before concluding a neighbour is absent.

**Arguments:** `node_type`**\*** = `threat` \| `technique` \| `actor` \| `ioc` \| `cve`<br>`node_id`**\*** _string_ — The entity id/value (TL- id, T-number, actor name, IOC value, or CVE id).<br>`limit` _integer_ — Neighbours in top_neighbors (default 20, max 100 over MCP). They are ordered by fidelity, so the rare low-fidelity edges are the ones a small limit drops.

### `get_correlation_subgraph`

The N-hop neighbourhood around any graph node — nodes, edges, and each edge's fidelity — for incremental exploration of the correlation graph. Start at depth 1 and expand: a whole-corpus graph exceeds every response budget. For a pre-aggregated single-entity view prefer get_entity_profile, which is cheaper and usually what you want; use this when you need the actual edge topology. Edges of the NPMI types (actor_technique, technique_cooccurrence) also carry the raw pmi, co_occurrence_count and the marginals {c, df_actor, df_tech, N} the score was computed from — use those, not the rounded fidelity, to compare two edges.

**Arguments:** `seed_type`**\*** = `threat` \| `technique` \| `actor` \| `ioc` \| `cve`<br>`seed_id`**\*** _string_ — The entity id/value to expand from.<br>`depth` _integer_ — Hops, 1-3 (default 1). Each hop multiplies the node count.<br>`min_fidelity` _number_ — Drop edges below this fidelity. Scale is 0-100, NOT 0-1 — typical NPMI edges sit between 5 and 30, so 0.5 filters nothing.<br>`edge_type` = `actor_technique` \| `technique_cooccurrence` \| `ioc_threat` \| `threat_similarity` — Keep only edges of this type (also prunes the traversal, so it changes which nodes are reachable). Omit for all types.<br>`limit_nodes` _integer_ — Default 40, max 60 over MCP.<br>`limit_edges` _integer_ — Default 80, max 120 over MCP.

### `get_pivotal_entities`

The hubs and bridges of the intelligence graph ranked by weighted degree and approximate betweenness — where a single detection buys the most coverage. NOTE: betweenness is an ego-bridge heuristic, not exact Brandes; the response says so in `note`. Do not present it as exact betweenness.

**Arguments:** `node_type` = `threat` \| `technique` \| `actor` \| `ioc` \| `cve`<br>`limit` _integer_ — Default 25, max 100.

### `get_graph_campaigns`

Campaign clusters the engine assembled from the similarity graph (connected components + label propagation): label, cohesion, top actors/techniques/IOCs/nation-states, and per-member {title, created_at, category, threat_actor}. Each cluster carries duplicate_risk plus its evidence (distinct_member_dates, date_span_days, shared_title_tokens) — a similarity-derived cluster of one event documented several times looks identical to a real multi-event campaign until you read those. Distinct from get_campaign_intelligence (looks up a NAMED campaign from threat text) and from list_campaigns (the roster of named campaigns); this one reports clusters the engine derived itself.

**Arguments:** `limit` _integer_ — Default 15, max 50.

### `get_correlations`

Read precomputed cross-dataset correlations. Choose an engine: 'overview' (rollup of all engines — default), 'mitre-heatmap', 'adversary-infra', 'ioc-consensus', 'cve-velocity', 'attribution', 'detection-debt', or 'enrichment'. Use 'overview' first to see what's available, then drill into a specific engine.

**Arguments:** `engine` = `overview` \| `mitre-heatmap` \| `adversary-infra` \| `ioc-consensus` \| `cve-velocity` \| `attribution` \| `detection-debt` \| `enrichment` — Which correlation engine to read (default 'overview')<br>`limit` _number_ — Paged engines only ('mitre-heatmap', 'ioc-consensus', 'detection-debt'): max rows, pre-ordered by score (default 50; max 200 for mitre-heatmap, 500 for the others). The summary block always covers the full set.<br>`offset` _number_ — Paged engines only: skip this many rows.


## L9 · Attack flow, D3FEND & ATLAS

Authored causal DAGs and their defensive twin. Flow similarity is ordered-sequence, NOT the blended correlation score — the channel is still gated off.

### `get_attack_flow`

Get the analyst-authored MITRE ATT&CK Flow for a threat: a causal DAG of actions, conditions, operators and assets, with per-node technique ids, tactics and descriptions. This is asserted causality — what actually caused what — not a flat technique list. Check `generator`: 'harness' is authored, 'derived' is timeline ORDERING and must not be read as causality. Use get_flow_similar_threats to find threats sharing a causal sequence.

**Arguments:** `threat_id`**\*** _string_ — Threat ID (e.g. TL-2026-1916)<br>`view` = `ir` \| `nodes` — 'ir' (default) returns the compact flow IR plus validation findings; 'nodes' returns the denormalized node/edge/asset projection with technique and tactic columns already joined.

### `get_flow_similar_threats`

Find threats that share a causal SEQUENCE of techniques with this one, scored by ordered-bigram cosine over ATT&CK Flow transitions. This sees what no other channel can: every existing similarity signal compares flat sets, so two threats can share an identical attack ORDER while sharing no indicator, CVE or actor. Returns the shared transitions (e.g. 'T1003>T1021') so the claim is inspectable. Scored independently and NOT folded into get_similar_threats' blended score.

**Arguments:** `threat_id`**\*** _string_ — Threat ID (e.g. TL-2026-1916)<br>`limit` _integer_ — Max neighbours (default 20, max 100)<br>`min_score` _number_ — Drop neighbours below this sim_flow (0-1)

### `get_flow_coverage`

How much of the corpus carries an authored ATT&CK Flow, split by provenance, plus flow node/transition/path-similarity totals and whether the flow similarity channel has met its coverage gate. Call this before reasoning over flow data so you know the denominator — flow-derived findings only speak to the covered slice.

**Arguments:** _takes no arguments_

### `get_d3fend_posture`

Get the defensive twin of a threat's ATT&CK mapping: offensive technique to digital artifact to D3FEND countermeasure, plus coverage by defensive tactic. Use to answer 'what would have stopped this'. COVERAGE: 197 of 1,891 threats carry a mapping, so absence is common and is not an error — a threat that exists with no mapping answers 200 with an empty `mappings` and a `reason` saying whether it has no techniques at all or techniques the ontology has no countermeasure for; only an unknown threat id answers 404. IMPORTANT: this is a deterministic projection of the threat's techniques through a static ontology — two threats sharing countermeasures almost always just share techniques, so never treat countermeasure overlap as independent correlation evidence.

**Arguments:** `threat_id`**\*** _string_ — Threat ID (e.g. TL-2026-1916)<br>`limit` _integer_ — Raw technique x countermeasure pairs in `mappings` (default 60, max 500). `countermeasures` always rolls up ALL pairs regardless, so the default loses no information — raise this only if you need the individual pairings.

### `get_atlas_intelligence`

MITRE ATLAS technique co-occurrence for threats that attack AI/ML systems — prompt injection, agent-tool abuse, context and memory poisoning, cost harvesting. `data` returns pairs whose BOTH endpoints are real ATLAS ids (^AML.), with cooccurrence_count, support, confidence and lift; free-text publish labels ("Prompt Injection", "Tool Poisoning") are returned SEPARATELY in `tag_pairs` and are not ATLAS techniques. SMALL-N WARNING: the corpus holds 106 pairs of which only 10 were observed more than once, so min_cooccurrence defaults to 2. Lower it and you get rows where confidence=1.0 and lift=1/support are arithmetic on a single observation — those carry single_observation:true; do not report them as associations. Ranked by cooccurrence_count, then lift.

**Arguments:** `limit` _integer_ — Max pairs per list (default 50, max 100)<br>`min_cooccurrence` _integer_ — Minimum times the two techniques were seen together. Default 2 (the honest set). 1 includes single-observation pairs, which are flagged single_observation:true.


## L10 · Malware / tool / campaign

Route by entity kind; resolve_entity when unsure.

### `get_malware_intelligence`

Pivot on a malware FAMILY by name (e.g. "LockBit", "Vidar", "Emotet"). Returns the canonical family + type, prevalence (threat/actor counts, first/last seen), the threats deploying it, the actors using it, and its most-common ATT&CK techniques. For an offensive TOOL (Cobalt Strike, Mimikatz) use get_tool_intelligence instead. Call resolve_entity first if unsure of the canonical name.

**Arguments:** `name`**\*** _string_ — Malware family name (e.g. "LockBit", "Vidar")

### `get_tool_intelligence`

Pivot on an offensive tool / utility / RMM / LOLBin by name (e.g. "Cobalt Strike", "Mimikatz", "AnyDesk", "PsExec"). Returns the canonical tool + class, prevalence, the threats and actors using it, and its most-common ATT&CK techniques. prevalence.actor_count is the TRUE distinct-actor total; the actors[] array is clipped to the top 20 (see prevalence.actors_returned) — do not read its length as the population.

**Arguments:** `name`**\*** _string_ — Tool name (e.g. "Cobalt Strike", "Mimikatz")

### `get_campaign_intelligence`

Pivot on a named campaign / operation by name (e.g. "Snowflake campaign", "ClickFix"). Returns the threats in the campaign, the actors involved, prevalence, and common ATT&CK techniques. Membership is the UNION of the curated threat_campaign_refs table and threats whose own TITLE names the campaign, because the curated table is thin (ClickFix: 7 curated rows against 57 threats that name it). Each threat carries matched_via (grounded | title | grounded+title) and the response carries a `membership` breakdown — neither source is complete, so treat `title` rows as leads and check them before calling them members.

**Arguments:** `name`**\*** _string_ — Campaign / operation name

### `list_campaigns`

Roster of NAMED campaigns/operations referenced by the corpus, with the actors, nation-states, date range and member threats behind each. Answers the campaign-lineage question the other tools cannot: get_campaign_intelligence needs you to already know a name, and get_graph_campaigns only reports the engine's own similarity clusters. Filter with min_actors to find campaigns carrying two or more distinct actors. Each member carries `text_supported` — whether the campaign name literally occurs in that threat's own record — because the underlying table has no provenance column and at least one row attaches a threat to a campaign with no textual basis, manufacturing a cross-nation link. Check `unsupported` and `cross_nation` before treating campaign co-membership as correlation evidence.

**Arguments:** `min_actors` _integer_ — Only campaigns with at least this many DISTINCT actor labels (e.g. 2 for multi-actor campaigns).<br>`limit` _integer_ — Default 50, max 200.


## L11 · Community OSINT

Corroborating, never authoritative.

### `get_osint`

Community corroboration for a threat or an indicator from the TL_OSINT_Scan layer (tweetfeed.live, CC0). Given threat_id, returns the matched community tags, the corroborated indicator count, and whether the community saw an indicator BEFORE our report. Given ioc_value, returns sightings, reporters, tags and every linked threat. Community-sourced and heavily concentrated (~73% of recent submissions come from a single reporter) — treat as corroborating evidence, never as authoritative attribution.

**Arguments:** `threat_id` _string_ — Threat ID (e.g. TL-2026-1531). Returns the per-threat corroboration block.<br>`ioc_value` _string_ — Exact indicator (IP, domain, URL, MD5 or SHA256). Returns the community lookup + our linked threats.

### `search_xscan_indicators`

Search the canonical community-indicator set (TL_OSINT_Scan / tweetfeed.live, CC0) — indicators the community reported that were also matched against our corpus. Filter by tag, type, ASN, country, minimum linked-threat count, or a value substring. There is deliberately NO family filter: the upstream AI family field is populated on under 1% of rows, so a family argument would return nothing. Use tag instead (community tags carry the family signal). Community-sourced and heavily concentrated (~73% of recent submissions come from a single reporter) — corroborating, not authoritative.

**Arguments:** `tag` _string_ — Community tag substring, case-insensitive and normalized (e.g. phishing, asyncrat, c2)<br>`type` = `ip` \| `domain` \| `url` \| `sha256` \| `md5` — Indicator type<br>`asn` _string_ — Autonomous system, e.g. AS14061<br>`country` _string_ — Two-letter country code, e.g. RU<br>`min_threats` _number_ — Only indicators linked to at least this many of our threats<br>`q` _string_ — Substring match on the indicator value<br>`limit` _number_ — Max rows (default 25, max 100)<br>`offset` _number_ — Row offset for paging

### `get_osint_trends`

What the security community is surging on right now (tweetfeed.live, CC0), joined against our own corpus coverage: trending tags with movement, TLD distribution, novelty, top producers, daily volume — plus `corpus` (how much of our corpus the community corroborates) and `early_warning` (the lead-time distribution). The coverage-gap and lead-time read; the corpus join exists nowhere else. Keyed on community TAGS, not malware family (populated on <1% of upstream rows).

**Arguments:** _takes no arguments_

### `get_community_campaigns`

Campaign clusters from the community OSINT layer (tweetfeed.live, CC0): cluster name, confidence, targeted brand, first/last seen, indicator count and types, tags and reporters. Cluster labels are UPSTREAM AI output, not Threadlinqs attribution — do not present them as our assessment. On an upstream proxy failure the response carries community_error rather than erroring; report "community feed unavailable", not "no campaigns".

**Arguments:** `limit` _integer_ — Default 15, max 50.


## L12 · Reporting & landscape

One bundle usually beats four calls.

### `get_daily_intel_bundle`

One-shot "what happened" bundle: the day's debrief (latest by default, or pass date) plus platform stats, the top recent threats as LEAN summary rows, and the correlations overview. Drill into any listed threat with get_threat(id).

**Arguments:** `date` _string_ — Debrief date YYYY-MM-DD (default: latest)<br>`top_n` _number_ — How many top threats to include (default 5, max 10)

### `get_latest_debrief`

Get the most recent daily intelligence debrief in full detail (resolves the latest date for you).

**Arguments:** _takes no arguments_

### `get_debrief`

Get the full daily intelligence debrief for a specific calendar date (YYYY-MM-DD): posture summary, themes, threats grouped by severity, MITRE coverage, IOC distribution, actor attribution, and detection status. Find available dates first with list_debriefs.

**Arguments:** `date`**\*** _string_ — Debrief date in YYYY-MM-DD format (e.g. 2026-05-30)

### `list_debriefs`

List recent daily intelligence debriefs (newest first) with their per-day rollups: new/updated threats, the day's theme, top categories and actors, severity counts, mitre_technique_count (+ mitre_techniques_sample, the first 10 of an alphabetically-sorted ID list — NOT a top-10), and ioc_breakdown by type with ioc_total. Enough to pick the day worth drilling into without fetching each one; use get_debrief for a date's full detail (all techniques, per-threat lists, highlights). Pass limit (default 30, max 100); the result includes has_more (true when the page is full, so older debriefs may exist). NOTE: the debriefs endpoint does not yet honor offset — it serves the most recent window.

**Arguments:** `limit` _number_ — Max debriefs to return (default 30, max 100)

### `get_landscape_briefing`

Get the latest threat-landscape briefing — a synthesized posture summary of recent threat activity. `latest` is the most recent briefing that is a REAL synthesis: the narrative model currently fails on most hourly runs, and a failed run still writes a one-bullet automated posture stub, so the newest row is usually not a summary at all. When a fallback is served the response says so (latest_is_fallback + fallback_note naming both dates) and `latest_age_hours` gives its age; `briefings[0]` is always the newest regardless of quality, and every row carries is_synthesis. narrative_ok reports whether the newest run produced a real narrative.

**Arguments:** _takes no arguments_

### `get_daily_theme`

Get the recent daily landscape themes (newest first, 14 days) with each day's top 6 threat tags. `tags` is recomputed LIVE over that date's threats, ranked count-descending with an alphabetical tie-break, and `tags_source` says whether a row used the live rollup ('live'), the stored nightly snapshot ('snapshot') or had no tags at all ('none') — `tags_available` is the boolean. The theme SENTENCE is generated just after midnight ET, before the day's ingest finishes, so `theme_note`/`snapshot_threat_count` disclose how many of the day's threats it was actually written from; when they disagree, trust `tags`.

**Arguments:** _takes no arguments_

### `get_threat_level`

Get the computed current threat-landscape level (a 0–25 rating of overall posture).

**Arguments:** _takes no arguments_


## L13 · Standards export

For handing data to another system.

### `export_stix`

Export a threat, actor, or CVE as a STIX 2.1 bundle for ingestion into a TIP/SIEM. Provide at least one of threat_id, actor, or cve_id. Returns a {type:"bundle", objects:[...]} with indicator (per IOC), attack-pattern (per MITRE technique), intrusion-set (actor), vulnerability (CVE), malware/threat-actor, and relationship objects. Set include_osint=true to add `sighting` SROs for indicators the community independently reported (TL_OSINT_Scan / tweetfeed.live, CC0) — community-sourced and heavily concentrated, so they carry x_threadlinqs_trust="community-unverified". The bundle is capped (≤200 objects / ≤80KB); a note object is appended if truncated.

**Arguments:** `threat_id` _string_ — Threat ID to export (e.g. TL-2026-0042)<br>`actor` _string_ — Threat-actor name or alias to export (e.g. "APT29")<br>`cve_id` _string_ — CVE identifier to export (e.g. CVE-2024-3400)<br>`include_osint` _boolean_ — Add community `sighting` objects for corroborated indicators (default false)

### `export_attack_navigator`

Export a MITRE ATT&CK Navigator layer (enterprise-attack) for visualization. Pass actor=<name> to score techniques attributed to one actor, or all=true for platform-wide coverage. score is PER TECHNIQUE — the number of corpus threats using it (for the actor, or platform-wide) — so the heatmap ranks within a tactic, not just between tactics; the unit is stated in the layer's description and metadata. Returns {name, versions, domain, description, techniques:[{techniqueID, score, color, comment}], gradient, metadata}. Capped at 600 techniques; when the tail is dropped, metadata carries a 'truncated' entry saying so. ATLAS (AML.*) and non-enterprise ids are excluded, since the layer domain is enterprise-attack.

**Arguments:** `actor` _string_ — Threat-actor name or alias whose techniques to score (e.g. "APT29")<br>`all` _boolean_ — If true, build a platform-wide coverage layer from MITRE coverage instead of a single actor

---

# Controlled vocabularies — every value you must not guess

Tool names here are bare. Hosts may prefix them (`mcp__threadlinqs-intel__search_threats`)
— match by suffix.

Every tool call needs tier ≥ 3 (Purple/Gold). `-32001` = missing/invalid key, `-32002` =
authenticated but under-tier. Both terminal — surface the upgrade path, do not retry.

## The single most misleading failure mode on this server

**A wrong enum value almost never errors. It returns an empty, HTTP-200, well-formed
result.**

Most filters compile to `WHERE col = ?`. A value that does not exist in the column matches
zero rows, and the tool answers `{ "data": [] }` with no complaint. An agent that guessed
`category="supply chain"` instead of `SUPPLY_CHAIN` will confidently report "no supply-chain
threats in the corpus" — against 202 of them.

Three distinct behaviours, and you have to know which one you are in:

| Behaviour | What you see | Tools |
|---|---|---|
| **Silent empty** (most common) | 200 + zero rows | `search_threats` (category/severity/status), `search_iocs` (type), `get_detections`/`search_detections` (type, severity), `get_mitre_coverage` (tactic), `get_pivotal_entities` (node_type), `get_entity_profile` / `get_correlation_subgraph` (seed_type/node_type), `resolve_entity` (type) |
| **Silent fallback to default** | 200 + *plausible but wrong* data | `get_c2` (bad `view` → `beacons`), `get_correlations` (bad `engine` → `overview`), `predict_mitre_transitions` (anything not `reverse` → `forward`), `search_vulnerabilities` (bad `sort` → `trending`) |
| **Silent filter drop** | 200 + *unfiltered* data | `search_vulnerabilities` `severity` — a value outside CRITICAL/HIGH/MEDIUM/LOW is ignored, so you get the whole feed and may report it as "all critical" |
| **Real error** | JSON-RPC error / 400 | `export_detection` (`format` validated → 400), `hunt` (unknown field → `Unknown hunt field: x`) |

The silent-fallback row is worse than the empty row: empty at least looks wrong.

## The rule: call the list tool first

Before filtering on any vocabulary you have not confirmed **in this session**:

| Want to filter on | Call first | Cost |
|---|---|---|
| threat category | `list_threat_categories` | one call, returns every category + its count |
| hunt fields / operators | `hunt_schema` (once per session, mandatory) | cheap, server-derived so it cannot go stale |
| actor / malware / tool / campaign name | `resolve_entity` | one call, returns canonical + UUID |
| MITRE tactic or technique | `get_mitre_coverage` (no `tactic` arg) | returns the tactic rollup with the exact strings |
| detection flavours in the corpus | `get_platform_stats` | includes the `detection_type` distribution |
| what a correlation engine offers | `get_correlations` with `engine="overview"` | the documented drill-down entry point |

`list_threat_categories` and `hunt_schema` exist for exactly this. Two cheap calls beat one
confident wrong answer.

Counts below are a 2026-08 snapshot of prod D1 and drift nightly. Treat them as *relative
magnitude* — "is this the majority spelling" — never as reportable figures.

---

## Threat categories (`search_threats.category`)

Exact match on `UPPER(category)`, so case does not matter but **spelling and the underscore
do**. `list_threat_categories` is the authority; this is what it currently returns.

**Load-bearing (≥8 threats):** `VULNERABILITY` 550 · `MALWARE` 465 · `SUPPLY_CHAIN` 202 ·
`PHISHING` 122 · `APT` 114 · `RANSOMWARE` 111 · `THREAT_INTEL` 77 · `DATA_BREACH` 37 ·
`ZERO_DAY` 23 · `CAMPAIGN` 14 · `THREAT_ACTOR` 12 · `ICS_SCADA` 9 · `CLOUD` 8

**Long tail (≤6, ingest noise not taxonomy):** `FRAUD` · `ADVISORY` · `AI_SECURITY` ·
`ESPIONAGE` · `INTRUSION` · `CYBERCRIME` · `MISCONFIGURATION` · `OTHER` · `POLICY` ·
`RECONNAISSANCE` · `SCAM` · `SOCIAL_ENGINEERING` · `SURVEILLANCE` · `BOTNET` · `EXTORTION` ·
`INCIDENT` · `IOT` · `NATION_STATE` · `RANSOMWARE_HACKTIVISM` · `RESEARCH` · `TOOL` · `TTP`

Traps:

- `SUPPLY_CHAIN`, not `SUPPLY-CHAIN` / `"supply chain"` / `SupplyChain`.
- There is no `CVE`, `EXPLOIT`, `INSIDER`, `DDOS`, or `CRYPTO` category — guessing any of
  them returns empty.
- Do not conclude anything from a long-tail filter. If you want "supply-chain-ish", use
  `query="supply chain"` (free text, substring) or `tag`, not a guessed category.

## Severity bands — three different vocabularies

There is no single severity vocabulary. Which one applies depends on the tool.

| Surface | Tool + arg | Accepted | Matching |
|---|---|---|---|
| Threats | `search_threats.severity` | `CRITICAL` `HIGH` `MEDIUM` `LOW` | `UPPER(severity_level) = ?` — case-insensitive, exact |
| Detections | `search_detections.severity` | `critical` `high` `medium` `low` `info` | lowercased, exact |
| CVEs | `search_vulnerabilities.severity` | `CRITICAL` `HIGH` `MEDIUM` `LOW` | uppercased; **anything else is silently dropped, not empty** |
| `hunt` | `severity = "…"` | as stored on `threats` | case-insensitive |

Corpus reality on `threats.severity_level`: `HIGH` ~1029, `CRITICAL` ~558, `MEDIUM` ~172,
plus a residue of `INFO` (11), `LOW` (8), `INFORMATIONAL` (7), `MODERATE` (1), `UNKNOWN` (1).
`severity="LOW"` returns ~8 rows and **misses** the `INFO`/`INFORMATIONAL` residue. Report
"8 rows are labelled LOW", not "only 8 low-severity threats exist". Detections carry `info`
as a real band; threats effectively do not — do not carry a band across surfaces.

## Threat status / exploitability / motivation / nation_state

`status` is exact on `UPPER(status)`:
`ACTIVE` (~1354) · `MONITORING` (187) · `PATCHED` (170) · `RESOLVED` (29) · `TRACKING` (17) ·
`DORMANT` (10) · `MITIGATED` (9) · `SUPERSEDED` (8) · `CONTAINED` (1) · `INACTIVE` (1) ·
`RESEARCH` (1). There is no `CLOSED`, `OPEN`, or `ARCHIVED`.

`exploitability` (returned, not a `search_threats` filter; filterable in `hunt`):
`ACTIVE` (~1312) · `POC_PUBLIC` (246) · `NONE` (81) · `THEORETICAL` (76) · `CONFIRMED` (4) ·
`WEAPONIZED` (3) · `ZERO-DAY` (2).

`motivation`, `nation_state`, `target_sector`, `target_region`, `affected_product`,
`threat_actor` on `search_threats` are **substring `LIKE`, not enums** — a near-miss will
not return empty. But they *are* dirty, so prefer the shortest unambiguous fragment:

- `motivation`: `FINANCIAL` 873 · `UNKNOWN` 541 · `ESPIONAGE` 296 · `DESTRUCTION` 18 ·
  `HACKTIVISM` 12, plus lowercase duplicates.
- `nation_state`: `Unknown` 1065 and `N/A` 143 dominate. Real: `Russia` 175 · `China` 113 ·
  `North Korea` 64 **and** `North Korea (DPRK)` 24 as separate strings · `Iran` 47.
  `"North Korea"` catches both DPRK spellings (substring); `"DPRK"` catches only 24.

## IOC vocabularies — the `search_iocs.type` trap

`search_iocs.type` does **not** take an indicator type. It is routed to the `category`
column and matched **exactly and case-sensitively**.

```
search_iocs(type="ip")       → []        # "ip" is an ioc_type, not a category
search_iocs(type="Network")  → []        # case-sensitive
search_iocs(type="network")  → 10,695 rows
```

Valid `search_iocs.type` values (lowercase, exact): `behavioral` 12309 · `network` 10695 ·
`file` 9846 · `entity` 3222 · `infrastructure` 2771 · `tool` 2533 · `malware` 2439 ·
`technique` 1624 · `package` 1469 · `host` 670 · `vulnerability` 169 · `cve` 40 ·
`financial` 25 · `software` 23 · `email` 11 · and `web` `application` `blockchain` `domain`
`signature` `cwe` at ≤4 rows each.

The finer **`ioc_type`** vocabulary is returned in results and is filterable through `hunt`
(`ioc_type = "sha256"`), not through `search_iocs`: `technique` 7304 · `domain` 4940 ·
`sha256` 3265 · `filename` 3009 · `ip` 2873 · `command` 2572 · `tool_name` 2509 ·
`organization` 2219 · `process` 2021 · `url` 1992 · `path` 1887 · `malware_family` 1754 ·
`package_name` 1486 · `platform` 1466 · `person` 973 · `pattern` 839 · `md5` 832 ·
`hosting` 690 · `registry` 610 · `c2_framework` 549 · `sha1` 353 · `hash` 179 · `cidr` 174 ·
`cve` 170 · `port` 122.

`hash` exists *alongside* `sha256`/`md5`/`sha1` — a legacy bucket. "All hashes" must union
all four.

`search_iocs` also **does not honor `offset`** (the response says `has_more` but paging is
not implemented). Narrow with a more specific `value` substring instead of paging.

Community indicators use a different, genuinely enumerated set —
`search_xscan_indicators.type`: `ip` · `domain` · `url` · `sha256` · `md5`. Nothing else.
There is deliberately **no family filter** on that tool (the upstream family field is
populated on <1% of rows); use `tag` instead. And community data corroborates — it is never
authoritative attribution.

`get_ioc_dns` reads **stored** DNS enrichment. It is not a live resolution at call time.
Say "stored enrichment as of the last ingest", never "I resolved it".

## Detection flavours

| Arg | Tool | Accepted | Behaviour on a wrong value |
|---|---|---|---|
| `type` | `get_detections`, `search_detections` | `spl` `kql` `sigma` | lowercased, exact → **empty** |
| `format` | `export_detection` (required) | `spl` `kql` `sigma` `json` | validated → **400 error** |

Corpus: `sigma` 5631 · `spl` 5542 · `kql` 5313, plus ~36 legacy rows typed `behavioral`,
`network`, `endpoint`, `authentication`, `integrity`, `file_integrity`, `ioc`, `process`,
`behavioral_correlation`. Those are real rows but not a supported filter surface — do not
present them as a fourth detection language.

`format="json"` returns the whole detection object; the other three return raw query text
plus `available: false` when that flavour is not populated for the rule. `available: false`
is not an error — it means that detection has no SPL/KQL/Sigma body, and you should say so
rather than reporting an empty rule.

## MITRE tactics — two spellings of the same tactic

The canonical ATT&CK order (this is what `predict_mitre_transitions` and the graph rank by):

`reconnaissance` · `resource-development` · `initial-access` · `execution` · `persistence` ·
`privilege-escalation` · `defense-evasion` · `credential-access` · `discovery` ·
`lateral-movement` · `collection` · `command-and-control` · `exfiltration` · `impact`

**But `threat_mitre.tactic` stores both a Title Case spelling and a kebab-case spelling, and
`get_mitre_coverage` matches `LOWER(tactic) = ?` exactly.** They are two different keys:

| `tactic=` you pass | Matches | ~rows |
|---|---|---|
| `"initial-access"` | only the kebab rows | 579 |
| `"initial access"` | only the Title Case rows | 2956 |

The value in the tool's own description (`initial-access`) hits the **minority** spelling
and undercounts by ~5×. Same everywhere: `Defense Evasion` 5916 vs `defense-evasion` 1665,
`Command and Control` 3636 vs `command-and-control` 827, `Execution` 3288 vs `execution` 1011.

So: call `get_mitre_coverage` with **no `tactic`** first and read the exact strings out of
the tactic rollup; if you must filter, query **both** spellings and sum, and say you did;
never report a per-tactic count from a single spelling.

ICS tactics also appear (`Inhibit Response Function`, `Impair Process Control`) — not in the
enterprise 14, so they vanish from any enterprise-only reasoning.

Technique ids are uppercased server-side (`t1059` works). `T1059` and `T1059.001` are
distinct nodes — a parent query does not roll up its sub-techniques.

## `get_c2` view

Exact enum; **an unknown value silently falls back to `beacons`**, so you get real-looking
beacon data when you asked for something else.

`beacons` (default, active beacon snapshots) · `configs` (full extracted C2 configs) ·
`operators` (operator clusters) · `watermarks` (Cobalt Strike watermark index) ·
`correlations` (cross-C2) · `timeline` (activity over time) · `stats` (aggregate counts).

There is no `clusters` view — the operator-clustering view is `operators`. There is no
`domains` view; use `get_c2_dns_intel`.

`get_c2_dns_intel.fidelity`: `dedicated` · `mixed` · `shared`. `dedicated` is
adversary-owned infrastructure; `shared` is shared hosting where a block would collateral.
The `compromised` boolean separates victim hosts from adversary-owned ones — relevant
whenever the output is going into a blocklist.

`generate_c2_blocklist` takes **no arguments at all**. Any argument object other than `{}`
is wrong. (Third-party docs claiming it takes three arguments are wrong.)

## `get_correlations` engine

Exact enum; **an unknown value silently falls back to `overview`**.

`overview` (default) · `mitre-heatmap` · `adversary-infra` · `ioc-consensus` ·
`cve-velocity` · `attribution` · `detection-debt` · `enrichment`

Hyphens, not underscores: `mitre-heatmap`, never `mitre_heatmap`. Call `overview` first —
it tells you which engines have data before you drill.

## Graph node types

`get_entity_profile.node_type`, `get_pivotal_entities.node_type`,
`get_correlation_subgraph.seed_type` all take the same enum:

`threat` · `technique` · `actor` · `ioc` · `cve`

Two things the enum does not tell you:

1. **`cve` has no rows in the centrality table.** `get_pivotal_entities(node_type="cve")`
   returns an empty list — the enum is valid, the data is not. Populated: `ioc` ~2944,
   `threat` ~1633, `technique` ~771, `actor` ~528. Use `get_cve_intelligence` instead.
2. **A wrong `seed_id` for a right `seed_type` also returns empty**, with
   `nodes: [], edges: []` and HTTP 200. Resolve the id first: `resolve_entity` for an actor
   name, an exact `TL-` id for a threat, an exact indicator value for an IOC. An
   un-canonicalized actor alias is the usual cause of an "empty graph".

`get_correlation_subgraph.depth` is 1–3 and clamps. Start at 1; depth 3 reliably blows the
response cap. Edge types are `technique_cooccurrence`, `actor_technique`,
`threat_similarity`, `ioc_threat` — there is no `actor_actor` or `cve_threat` edge, so paths
between those never exist.

`get_pivotal_entities` betweenness is an **ego-bridge heuristic**, not exact Brandes
betweenness — the payload says so in its own `note` field. Report it as a heuristic ranking.

## `predict_mitre_transitions.direction`

`forward` (default) = what typically follows · `reverse` = what typically precedes.

Anything that is not literally `reverse` (case-insensitively) becomes `forward`. So
`direction="backward"`, `direction="prior"`, `direction="back"` all silently give you the
forward chain — the opposite of what you asked for. There are exactly two values.

`predict_mitre_transitions` answers **sequence**. `get_technique_rules` answers
**co-occurrence** (support/confidence/lift on technique pairs). They are not substitutes.

## `search_vulnerabilities` — sort, window, vendor

`sort` — exact; anything else silently becomes `trending`:

| `sort` | Orders by |
|---|---|
| `trending` (default) | `trending_score DESC, priority_score DESC` — platform-native attention signal |
| `latest` | `published_date DESC` |
| `priority` | `priority_score DESC` — platform-native blended score |
| `cvss` | `cvss_v3_score DESC` (v3 only; a v4-only CVE sorts as null) |
| `epss` | `epss_score DESC` |

`trending` and `priority` are **Threadlinqs-computed scores, not standards**. Never present
them as CVSS/EPSS or as a vendor rating.

Other filter semantics:

- `severity` — `CRITICAL`/`HIGH`/`MEDIUM`/`LOW` against `cvss_v3_severity`. Anything else
  is **silently ignored** and you get the unfiltered feed. Check the returned
  `facets.severity` block before reporting a severity-scoped count.
- `window` — integer **days back from now** on `published_date`, clamped to 3650. Not a
  date, not a duration string: `window="7d"` parses as NaN and the filter is dropped.
- `vendor` — lowercased **substring** over the affected-products JSON, max 60 chars. No
  canonical vendor list. `vendor="cisco"` matches "Cisco IOS XE"; `"Cisco Systems, Inc."`
  matches nothing. Use the shortest distinctive fragment.
- `cwe` — uppercased, stripped to `[A-Z0-9-]`, substring over the weaknesses JSON. Pass
  `CWE-79`; bare `79` also substring-matches `CWE-798`.
- `kev` / `has_poc` / `nuclei` — booleans. `epss_min` — 0–1 float; `epss_percentile` is
  mixed-scale in storage and normalized on the way out, so never compare one to `epss_min`.
- The feed is **not the whole CVE table**: a row appears when
  `in_feed=1 OR is_kev=1 OR the CVE is linked to a threat`. "Not in the feed" ≠ "does not
  exist" — use `get_cve` for a specific id.

## `resolve_entity.type`

Optional. When given it is matched **exactly** against the reference set's `entity_type`,
so a wrong type turns a resolvable name into `matched: false`.

The tool description lists `actor|malware|tool|sector|region|technique|campaign`. The
reference set actually holds more: `malware` 10897 · `actor` 3996 · `technique` 2532 ·
`tool` 1448 · `mitigation` 564 · `country` 252 · `atlas` 182 · `campaign` 157 ·
`sector` 133 · `data_source` 80 · `region` 32 · `os` 31.

Practical rule: **omit `type` unless you are disambiguating.** Without it the lookup runs
across all types, does an exact-alias pass then a fuzzy pass, and tells you the
`entity_type` it found — which is usually the thing you wanted to learn. Supplying a wrong
`type` is the only way to make a resolvable name look unresolvable.

`matched: false` means "not in the reference set", not "does not exist". The response says
to use the name verbatim in that case. Do not invent a canonical form or a UUID.

## `hunt` — fields and observation types

`hunt_schema` is the authority and must be called once before your first `hunt`. Unlike
every other tool here, **`hunt` errors on an unknown field** (`Unknown hunt field: foo` in a
filter, `Unknown group-by field: foo` after `by`) rather than returning empty — so a typo is loud, but a *valid
field with a wrong value* is still silently empty.

Field names (each with aliases; see `hunt_schema` for the full alias list):
`actor` `actor_uuid` `tool` `malware` `arsenal` `ioc` `ioc_value` `ioc_type` `cve` `mitre`
`technique` `tactic` `nation` `country` `c2_ip` `watermark` `domain` `dns_ip` `severity`
`category` `threat` `threat_id` `type`/`obs` `source` `seen`/`first_seen`/`ts`
`sector` `region` (+ `target_*` forms).

`type` / `obs` filters the observation type. Real values across the ~106k rows: `ioc` 47500 ·
`mitre` 45640 · `dns` 5528 · `tool` 3030 · `malware` 2543 · `cve` 2045 · `attribution` 931 ·
`infra` 253 · `c2_beacon` 116.

There is no `detection` observation type — detections are not in the hunt index. Detection
questions go to `search_detections` / `get_mitre_gap_analysis`.

Two hunt-specific traps. `tool` and `malware` are **the same column** (`tool_lc`) with
different intent, so `malware = "LockBit"` also matches a row where LockBit was recorded as
a tool — cross with `type = "malware"` when it matters. And `tactic` inherits the **same
double-spelling problem**: use `tactic IN ("defense evasion", "defense-evasion")`, or group
by tactic and merge the pairs yourself.

One row = one observation, not one threat. `| stats count` counts observations;
`| stats count_distinct(threat) by …` (alias `dc`) counts distinct threats. There is no
`dcount` — the server rejects it outright.

## Attribution vocabularies

`get_attribution_evidence` returns these. They are the difference between an assessment and
a stub, and misreading them is a factual error, not a style issue.

| Field | Values | Meaning |
|---|---|---|
| `state` | `assessed` · `pending_research` | **`pending_research` is an unresearched intake stub.** Never present it as attribution. |
| `verdict` | `attributed` (~24) · `assessed_unattributed` (~907) | The researched conclusion |
| `method` | `research+adversarial-verify` (~516) · `intake` (~415) | `intake` = machine-stubbed, no research |
| `confidence` | `HIGH` · `MEDIUM` · `LOW` (empty on stubs) | Only meaningful when `verdict='attributed'` |
| `scope` | `operation` · `exploitation` (empty on stubs) | What the attribution covers |
| `reason_code` | `pending_research` `no_public_attribution` `generic_vuln` `report_roundup` `commodity_malware` `insufficient_evidence` `other` | Why *not* attributed |

`reason_code='pending_research'` is machine-only and always paired with
`state='pending_research'`. If you see it, the correct sentence is "this threat has not been
researched for attribution yet" — not "unattributed", and definitely not a named actor.

`threats.threat_actor` alone cannot tell you which of these you are looking at. That is the
whole reason `get_attribution_evidence` exists. If you are about to name an actor, check
`state` first.

## Community / OSINT vocabularies

Community tags (`search_xscan_indicators.tag`, `get_osint_trends`) are **free-form,
case-insensitive, normalized substrings** from the upstream feed — there is no closed list
and no validation. `tag="asyncrat"` and `tag="async"` both work; `tag="AsyncRAT_v3"`
probably does not.

`get_community_campaigns` cluster labels are **upstream AI output**, not a Threadlinqs
assessment — attribute them to the community feed explicitly. On an upstream failure the
response carries `community_error`: report "community feed unavailable", never "no campaigns
found". The layer is heavily concentrated (~73% of recent submissions from one reporter). It
corroborates. It never attributes.

## Quick reference — guess-safe vs guess-fatal

| Safe to guess (substring / free text) | Fatal to guess (exact match) |
|---|---|
| `search_threats`: `query` `threat_actor` `nation_state` `motivation` `target_sector` `target_region` `affected_product` `malware` `tool` `campaign` `os` `sector` | `search_threats`: `category` `severity` `status` |
| `search_vulnerabilities`: `query` `vendor` `cwe` | `search_vulnerabilities`: `sort` `severity` (silently dropped) |
| `search_xscan_indicators`: `tag` `q` | `search_xscan_indicators`: `type` |
| `search_detections`: `query` | `search_detections` / `get_detections`: `type` `severity` |
| `hunt`: values on `LIKE`-style fields | `search_iocs`: `type` · `get_mitre_coverage`: `tactic` · `get_c2`: `view` · `get_correlations`: `engine` · `get_entity_profile`/`get_pivotal_entities`/`get_correlation_subgraph`: `node_type`/`seed_type` · `resolve_entity`: `type` · `export_detection`: `format` |

When a filtered call returns empty, the first hypothesis is a wrong vocabulary value, not an
empty corpus. Re-run the same call **without** the suspect filter. If it returns rows, the
filter value was wrong — go call the list tool.

## Response cap

Every tool response is capped at **90,000 characters** and appends:

```
... [truncated: response exceeded 90000 chars — narrow your query
```

That marker means the payload is incomplete and **any count or "no results for X" you derive
from it is unsound**. Do not retry unchanged; do not summarize the fragment as if it were
whole. Narrow instead: add a filter from the vocabularies above, lower `limit`, switch to a
composite (`get_ioc_intelligence` over `search_iocs`, `get_cve_intelligence` over `get_cve` +
velocity + detections), or use `hunt … | stats count by <field>` so the server aggregates
instead of shipping you rows to count.

Paging is honored on `search_threats`, `get_recent_threats`, `get_detections`,
`search_xscan_indicators`. It is **not** honored on `search_iocs`, `list_debriefs`,
`search_actors` — on those, narrow rather than page.

