# Cross-Layer Patterns

> Cross-layer Cypher for WhisperGraph: sourced verdicts, blast radius, WHOIS and BGP history, physical footprint, watchlist enrichment, stable columns.

*Source: https://www.whisper.security/docs/recipes/cross-cutting*
*Published: 2026-05-04*
*Last updated: 2026-10-01*

---
This is where the use cases converge. A single indicator — a domain in a phishing email, an IP in a firewall log, an ASN in a routing alert — rarely tells you enough on its own. These recipes take you to the composed answer: **attribution** (whose network), **the verdict** (is it bad, and who says so), **blast radius** (what else moves with it), **history** (what it was before), and **identity** (what kind of host it is) in one sourced report — and then to the batch and automation shapes that run the same answer over a whole list, or from a scheduled job, without the columns shifting. On a pre-joined graph each of those is a handful of anchored traversals, and an AI agent can run the whole sequence over MCP without you touching a keyboard.

Every pattern below is copy-paste against `https://graph.whisper.security/api/query`. See [Getting Started](/docs/whisper-graph/getting-started) for the key, the [Graph Schema](/docs/whisper-graph/schema) for the full model, and [Procedures](/docs/whisper-graph/procedures) for the `CALL` signatures.

**Key concepts:** [Blast radius](/glossary/blast-radius) · [Reconciled verdict](/glossary/reconciled-verdict) · [Coverage-qualified assessment](/glossary/coverage-qualified-assessment) · [MOAS conflict](/glossary/moas-conflict) · [MCP](/glossary/mcp).

---

## Recipe 1 — Paste one domain, get a sourced report

**What the graph does:** "what is `paypal.com`, who runs it, is it clean, and what's its registrar posture?" is one statement: anchor once on the hostname and fan out across DNS, routing, geo, registrar, and the threat posture. Every value is a graph edge you can cite.

```cypher expect=rows>0,no-null-columns seed=paypal.com verified=2026-09-02
// One domain → resolution, network owner, country, registrar, threat posture
MATCH (h:HOSTNAME {name: "paypal.com"})-[:RESOLVES_TO]->(ip:IPV4)
OPTIONAL MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(co:COUNTRY)
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(reg:REGISTRAR)
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn, n.name AS network,
       city.name AS city, co.name AS country, reg.name AS registrar,
       ip.isThreat AS isThreat, ip.threatScore AS threatScore
LIMIT 5
```

> **Tip:** Keep the routing, name, and geo legs as `OPTIONAL MATCH` — anycast and CDN IPs frequently lack a city, and optional matches return the row anyway instead of dropping it. The threat properties (`threatScore`, `threatLevel`, `isThreat`, `isTor`, `isAnonymizer`) live on the node itself. Return `isThreat` and `threatScore` for the posture: both are always set — `false` and `0.0` on an address no feed has listed. `threatLevel` is only written once a feed lists the address, so on a clean one the column comes back empty, which reads like missing data when it is actually the verdict. When the list is longer than one, `whisper.enrich` does this join server-side for every indicator at once — see [Working in batches](#working-in-batches).

---

## Recipe 2 — The full verdict with its evidence chain

**What the graph does:** `explain()` auto-detects the indicator type and returns the score *and* the arithmetic behind it — every contributing feed, its weight, and its first/last-seen.

The `factors` and `sources` arrays are an inspectable evidence chain you can paste straight into a ticket, which is what makes the verdict defensible when someone asks why the score is what it is. The same call works on a domain, IP, ASN, or CIDR.

```cypher expect=rows>0,no-null-columns seed=185.220.101.1 verified=2026-09-02
// Scored verdict + the exact feeds and factors behind it
CALL explain("185.220.101.1")
YIELD indicator, type, found, score, level, explanation, factors, sources
RETURN indicator, type, found, score, level, explanation, factors, sources
```

A run of that on 2026-08-09 returned:

```json
[{
  "indicator": "185.220.101.1",
  "type": "ip",
  "found": true,
  "score": 21.440094319478078,
  "level": "LOW",
  "explanation": "185.220.101.1 is listed in 6 threat feed(s). Score 21.4 (Low - limited risk).",
  "factors": [
    "Listed in 6 source(s) with combined weight 6.00",
    "Base score: 6.00 × log₂(6 + 1) = 16.84",
    "Recency boost: ×1.2 (last seen 19 hours ago)",
    "Age boost: ×1.06 (on lists for 5 days)",
    "Final score: 16.84 × 1.2 × 1.06 = 21.44"
  ],
  "sources": [
    {"feedId": "tor-exit-nodes", "weight": 0.5, "firstSeen": "2026-08-03T16:21:48.400511263Z", "lastSeen": "2026-08-08T10:06:47.505353314Z"},
    {"feedId": "firehol-abusers-1d", "weight": 1.5, "firstSeen": "2026-08-03T16:21:20.771305802Z", "lastSeen": "2026-08-07T07:51:07.776194553Z"},
    {"feedId": "greensnow", "weight": 1.0, "firstSeen": "2026-08-03T16:22:08.761536178Z", "lastSeen": "2026-08-06T07:38:10.889494390Z"},
    {"feedId": "firehol-level2", "weight": 1.3, "firstSeen": "2026-08-04T17:38:44.217845576Z", "lastSeen": "2026-08-04T17:38:44.217845576Z"},
    {"feedId": "stopforumspam-listed-ip-7d", "weight": 0.5, "firstSeen": "2026-08-03T16:22:10.328361154Z", "lastSeen": "2026-08-06T07:38:35.857131131Z"},
    {"feedId": "stamparm-ipsum", "weight": 1.2, "firstSeen": "2026-08-03T16:22:08.606554919Z", "lastSeen": "2026-08-08T23:45:58.114818276Z"}
  ]
}]
```

> **Tip:** Paste `factors` and `sources` straight into the case notes — they're the citation. The verdict reflects whichever feeds are currently loaded, so treat the score as a live read, not a fixed number. Name the columns you want in `YIELD`: a bare `CALL explain(...)` also hands back an `advisory` column that only some indicators carry (`explain("1.1.1.1")` returns `allowlist-vouched`; most indicators return nothing there), and an empty column in the middle of a report invites the wrong conclusion. `YIELD *` is rejected on `explain`, because the emitted columns depend on the indicator type. `CALL explain("AS13335")` and `CALL explain("185.220.101.0/24")` work the same way for an ASN or a CIDR range. To keep only the feeds that drove the verdict, `UNWIND sources AS s` and filter `WHERE s.weight >= 1.0`.

---

## Recipe 3 — Blast radius from one indicator to the campaign

**What the graph does:** co-tenancy, shared-registrant, and shared-infrastructure pivots are all one hop away from the same anchor. This finds every sibling domain on the same IP and every domain sharing the WHOIS contact email, in one round-trip.

```cypher expect=rows>0 seed=paypal.com verified=2026-09-02
// One domain → co-tenant siblings + shared-registrant domains
MATCH (h:HOSTNAME {name: "paypal.com"})-[:RESOLVES_TO]->(ip:IPV4)
MATCH (ip)<-[:RESOLVES_TO]-(sibling:HOSTNAME)
WHERE sibling.name <> h.name
WITH h, collect(DISTINCT sibling.name)[..15] AS co_tenants
OPTIONAL MATCH (h)-[:HAS_EMAIL]->(e:EMAIL)<-[:HAS_EMAIL]-(shared:HOSTNAME)
WHERE shared.name <> h.name
RETURN co_tenants, collect(DISTINCT shared.name)[..15] AS shared_registrant
LIMIT 1
```

> **Tip:** Co-tenancy on a big shared host or CDN IP is high-fan-out by design — the `collect(...)[..15]` bound keeps it fast and honest. Shared *registrant* email is the higher-signal pivot: it ties a domain to its operator across IP and registrar changes. The same shape works on `HAS_REGISTRAR` and `NAMESERVER_FOR`. Raw registrant organisation strings vary in spelling; `(:ORGANIZATION)-[:SAME_ORG_AS]->(:ORGANIZATION)` folds them to one canonical company.

---

## Recipe 4 — What it was before: WHOIS + BGP history

**What the graph does:** a registrar that changed last week, or a prefix whose origin AS flipped, is the lead — and the timeline is where it shows.

The history procedures return timestamped snapshots: `whisper.history.whois()` the WHOIS history for a domain (create/update dates, registrar, registrant, nameservers), `whisper.history.bgp()` the routing history for an IP/ASN/prefix (which network announced a block, and when).

```cypher expect=rows>0 seed=paypal.com verified=2026-09-02
// Registrar + nameserver history for a domain (needs an API key)
CALL whisper.history.whois("paypal.com")
YIELD createDate, updateDate, registrar, registrant, nameServers
RETURN createDate, updateDate, registrar, registrant, nameServers
LIMIT 5
```

You can also read the current registrar transition straight off the edges — `HAS_REGISTRAR` is the current registrar, `PREV_REGISTRAR` any prior one:

```cypher expect=rows>0 seed=paypal.com verified=2026-09-02
// Current vs. prior registrar in one shot
MATCH (h:HOSTNAME {name: "paypal.com"})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(cur:REGISTRAR)
OPTIONAL MATCH (h)-[:PREV_REGISTRAR]->(prev:REGISTRAR)
RETURN h.name AS domain, cur.name AS current_registrar,
       collect(DISTINCT prev.name) AS prior_registrars
LIMIT 1
```

> **Tip:** The history procedures are the one composite-report ingredient that needs a key, so [sign in](https://console.whisper.security/sign-in?redirect_url=https%3A%2F%2Fwww.whisper.security%2Fdocs%2Frecipes%2Fcross-cutting) to run them — there is no card to enter. Each single-shape variant emits a fixed column set, which is what makes them safe to schedule ([Built for automation](#built-for-automation)). BGP history over a large network can take many seconds: keep a `LIMIT` on it and expect a longer round trip than an anchored read.

---

## Recipe 5 — Identity is not a verdict (gate on coverage)

**What the graph does:** *whose* infrastructure something is and *whether it's dangerous* are different questions, and two procedures answer them separately.

`whisper.identify()` tells you what the host is — the vendor behind it, its category, and its tenancy class; `whisper.assess()` gives a verdict **qualified by coverage**, so "no data" reads as "unknown," never "benign." The separation is what keeps "this host is on AWS, and AWS hosts malware, so this host is suspicious" out of your findings.

```cypher expect=rows>0 seed=github.com verified=2026-09-02
// Whose infrastructure is this host?
CALL whisper.identify(["github.com"])
YIELD host, vendor_id, canonical_name, category, roles, host_class, band
RETURN host, vendor_id, canonical_name, category, roles, host_class, band
LIMIT 5
```

```cypher expect=rows>0 seed=github.com verified=2026-09-02
// Is it dangerous — and how much do we actually know?
CALL whisper.assess(["github.com"])
YIELD host, label, band, coverage, evidence
RETURN host, label, band, coverage, evidence
LIMIT 5
```

> **Read `coverage` before `band`.** Only `known-clean` licenses the word "clean"; `no-data` means
> *unknown*, which is a different thing again; `malicious-evidenced` and `ambiguous` mean there is
> evidence, whatever the band says.
> Full contract: [Coverage — what we looked at](/docs/whisper-graph/procedures/coverage).

> **Tip:** Read `host_class` too: `multi_tenant_user_content` means anyone can publish there, so one bad URL doesn't condemn the domain. When `identify` finds no direct match, `CALL whisper.walk("example.com")` returns the bounded structural neighborhood instead. Both procedures also accept a single host string, and a full URL folds down to its host — see [Can I pass a URL instead of a host?](#can-i-pass-a-url-instead-of-a-host).

---

## Recipe 6 — The full investigation in one query

**What the graph does:** the report your lead wants — attribution, verdict, registrant, geo, mail posture — is one traversal, because every layer shares one anchor. This is the paste-one-indicator → sourced-report flow, end to end.

```cypher expect=rows>0,no-null-columns seed=paypal.com verified=2026-09-28
// Composite report: owner + geo + registrar + registrant + mail + threat
MATCH (h:HOSTNAME {name: "paypal.com"})
OPTIONAL MATCH (h)-[:RESOLVES_TO]->(ip:IPV4)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
              -[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(co:COUNTRY)
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(reg:REGISTRAR)
OPTIONAL MATCH (h)-[:REGISTERED_BY]->(org:ORGANIZATION)
OPTIONAL MATCH (h)<-[:MAIL_FOR]-(mx:HOSTNAME)
WITH h, ip, ap, n, co, reg, collect(DISTINCT org.name) AS registrants, collect(DISTINCT mx.name)[..5] AS mail_servers
RETURN h.name AS domain, ip.name AS ip, ap.name AS prefix,
       n.name AS network, co.name AS country, reg.name AS registrar,
       registrants, mail_servers,
       ip.isThreat AS isThreat, ip.threatScore AS threatScore
LIMIT 5
```

> **Tip:** `MAIL_FOR` points **server → domain**, so a domain's mail servers are reached *backwards*: `(domain)<-[:MAIL_FOR]-(mx)`. The same direction trap applies to `NAMESERVER_FOR`. WHOIS carries several org entries (registrant, admin, technical) for one domain, so fold `org` into `collect(DISTINCT org.name)` the same way as the mail leg — leaving it ungrouped repeats the whole row once per organization on file, which reads as duplicate data rather than one domain with several registrant strings.

---

## Recipe 7 — Cross-persona pivot: domain → typosquats → verdict → owner

**What the graph does:** brand protection (find lookalikes), threat intel (are they bad?) and attribution (who runs them?) chain into one query flow. The generator comes first — [the typosquat recipe](/docs/recipes/soc#catch-the-lookalike-domain-behind-the-lure) returns the registered lookalikes of a brand. The query below then takes those hits through a resolution check for where they point and whose network:

```cypher expect=rows>0,no-null-columns seed=paypall.com verified=2026-09-02
// 2) Triage the lookalikes: where they point, whose network
UNWIND ["paypall.com", "payppal.com"] AS d
MATCH (h:HOSTNAME {name: d})-[:RESOLVES_TO]->(ip:IPV4)
OPTIONAL MATCH (ip)-[:ANNOUNCED_BY]->(:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
RETURN d AS lookalike, ip.name AS ip, ip.threatLevel AS threat, a.name AS asn
LIMIT 10
```

> **Tip:** `exists: true` from `whisper.variants()` means *registered*, not *malicious* — the query above is what turns a candidate into a lead. Both of these doubled-letter lookalikes land on one address on one network, which is the shape worth chasing: pull that IP's co-tenants (Recipe 3) to see the rest of the cluster, and run `explain()` on it for the feeds behind its threat level. A lookalike whose `threatLevel` comes back empty simply isn't on a feed yet — judge it on where it points and what it shares with, not on the absent score. An agent does all the legs back-to-back; see Recipe 10.

---

## Recipe 8 — Routing-layer cross-check: hijack signal + RPKI + owner

**What the graph does:** a MOAS alert ("this prefix is announced by two ASNs") only means something next to whether the competing origin is RPKI-authorized and who owns the address space. The MOAS conflict, the ROA authorization and the owner all hang off the same prefix.

`CONFLICTS_WITH` names the competing origins (the announcer itself is excluded), `moasIsLegitimate` is the graph's read on whether the multi-origin state looks like normal multi-homing (a `null` means the conflict has not been assessed yet, not that it looks fine), and a ROA that reaches *both* the origin (`ROA_AUTHORIZES_ORIGIN`) and this exact block (`ROA_AUTHORIZES_PREFIX`) is the one that tells you RPKI actually backs this announcement. Multi-origin state is live routing data, so lead with the discovery form, which finds whatever is in conflict right now.

```cypher expect=static verified=2026-09-02 reason="multi-origin conflict state changes as prefixes are withdrawn and re-announced, so this block shows a captured result rather than a live run"
// Prefixes in conflict right now → conflicting origins + RPKI ROAs authorizing each for this block
MATCH (ap:ANNOUNCED_PREFIX)-[:CONFLICTS_WITH]->(origin:ASN)
WITH ap, origin LIMIT 15
OPTIONAL MATCH (p:PREFIX {name: ap.name})<-[:ROA_AUTHORIZES_PREFIX]-(roa:ROA)-[:ROA_AUTHORIZES_ORIGIN]->(origin)
WHERE roa.authSource = "rpki-roa"
RETURN ap.name AS prefix, ap.moasIsLegitimate AS looks_legitimate,
       origin.name AS conflicting_origin, count(roa) AS authorizing_roas
LIMIT 15
```

**Sample output** (captured 2026-09-02):
```json
[
  {"prefix": "164.163.138.0/24", "looks_legitimate": false, "conflicting_origin": "AS1", "authorizing_roas": 0},
  {"prefix": "191.241.191.0/24", "looks_legitimate": false, "conflicting_origin": "AS10", "authorizing_roas": 0},
  {"prefix": "195.74.62.0/23", "looks_legitimate": false, "conflicting_origin": "AS10", "authorizing_roas": 0}
]
```

Empty result: `CONFLICTS_WITH` and the `ROA_*` edges are coverage-scoped routing layers. Zero rows from the discovery form means nothing is in conflict in the current table; zero rows from the anchored form (`MATCH (ap:ANNOUNCED_PREFIX {name: "<prefix>"})-[:CONFLICTS_WITH]->(origin:ASN)`) means that prefix is not currently in conflict, which is the answer you were hoping for. A count of zero in `authorizing_roas` means no ROA was issued for this exact block — RPKI can still cover the announcement through a shorter covering prefix, so read it as "nothing authorizes it here," not as proof of a hijack.

> **Tip:** To check one prefix you care about, swap the first line for the anchored form above; reach the prefix from any IP you're watching via `(ip:IPV4 {name: "..."})-[:ANNOUNCED_BY]->(ap)`, where `isMoas` is the quick yes/no. Walk both ROA legs, not just the origin one: `ROA_AUTHORIZES_ORIGIN` on its own counts every ROA that names that AS anywhere in the address space, which for a large transit network runs to four figures and says nothing about the block in front of you. The RPKI side of a ROA lands on a `PREFIX` node, not on the `ANNOUNCED_PREFIX`, so join them by name. Any specific example prefix will eventually settle, which is why the discovery form is the one to build on and the anchored form is the one to schedule against your own space. For the full scored picture, `CALL explain("AS<number>")` rolls up an AS's threat posture.

---

## Recipe 9 — Physical + logical footprint of a network

**What the graph does:** the physical side — "where does Cloudflare's network physically sit, and where does it peer?" — is the same query surface as everything else: an ASN connects to the buildings it occupies (`AS_PRESENT_AT`) and the exchanges it joins (`IX_MEMBER`). This is the attribution story's last mile.

```cypher expect=rows>0 seed=AS13335 verified=2026-09-02
// AS13335 (Cloudflare): facilities + internet exchanges
MATCH (a:ASN {name: "AS13335"})
OPTIONAL MATCH (a)-[:AS_PRESENT_AT]->(f:FACILITY)
WITH a, collect(DISTINCT f.name)[..10] AS facilities
OPTIONAL MATCH (a)-[:IX_MEMBER]->(ix:INTERNET_EXCHANGE)
RETURN a.name AS asn, facilities,
       collect(DISTINCT ix.name)[..10] AS internet_exchanges
LIMIT 1
```

> **Tip:** Both legs are high-fan-out for a large transit network, so each gets its own bounded `collect`. Anchor an ASN with the `AS`-prefixed `name` (`AS13335`), never a bare number, and avoid `CONTAINS` on `ASN.name` — it falls back to a scan. For peering adjacency use `BGP_NEIGHBOR` (`ASN → ASN`); it also works inside a variable-length pattern, and on an undirected peering walk add `WHERE n <> a`, because a mesh routinely returns to the origin AS.

---

## Recipe 10 — Run it autonomously over MCP

**What the graph does:** WhisperGraph is [MCP-native](/products/integrations#mcp). A multi-hop investigation collapses into one tool call, and every claim cites the exact Cypher that ran. One traversal returns the joined answer, so the agent spends its context on the investigation rather than on stitching results together.

Point any MCP client (Claude, ChatGPT, Cursor) at `https://mcp.whisper.security` and the agent gets seven read-only tools: `query` for raw Cypher (every pattern on this page, procedures included), `explain_indicator` for one-call verdicts with coverage, `identify` for who runs a host, `explain_schema` for schema introspection, `read_docs` for these docs, and `list_workflows` / `run_workflow` to discover and execute the guided investigations from the [workflow gallery](/docs/ai/mcp/workflow-gallery) by slug.

The same composite report from Recipe 6, as a REST call an agent (or your SOAR) makes directly:

```bash expect=rows>0,no-null-columns seed=paypal.com verified=2026-08-09
curl -s -A "whisper-client/1.0" \
  https://graph.whisper.security/api/query \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $WHISPER_API_KEY" \
  -d '{"query":"MATCH (h:HOSTNAME {name:\"paypal.com\"})-[:RESOLVES_TO]->(ip:IPV4)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME) RETURN ip.name, n.name, ip.isThreat, ip.threatScore LIMIT 5"}'
```

A typical agent loop runs the patterns in sequence on one indicator: **identify** the host class → **explain** the verdict with its sources → pivot **blast radius** for siblings → pull **history** for the registrar or origin change → and write the citation from the edges it walked. An agent that generates its own Cypher should be handed the [Graph Schema](/docs/whisper-graph/schema): a model working from a schema it was not given will confidently invent labels this graph has never had (`Domain`, `Certificate`, `TECHNIQUE`), and those now error rather than returning empty. See the [AI & Agents section](/docs/ai) and [MCP setup](/docs/ai/mcp/setup) to connect a client.

---

## From a submarine cable to the clicks it carries

WhisperGraph joins the physical internet to the logical one, so you can trace a subsea cable to the data centers it lands near, the networks present there, the prefixes they route, and ultimately the addresses served — the full physical-to-DNS chain. Run it a step at a time, then chain the whole path in one statement.

**1 · Where the cable lands, and the facilities nearby:**

```whisper-run expect=rows>0 seed=2Africa verified=2026-09-02
MATCH (cab:SUBMARINE_CABLE {name: "2Africa"})-[:CABLE_LANDS_AT]->(l:CABLE_LANDING)-[:LANDING_NEAR]->(f:FACILITY)
RETURN l.name AS landing, f.name AS facility
LIMIT 15
```

**2 · The networks present at one of those facilities:**

```cypher expect=rows>0 verified=2026-09-02
MATCH (f:FACILITY {name: "Teraco CT1 Cape Town, South Africa"})<-[:AS_PRESENT_AT]-(a:ASN)
RETURN a.name AS network
LIMIT 15
```

**3 · The prefixes a network routes** (each resolves on to IPs and the hostnames they serve):

```cypher expect=rows>0 seed=AS37662 verified=2026-09-02
MATCH (a:ASN {name: "AS37662"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
RETURN p.name AS prefix
LIMIT 15
```

Or collapse the chain into a single traversal:

```cypher expect=rows>0 seed=2Africa verified=2026-09-02
MATCH (cab:SUBMARINE_CABLE {name: "2Africa"})-[:CABLE_LANDS_AT]->(:CABLE_LANDING)
      -[:LANDING_NEAR]->(f:FACILITY)<-[:AS_PRESENT_AT]-(a:ASN)
RETURN f.name AS facility, collect(DISTINCT a.name)[0..10] AS networks
LIMIT 10
```

That is the cable-to-clicks path: physical infrastructure on one end, the networks and routes that turn it into reachable services on the other — one graph, one query language.

## Working in batches

Most real work arrives as a list: a watchlist of domains, a page of firewall IPs, a client's whole brand portfolio. Sending fifty single-indicator queries is the slowest way to answer that, and the round trip you save is the smaller half. What matters is what comes back: one answer per indicator that already carries the join — the verdict *and* the operator *and* the routing posture *and* the country, resolved against each other before it left the server. Start here before you write a loop.

### How do I get a verdict with coverage for a whole watchlist?

Your pipeline pulled a handful of indicators off an alert. You want a verdict for each, plus enough context to know whether the verdict means anything, without firing one request per indicator. `coverage` is the column that decides whether you may close on clean.

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
// One row per indicator, with coverage so you can tell no-data from clean
CALL whisper.assess(["1.1.1.1", "185.220.101.1", "google.com", "8.8.8.8", "45.148.10.35"])
YIELD host, label, band, coverage
RETURN host, label, band, coverage
LIMIT 10
```
**Returns:** `host, label, band, coverage`

**Sample output** (captured 2026-09-02):
```json
[
  {"host": "1.1.1.1", "label": "benign-allowlisted", "band": "INFO", "coverage": "known-clean"},
  {"host": "185.220.101.1", "label": "ambiguous", "band": "LOW", "coverage": "ambiguous"},
  {"host": "google.com", "label": "benign-allowlisted", "band": "NONE", "coverage": "known-clean"}
]
```

**Costs:** one procedure call, no traversal, mixed IPs and hostnames in one list; the exact columns are `host, label, band, sub_labels, signals, coverage, evidence, verdictScore, isThreat, threatSources, company_apex, company_linkage, company_name, company_sector, company_hq_country, pl_generation`, and a `YIELD` naming anything else is rejected rather than ignored. The last six describe the company behind the host; without company data on your access, `company_name`, `company_sector` and `company_hq_country` come back empty.

> **Tip:** `known-clean` means the graph has positive evidence this indicator is benign; `ambiguous` means the evidence points both ways; a no-data answer means nobody has said anything about it either way — which is not the same as safe. Read `coverage` before you act on `band`. A single host string works too when the list is one long.

**From here, →** [How do I get owner, network, country and band for a mixed list?](#how-do-i-get-owner-network-country-and-band-for-a-mixed-list)

### How do I get owner, network, country and band for a mixed list?

Your pipeline needs more than a verdict per indicator — it needs the joined answer: who operates the address, which network announces it, where that network is registered, and how bad it looks. That is the row a SOAR enriches an alert with, for IPs and hostnames alike, in one call.

```cypher expect=rows>0 seed=github.com verified=2026-09-02
// One call: owner, country, ASN and threat band for a mixed list of indicators
CALL whisper.enrich(["1.1.1.1", "8.8.8.8", "github.com"])
YIELD name, owner, country, asn, band, prevalence, coverage
RETURN name, owner, country, asn, band, prevalence, coverage
LIMIT 10
```
**Returns:** `name, owner, country, asn, band, prevalence, coverage`

**Sample output** (captured 2026-09-02):
```json
[
  {"name": "1.1.1.1", "owner": "Cloudflare, Inc.", "country": "US", "asn": "AS13335", "band": "INFO", "prevalence": null, "coverage": "full"},
  {"name": "8.8.8.8", "owner": "Google LLC", "country": "US", "asn": "AS15169", "band": "INFO", "prevalence": null, "coverage": "full"},
  {"name": "github.com", "owner": "GitHub, Inc.", "country": "US", "asn": "AS36459", "band": "NONE", "prevalence": 10, "coverage": "full"}
]
```

**Costs:** one procedure call that does the host → IP → prefix → ASN join server-side, so it costs you no traversal at all; rows are de-duplicated by canonical name, so the output is not positionally aligned to your input array — join back by `name`, never by index.

> **Tip:** three things to hold onto. `owner` is a *network* attribution, not a threat attribution — it names the operator of the origin AS behind one representative resolved IP, which is why a malicious host on a reputable cloud shows a reputable owner. `prevalence` is a popularity rank where lower is more prevalent, `null` means unranked, and it is only ever populated for hostnames. And the same semantics arrive on the response envelope as an `enrich-semantics` advisory, so a client can read them rather than remember them ([What is the response telling me outside the rows?](#what-is-the-response-telling-me-outside-the-rows)).

**From here, →** [How do I score a list of indicators in one round trip?](#how-do-i-score-a-list-of-indicators-in-one-round-trip) when you need the numeric score behind the band.

### How do I score a list of indicators in one round trip?

You want the numeric score and level for every indicator in a batch so you can sort and threshold downstream. `UNWIND` turns the list into rows and each row gets its own `explain()`.

```cypher expect=rows>0 seed=45.148.10.35 verified=2026-09-02
// Verdict for a whole watchlist in one round trip
UNWIND ["1.1.1.1", "45.148.10.35", "8.8.8.8"] AS ind
CALL explain(ind) YIELD indicator, score, level
RETURN indicator, score, level
LIMIT 10
```
**Returns:** `indicator, score, level`

**Sample output** (captured 2026-09-02):
```json
[
  {"indicator": "1.1.1.1", "score": 0.85, "level": "INFO"},
  {"indicator": "45.148.10.35", "score": 16.33, "level": "LOW"},
  {"indicator": "8.8.8.8", "score": 0.85, "level": "INFO"}
]
```

**Costs:** one round trip but one `explain()` per row on the server, so keep the list to what you will actually act on; for the cheap batch path read `verdictLevel` and `verdictBlocking` straight off the nodes instead.

> **Tip:** `explain()` changes its column set depending on what you hand it — a domain, an IP, an ASN and a CIDR do not all return the same fields. Name the columns you want in `YIELD` and keep them from a single shape. `indicator`, `score` and `level` are safe together for every indicator type; mixing in a column that only exists for one shape is rejected up front rather than returning a half-empty row.

**From here, →** [Recipe 2 — The full verdict with its evidence chain](#recipe-2-the-full-verdict-with-its-evidence-chain) for the sources behind any one score.

### How do I pull registration history for a list of domains?

You are profiling a portfolio of lookalike domains and want each one's registration timeline — created, updated, expiry, registrar, registrant — to spot the ones registered in a burst or quietly transferred. One call, one row per historical snapshot.

```cypher expect=rows>0 seed=booking.com verified=2026-09-02
// One call, a whole watchlist of registration histories
UNWIND ["booking.com", "airbnb.com", "expedia.com"] AS d
CALL whisper.history.whois(d)
YIELD indicator, createDate, updateDate, expiryDate, registrar, registrant, country
RETURN indicator, createDate, updateDate, expiryDate, registrar, registrant, country
LIMIT 10
```
**Returns:** `indicator, createDate, updateDate, expiryDate, registrar, registrant, country`

**Sample output** (captured 2026-09-02):
```json
[
  {"indicator": "booking.com", "createDate": "1998-04-17", "updateDate": "2021-02-26", "expiryDate": "2021-04-15", "registrar": "MarkMonitor, Inc.", "registrant": "Booking.com B.V.", "country": "NL"},
  {"indicator": "booking.com", "createDate": "1998-04-17", "updateDate": "2024-08-02", "expiryDate": "2025-04-16", "registrar": "MarkMonitor, Inc.", "registrant": "Booking.com B.V.", "country": "NL"}
]
```

**Costs:** one history call per row, so the `LIMIT` caps snapshots, not domains — raise it or page when the list is long; needs an API key.

> **Tip:** you get one row per historical snapshot, not one per domain — that is the point. Sort by `updateDate` to see the ownership timeline and watch for a `registrant` that changes; older snapshots also frequently carry a real registrant where the current record says "REDACTED FOR PRIVACY". A subdomain in the list folds up to its registrable parent, and `registrableDomain` names which.

**From here, →** [How do I query history from a scheduled job without the columns shifting?](#how-do-i-query-history-from-a-scheduled-job-without-the-columns-shifting)

### How do I list subdomains for several apexes without one starving the rest?

You have a handful of apex domains and want their known subdomains without one query per domain — and without a fan-out that stalls on the big one. The bound goes before the aggregation, and a `shown` column tells you when you hit it.

```cypher expect=rows>0 seed=stripe.com verified=2026-09-02
// Bounded subdomain pull for a list of apexes
UNWIND ["stripe.com", "github.com"] AS d
MATCH (h:HOSTNAME {name: d})<-[:CHILD_OF]-(sub:HOSTNAME)
WITH d, sub LIMIT 200
RETURN d AS domain, collect(sub.name)[0..10] AS subdomains, count(sub) AS shown
LIMIT 10
```
**Returns:** `domain, subdomains, shown`

**Sample output** (captured 2026-09-02):
```json
[
  {"domain": "stripe.com", "subdomains": ["_custom-email-domain.stripe.com", "_spf.stripe.com", "answers.stripe.com", "api.stripe.com"], "shown": 87},
  {"domain": "github.com", "subdomains": ["0.github.com", "000.github.com", "00010011.github.com", "001.github.com"], "shown": 113}
]
```

**Costs:** one indexed anchor per apex and one reverse `CHILD_OF` hop, capped in total before the `collect`; `CHILD_OF` points upward (`sub → apex`), so the subdomains sit on the reverse arrow.

> **Tip:** the `WITH d, sub LIMIT 200` is doing the real work — it caps the total rows *before* the collect, so one large domain in the list cannot take everything. `shown` tells you when you hit the cap: in the sample the two values sum to the bound, so the second apex received only what the first left over. Raise the bound, or page the big domain separately with a stable `ORDER BY sub.name SKIP … LIMIT …`. A `collect(...)[0..N]` slice on its own does not bound anything — it is applied after the collect has materialised every row.

**From here, →** [Recipe 3 — Blast radius from one indicator to the campaign](#recipe-3-blast-radius-from-one-indicator-to-the-campaign) once you have the names.

### Which SaaS vendor owns each of these egress addresses?

Your firewall log is full of outbound destinations and you want to know which are just SaaS platforms your own company uses before anyone gets paged. The vendor is the attribution when there is one; the ASN is the fallback when there is not.

```cypher expect=rows>0 seed=13.107.42.14 verified=2026-09-02
// Which SaaS vendor owns each of these egress addresses?
UNWIND ["13.107.42.14", "1.1.1.1"] AS x
MATCH (ip:IPV4 {name: x})
OPTIONAL MATCH (ip)-[:DELEGATED_TO]->(v:VENDOR)
OPTIONAL MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
RETURN x AS ip, collect(DISTINCT v.name)[0..3] AS vendors, a.name AS asn
LIMIT 10
```
**Returns:** `ip, vendors, asn`

**Sample output** (captured 2026-09-02):
```json
[
  {"ip": "13.107.42.14", "vendors": ["azure"], "asn": "AS8068"},
  {"ip": "1.1.1.1", "vendors": [], "asn": "AS13335"}
]
```

**Costs:** one indexed anchor per address, one optional vendor hop and one optional two-hop routing arm; both arms must stay `OPTIONAL`, because a plain `MATCH` on the vendor step would silently drop every IP that has no vendor, which is most of them.

> **Tip:** an empty `vendors` list is a real answer — it means the address is not in a published SaaS egress range, so the ASN is your best attribution. `VENDOR` names are lowercase slugs (`azure`, `aws`, `okta`). When the delegation sits on the prefix rather than the address, walk `(ip)-[:BELONGS_TO]->(:PREFIX)-[:DELEGATED_TO]->(v)` instead.

**From here, →** [Which of these source IPs are Tor, VPN or proxy infrastructure?](#which-of-these-source-ips-are-tor-vpn-or-proxy-infrastructure)

### Which of these source IPs are Tor, VPN or proxy infrastructure?

Before you treat a set of source IPs as attributable, you want to know what kind of egress they are: a Tor exit, a commercial VPN or anonymizer, or a bulk-storage/paste destination. `egressClasses` names the kind directly, and it is the field to read here rather than picking through the individual boolean flags.

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-28
// Flag anonymising and exfiltration infrastructure across a batch of indicators
UNWIND ["185.220.101.1", "1.1.1.1", "8.8.8.8"] AS n
MATCH (x:IPV4 {name: n})
RETURN x.name AS indicator,
       coalesce(x.isTor, false) AS tor,
       coalesce(x.egressClasses, []) AS egress_classes,
       coalesce(x.isAnonymizer, false) AS anonymizer,
       coalesce(x.isExfilDestination, false) AS exfil_destination,
       x.threatLevel AS threat_level
LIMIT 10
```
**Returns:** `indicator, tor, egress_classes, anonymizer, exfil_destination, threat_level`

**Sample output** (captured 2026-09-28):
```json
[
  {"indicator": "185.220.101.1", "tor": true, "egress_classes": ["tor-exit"], "anonymizer": true, "exfil_destination": false, "threat_level": "LOW"},
  {"indicator": "1.1.1.1", "tor": false, "egress_classes": ["commercial-anonymizer"], "anonymizer": true, "exfil_destination": false, "threat_level": "INFO"},
  {"indicator": "8.8.8.8", "tor": false, "egress_classes": ["commercial-anonymizer"], "anonymizer": true, "exfil_destination": false, "threat_level": "INFO"}
]
```

**Costs:** indexed reads only, no traversal; wrap each flag in `coalesce(..., false)` (or `coalesce(..., [])` for the list) so an address the graph has never seen returns an empty value rather than `null`, or your downstream code has to handle three states instead of two.

> **Tip:** `anonymizer` is broader than `tor` — it also covers public resolvers and privacy relays, which is why `1.1.1.1` and `8.8.8.8` both trip it: Cloudflare's and Google's own public resolvers classify as `commercial-anonymizer` here, a statement about how the address is used on the wider internet, not about either operator. Being an anonymizer is a routing fact, not a verdict. `isExfilDestination` is the one to alert on: it marks bulk-storage, paste and anonymous-upload destinations, which is a very different finding from an inbound scanner. The same node also carries `isC2`, `isMalware`, `isPhishing` and `isThreat` for the full boolean sweep in one row.

**From here, →** [How do I get a verdict with coverage for a whole watchlist?](#how-do-i-get-a-verdict-with-coverage-for-a-whole-watchlist) for the reconciled verdict behind the flags.

### How many ROAs authorize each network in a list?

You are scoring routing hygiene across a list of autonomous systems and want the simplest signal first: how many RPKI ROAs authorize each one to originate prefixes. A network with none is unsigned; a network with thousands has done the work.

```cypher expect=rows>0 seed=AS13335 verified=2026-09-30
// RPKI ROA count per ASN, across a batch (the ROA label also holds routing-registry route objects)
UNWIND ["AS13335", "AS15169", "AS36459"] AS an
MATCH (asn:ASN {name: an})
CALL { WITH asn MATCH (asn)<-[:ROA_AUTHORIZES_ORIGIN]-(r:ROA) WHERE r.authSource = "rpki-roa" RETURN count(r) AS roas }
RETURN an AS asn, roas
LIMIT 10
```
**Returns:** `asn, roas`

**Sample output** (captured 2026-09-30):
```json
[
  {"asn": "AS13335", "roas": 5327},
  {"asn": "AS15169", "roas": 1838},
  {"asn": "AS36459", "roas": 5}
]
```

**Costs:** one indexed anchor per network and a single-hop count inside a `CALL { }` subquery, which keeps the count scoped per input row — an aggregate in the outer query would collapse the batch into one number — and keeps each arm shallow.

Empty result: `ROA_AUTHORIZES_ORIGIN` is a coverage-scoped routing layer. A count of zero means no ROA in the current table names that AS as an origin — its announcements are unverifiable, which is a finding — never that the network does not exist. An AS missing from the output altogether was not matched by name; anchor with the `AS`-prefixed form (`AS13335`).

> **Tip:** read the count as a floor on intent, not a score: a large count means the operator signs its space, zero means nothing is signed. Pair it with the per-prefix posture — `rpkiStatus`, `rpkiInvalidReason`, `isMoas`, `roaAsn` on an `ANNOUNCED_PREFIX` reached from any host's IP through `ANNOUNCED_BY` — to see whether the signing actually matches what is announced.

**From here, →** [Recipe 8 — Routing-layer cross-check: hijack signal + RPKI + owner](#recipe-8-routing-layer-cross-check-hijack-signal-rpki-owner)

### What kind of thing is each token in an unlabelled list?

A list arrives from a report, a log or an agent, and nothing in it is labelled. Some entries are IPs, some hostnames, some ASNs, and some are not in the graph at all. You want one typed row per input, misses included — which is also the batch existence check.

```cypher expect=rows>0 seed=github.com verified=2026-09-02
// One typed row per token, misses included
UNWIND ["1.1.1.1", "github.com", "AS13335", "definitely-not-a-real-domain-xyzzy.com"] AS tok
OPTIONAL MATCH (seed {name: tok})
RETURN tok AS token,
       CASE WHEN seed IS NULL THEN "unknown" ELSE labels(seed)[0] END AS kind,
       coalesce(seed.threatLevel, "n/a") AS threat_level
LIMIT 10
```
**Returns:** `token, kind, threat_level`

**Sample output** (captured 2026-09-02):
```json
[
  {"token": "1.1.1.1", "kind": "IPV4", "threat_level": "INFO"},
  {"token": "github.com", "kind": "HOSTNAME", "threat_level": "NONE"},
  {"token": "AS13335", "kind": "ASN", "threat_level": "NONE"},
  {"token": "definitely-not-a-real-domain-xyzzy.com", "kind": "unknown", "threat_level": "n/a"}
]
```

**Costs:** one indexed name lookup per token, no traversal; the anchor is unlabelled on purpose so one lookup serves every type, and `labels(seed)[0]` reports what it landed on.

> **Tip:** `OPTIONAL MATCH` is what makes this safe — a plain `MATCH` silently drops the tokens that do not exist, so every row comes back resolved and you cannot tell which inputs were misses. Names are stored lowercase, so fold the case in your own code first. For a single token you cannot classify at all, or one that needs prefix or suffix matching, `CALL whisper.search("<token>")` routes by detected type to a bounded lookup and returns an explicit `warning` row instead of scanning; this shape is for lists where an exact-name lookup will do.

**From here, →** [How do I get a verdict with coverage for a whole watchlist?](#how-do-i-get-a-verdict-with-coverage-for-a-whole-watchlist) with the tokens that resolved.

## Built for automation

A query you run once by hand can be sloppy. A query a pipeline runs every hour cannot: its columns have to be the same on every call, it has to carry its own notices rather than hiding them in rows, and it should accept the input the upstream system actually produces. These three shapes are what make the patterns above safe to schedule.

### How do I query history from a scheduled job without the columns shifting?

You are wiring history lookups into a pipeline that runs the same query on a schedule, and you need a column set that will not shift under a fixed `YIELD` between one indicator and the next. Call the single-shape variant for the indicator type and `YIELD` from that one shape.

```cypher expect=rows>0 seed=cloudflare.com verified=2026-09-02
// Domains → the fixed WHOIS shape
CALL whisper.history.whois("cloudflare.com")
YIELD indicator, registrableDomain, registrar, registrant, country,
      createDate, updateDate, expiryDate, nameServers
RETURN registrar, registrant, country, createDate, updateDate, nameServers
LIMIT 3
```
**Returns:** `registrar, registrant, country, createDate, updateDate, nameServers`

**Sample output** (captured 2026-09-02):
```json
[
  {"registrar": "CloudFlare, Inc.", "registrant": "CloudFlare, Inc.", "country": "US", "createDate": "2009-02-17", "updateDate": "2017-06-07", "nameServers": "ns3.cloudflare.com|ns4.cloudflare.com|ns5.cloudflare.com|ns6.cloudflare.com|ns7.cloudflare.com"},
  {"registrar": "Cloudflare, Inc.", "registrant": "DATA REDACTED", "country": "US", "createDate": "2009-02-17", "updateDate": "2020-04-17", "nameServers": "ns3.cloudflare.com|ns4.cloudflare.com|ns5.cloudflare.com|ns6.cloudflare.com|ns7.cloudflare.com"}
]
```

**Costs:** one procedure call, one row per historical snapshot; the WHOIS shape is a fixed contract, so a scheduled `YIELD` stays valid across releases; needs an API key.

> **Tip:** the general `whisper.history(indicator)` is multi-shape — it emits WHOIS columns for a domain and routing columns for an IP, ASN or prefix — so a `YIELD` that names columns from both shapes (`createDate` and `prefix`, say) can never be satisfied by one row and is rejected up front, with a `multi_shape_yield` entry in `suggestions[]` your client can branch on. From automation, always call the single-shape variant. `nameServers` is a `|`-joined string, not a list, so split it client-side.

**From here, →** [What routing history do I get for an IP, with stable columns?](#what-routing-history-do-i-get-for-an-ip-with-stable-columns)

### What routing history do I get for an IP, with stable columns?

The same rule for the routing side: an IP, ASN or prefix goes to `whisper.history.bgp`, which always emits the same routing columns — which network originated which prefix, over which window, seen by what share of vantage points.

```cypher expect=rows>0 seed=8.8.8.8 verified=2026-09-02
// IPs / ASNs / prefixes → the fixed routing shape
CALL whisper.history.bgp("8.8.8.8")
YIELD indicator, type, origin, prefix, startTime, endTime, visibility, peersSeing, cached
RETURN origin, prefix, startTime, endTime, visibility, cached
LIMIT 5
```
**Returns:** `origin, prefix, startTime, endTime, visibility, cached`

**Sample output** (captured 2026-09-02):
```json
[
  {"origin": "AS701", "prefix": "8.0.0.0/6", "startTime": "2013-12-05T00:00:00", "endTime": "2013-12-16T23:59:59", "visibility": 0.0328, "cached": false},
  {"origin": "AS3352", "prefix": "8.0.0.0/7", "startTime": "2007-08-15T00:00:00", "endTime": "2007-08-26T23:59:59", "visibility": 0.1761, "cached": false}
]
```

**Costs:** one call against a routing-history backend, so expect a longer round trip than an anchored read and a longer one still for an ASN-wide question; keep the `LIMIT`; needs an API key.

> **Tip:** note the routing column is spelled `peersSeing` — it is the contract, so spell it that way. `visibility` is the share of vantage points that saw the announcement, so a low-visibility row is a partial or leaked route rather than the network's steady state. `cached` tells you whether the row came from a warm read; if a cold read comes back empty, retry once before you conclude there is no history.

**From here, →** [Recipe 8 — Routing-layer cross-check: hijack signal + RPKI + owner](#recipe-8-routing-layer-cross-check-hijack-signal-rpki-owner) to check the current table against the history.

### What is the response telling me outside the rows?

A successful response can carry non-fatal notices — the API's way of telling you it quietly did something on your behalf, such as folding a subdomain up to its registrable parent. They arrive in a top-level `advisories[]` array beside `columns`, `rows` and `statistics`, so a client reads them without scraping row data.

```cypher expect=rows>0 seed=www.cloudflare.com verified=2026-09-02
// The fold emits a whois-parent-fold advisory at the top level of the response
CALL whisper.history.whois("www.cloudflare.com")
YIELD indicator, registrableDomain, registrar
RETURN indicator, registrableDomain, registrar
LIMIT 1
```
**Returns:** `indicator, registrableDomain, registrar` — plus, on the envelope, `advisories[]`

**Response** (abridged, captured 2026-09-02):
```json
{
  "columns": ["indicator", "registrableDomain", "registrar"],
  "rows": [
    {"indicator": "www.cloudflare.com", "registrableDomain": "cloudflare.com", "registrar": "CloudFlare, Inc."}
  ],
  "advisories": [
    {
      "kind": "whois-parent-fold",
      "message": "WHOIS shown for registrable parent cloudflare.com (queried www.cloudflare.com)",
      "queried": "www.cloudflare.com",
      "resolved": "cloudflare.com"
    }
  ]
}
```

**Costs:** nothing beyond the query itself; the advisory lives on the response envelope, not in a row, so it survives any `YIELD` / `RETURN` projection — you get it even when your `RETURN` names none of the folded columns.

> **Tip:** when there is nothing to report the channel is empty and the `advisories` key is omitted entirely, so test for its presence rather than expecting an empty array. `queried` and `resolved` are omitted when they do not apply to an advisory kind, so read them defensively. `whisper.enrich` uses the same channel for its `enrich-semantics` notice.

**From here, →** [Can I pass a URL instead of a host?](#can-i-pass-a-url-instead-of-a-host) for the fold that produces this advisory.

### Can I pass a URL instead of a host?

Your input arrives as a full URL — a link pulled from an email, a log line, a crawler — and you do not want to strip it to a host before every call. The agent-facing procedures fold a URL anchor to its host for you: `https://host/path?q=1` is read as `host`, scheme, path and query dropped.

```cypher expect=rows>0 seed=github.com verified=2026-09-02
// A full URL is folded to its host before identification
CALL whisper.identify("https://github.com/torvalds/linux")
YIELD host, vendor_id, canonical_name, host_class
RETURN host, vendor_id, canonical_name, host_class
LIMIT 5
```
**Returns:** `host, vendor_id, canonical_name, host_class`

**Sample output** (captured 2026-09-02):
```json
[{"host": "github.com", "vendor_id": "github", "canonical_name": "Github", "host_class": "multi_tenant_user_content"}]
```

**Costs:** identical to the host form; the fold happens before the lookup and costs nothing. `whisper.identify`, `whisper.assess`, `whisper.walk` and the history procedures all fold URLs, and `whisper.assess` / `whisper.assessUrl` take either a single string or a list, so a one-off call needs no list wrapper.

> **Tip:** the two folds compose. `whisper.history.whois("https://www.cloudflare.com/pricing")` first folds the URL to the host `www.cloudflare.com`, then folds that subdomain up to its registrable parent `cloudflare.com`, and the `whois-parent-fold` advisory reports the host it started from. When the path itself is the question — a kit path, a download URL on a multi-tenant host — use `whisper.assessUrl`, which scores the path separately from the apex.

**From here, →** [Recipe 5 — Identity is not a verdict (gate on coverage)](#recipe-5-identity-is-not-a-verdict-gate-on-coverage)

## Where to go next

- **[Workflows](/docs/workflows)** — guided workflows you can run in the browser, organized by domain.
- **[Graph Schema](/docs/whisper-graph/schema)** — every label, edge, and property, with the direction traps spelled out.
- **[Procedures](/docs/whisper-graph/procedures)** — full `CALL` signatures for `explain()`, `whisper.variants()`, `whisper.history.whois()` / `whisper.history.bgp()`, `whisper.enrich()`, and `whisper.origins()`.
- **[Threat Feeds & Categories](/docs/whisper-graph/threat-feeds)** — the 134 feeds and 33 categories behind every verdict.
- **[AI & Agents](/docs/ai)** — connect an MCP client so an agent runs all of this itself.
