Pack format
The JSON a source pack is made of, and every shape the importer accepts.
A source is one place Coeus can send an entity: a URL template plus the entity types it accepts. A pack is a named list of sources. Coeus ships one built-in pack, saves your own sources in another, and can subscribe to packs published at a URL so several investigators share one list.
Everything here is plain JSON. Nothing in a pack executes; a pack only ever produces URLs that Coeus opens in a tab.
Source
{
"id": "acme-intel", // required. kebab-case, unique in the pack
"name": "ACME intel", // required. shown in menus and lists
"desc": "Internal reputation source", // optional, one line
"tags": ["reputation"], // optional, shown as chips, used by search
"auth": true, // optional. true = needs an account
"url": { // required. at least one entry
"ipv4": "https://intel.example.com/ip/{ioc}",
"domain": "https://intel.example.com/domain/{ioc}"
},
"types": ["ipv4", "domain"], // optional, legacy. see "types" below
"localOnly": true // optional. your sources only: never synced
}url keys
A key is either a type id or a group id. The concrete type always wins over the group, so a source can give SHA-256 its own template and let every other hash fall through to a generic one.
| Type ids | |
|---|---|
ipv4 ipv6 cidr | group ip |
md5 sha1 sha256 | group hash |
process filepath | group file |
domain url email cve asn mac btc registry username phone person company image coords vehicle evm | no group |
tron sol ltc doge xmr evmtx btctx s3 gcs azblob ghrepo docker npmpkg pypipkg onion sha512 jarm ja3 sshfp ssdeep telegram discord youtube cpe attack imei iban uuid | no group |
The second row is Phase O2, added on 20 Sept 2026. Coeus ships no built-in source for any of
them yet — a reader for one is a capture rule in a pack, which is exactly what you are writing.
docs/NAMING.md §1 says what each id is in one line. Two notes for a pack author:
- The value Coeus hands
{ioc}is the canonical form, not the string on the page. A bucket is its name (acme-soc-backups, never thehttps://…s3.amazonaws.com/…URL it was written as), a repository isowner/name, a PyPI project is normalised per PEP 503, an IBAN has its spaces stripped, a Telegram channel loses itst.me/. sha512,jarm,ja3,sshfpandssdeepare not in thehashgroup. Ahashtemplate is asked for MD5, SHA-1 and SHA-256 only, so a source that also reads SHA-512 needs asha512key of its own.
evm is an Ethereum-style address, 0x and forty hex digits, recognised on sight like btc. Its
value is always lower case (a checksummed address arrives as {ioc} without its capitals), so a
regex that matches a list of addresses wants ignoreCase. Forty hex digits without 0x stay
sha1.
person, company, image, and vehicle are explicit types: Coeus can resolve source templates
for them, but it does not guess them from ordinary page text. An image entity is normally the URL
of an image selected in the browser.
"url": {
"sha256": "https://example.com/sha256/{ioc}",
"hash": "https://example.com/search?q={ioc}"
}A SHA-256 uses the first template; an MD5 or SHA-1 uses the second.
types
types comes from the 1.x format and is optional. When it is present the source
only applies to the types it lists — useful when one url entry covers a group
but you want the source restricted to part of it. The template is still chosen
from url by key.
auth
auth: true puts a lock badge next to the source. It means the site will ask
for an account before it shows anything, so Coeus can only ever hand you the
link: the source is an open it yourself entry, opened in this browser under
your own account — the coverage reason ownAccount. Coeus stores no credential
for it, and since 18 Sept 2026 it stores no API key for anything
(docs/SERVER_SIDE.md R4): there are no keyed adapters and no key settings in
a pack. The badge is purely a heads-up about what happens when you open it.
localOnly
Only meaningful for sources you add yourself. A localOnly source stays in
chrome.storage.local, so it never leaves this browser through Chrome sync. Use
it for anything you do not want synced to your Google account.
Pack
{
"schema": 1, // required, always 1
"id": "acme-soc", // required, kebab-case
"name": "ACME SOC", // required
"version": "2026.09", // optional, shown on the Packs page
"homepage": "https://example.com/soc", // optional
"sources": [], // the Source objects; may be empty
"captures": [] // optional. capture rules, see below
}Serve the file over HTTPS with Content-Type: application/json. Coeus refetches
it on the schedule in Settings using If-None-Match, so send an ETag if you
can. When a refresh fails, the previous copy stays in use and the Packs page
shows the error.
Captures
A pack may also carry capture rules: what Coeus reads off one kind of page or answer when the user presses Trace on an entity, or Capture on a page. A rule is data — selectors, meta names, JSON paths and regular expressions — never code, and every limit (fields, values, page size, time) is the engine's. Rules that ask for a newer engine than the installed Coeus are kept and ignored. Only the Coeus library publishes rules today; the shape, for reference:
{
"id": "github-profile", // kebab-case, frozen once published
"engine": { "kind": "capture", "min": 1 }, // the engine version the rule needs
"name": "GitHub profile",
"mode": "fetch", // "fetch" (no tab) or "tab" (a page the user never sees)
"match": { "host": "github.com", "path": "/[A-Za-z0-9-]+/?" }, // "*.example.com" also matches subdomains
"about": { "type": "username", "value": { "meta": "profile:username" } }, // the entity the page is about
"fields": [
{ "key": "name", "label": "Name", "from": { "css": ".p-name" } },
{ "key": "website", "from": { "css": "[itemprop=url] a", "attr": "href" },
"transform": ["url"], "as": { "entity": "url", "link": "website on profile" } },
{ "key": "created", "from": { "regex": "^created:\\s*(\\S+)" }, "transform": ["date"] }
],
"verified": "2026-09-12" // when its saved-page fixtures last passed
}from is one of css (+ attr, many), meta, jsonld (a path whose first segment is the
@type), json (a path into a JSON response), regex (+ group, name, scope: "text" | "html",
many, ignoreCase; multiline), lines (engine 7: a CSV, a TSV or a blocklist) or embedded
(engine 7: JSON inside a script). The last two, and the shapes that pick one row out of an answer,
have sections of their own below.
transform runs in order. Up to six of them through engine 6, up to eight from engine 7.
| Transform | What it does | Engine |
|---|---|---|
trim | collapse runs of whitespace, then trim | 1 |
lower / upper | lowercase / uppercase | 1 / 7 |
date | a written date to YYYY-MM-DD | 1 |
unix | epoch seconds or milliseconds to a date | 1 |
age | a date to whole days since it; a future date is negative, an undated value is unchanged | 7 |
number | the first number in the value, commas removed | 1 |
digits | digits only | 1 |
bytes | a written size to whole bytes, 1024 to the step: 1.5 MB → 1572864, 2KiB → 2048. A value with no unit is already bytes; one with a unit nobody uses is unchanged | 7 |
satoshi | satoshi to BTC | 1 |
wei | an integer string of wei to ETH, exact, truncated to at most six decimals — 6712597953701629485 reads 6.712597. Anything that is not a plain integer is left as it is | 4 |
asn | 15169 → AS15169 | 1 |
yesno | true, 1 or "yes" → yes; false, 0 or "no" → no; the rest unchanged | 1 |
url | resolve against the page's address | 1 |
link | a link that carries another address in a query parameter — a search engine's own redirect — becomes the address it points at; anything else is unchanged | 3 |
base64 | decode base64 or base64url, padded or not. Anything that is not base64, or that decodes to bytes which are not text, is left exactly as it was | 7 |
hex | decode a run of hex pairs, with or without 0x and with or without : or - between them. Same rule: what will not decode is unchanged | 7 |
urldecode | percent-decoding. An unfinished escape (100%) is left as written | 7 |
first | keep the first value | 1 |
count | replace the values with how many distinct ones there were | 1 |
unique | drop repeats, keeping the order they arrived in | 7 |
sort | sort the values, ignoring case, numbers in numeric order (ns2 before ns10) | 7 |
join | all the values as one, , between them | 7 |
split | each value split on , ; | and newlines, into several | 7 |
redact | keep the first two characters and the last two, … for the middle; six characters or fewer become … on their own. For a source whose answer is worth recording without keeping what it said | 7 |
base64, hex and redact never fail: a value they cannot convert comes through untouched, the
way satoshi and unix already do, so a page that changes shape gives you the raw value rather
than nothing. base64 and hex decode at most 8,000 characters.
max (engine 3) keeps at most that many values, which is how a rule takes the first ten results off
a page rather than all of them; the engine's ceiling of 50 still applies above it. A field with as
also keeps an entity of that type, linked to the page's entity with the given label.
Every pattern in a rule is checked before it runs. A regular expression that repeats a group
with nothing in it to force progress — (a+)+, (\w*\s*)* — is refused, because such a pattern can
take exponential time on a body that nearly matches and no time limit can interrupt one match. So is
a backreference. A bounded repetition ((?:\.\d+){1,3}), lookaround, and an unbounded quantifier
inside a repeated group that has something else to anchor it ((?:[^\[\]]|\[[^\[\]]*\])*) are all
fine. A rule with a refused pattern is dropped; the pack's other rules are kept.
A regex source may name its group instead of numbering it (engine 7):
{ "regex": "asn:\\s*(?<asn>AS\\d+)", "name": "asn" }. A name the pattern does not declare finds
nothing — it never falls back to an index — which is what you want when a pattern is edited.
Declaring the engine
Every rule carries "engine": { "kind": "capture", "min": N }: the oldest Coeus that can read it.
A Coeus older than min keeps the rule and ignores it, so a new feature never half-runs on an
install that predates it. What each number added:
min | What it added |
|---|---|
| 1 | fields, reads, presence |
| 2 | tags |
| 3 | follow with from, leads, a field's max, the link transform |
| 4 | the wei transform, the year and sha256 template modifiers |
| 5 | first-party pages fetched without cookies |
| 6 | headers, when |
| 7 | the upper base64 hex urldecode bytes age join split sort unique redact transforms; seven or eight transforms on a field; name on a regex; is on a json or a lines; the lines and embedded sources; accept: "xml" and accept: "csv"; notFound |
Engine 8 adds response validation for DNS JSON replies, described below. Its
compatible served representation omits that refinement for older engines; those
installs retain their old reader behavior until their extension is updated.
Declare the engine you actually use. A rule that uses one of engine 7's additions but says
min: 1 is refused outright when the pack is read — the refusal names the addition that forced the
bump — and dropped, while the pack's other rules are kept. That is deliberate: the alternative is an
older Coeus reading half of it and recording the result as a fact.
Tags: the highlights a field puts on the entity
A field may carry tags (engine 2): short labels the entity wears as pills beside its verdict,
listed in full with their evidence when the user opens one. A tag applies when any of the field's
values passes every test in when; without when, whenever the field has a value.
{ "key": "kev", "label": "In KEV", "from": { "json": "kev" }, "transform": ["yesno"],
"tags": [{ "label": "Exploited in the wild", "tone": "malicious", "when": { "equals": "yes" } }] },
{ "key": "pulses", "from": { "json": "pulse_info.count" },
"tags": [{ "label": "In {value} threat pulses", "tone": "suspicious", "when": { "min": 3 } },
{ "label": "In {value} threat pulses", "tone": "notable", "when": { "min": 1, "max": 2 } }] },
{ "key": "ports", "from": { "json": "ports" }, "tags": [{ "label": "Open ports: {count}", "tone": "plain" }] }toneismalicious,suspicious,notableorplain. The first two are warnings: a rule that raises one votes on the entity's verdict at its loudest warning, one vote per rule.notableis worth a glance;plainis context. A rule with nothing to warn about casts no vote, so a name that only ever collects plain facts stays Unknown rather than turning Clean.whenis any ofequals(a string or a list, compared trimmed and case-insensitively),matches(a case-insensitive regular expression) andmin/max(bounds on the first number in the value;1,200reads as a thousand). All given tests must pass.labelis clipped to 40 characters after{value}(the value that passed) and{count}(how many distinct values the field had) are filled in. At most four tags per field.- A rule's tags are replaced by its next answer, like its facts, so a warning clears when the source stops raising it. Tags with the same label from several sources fold into one pill that counts them; the loudest tone wins, unless the user gave that label a tone of their own.
- A rule that carries
tagsmust declare"engine": { "kind": "capture", "min": 2 }; an older Coeus keeps it and ignores it.
Following: reading the pages a rule's page points at
follow (tab mode only) is a short, bounded list of further pages, opened one at a time inside the
same click. Each step says where to go and what to do there.
"follow": [
{ "path": "/u/{value}/about", "fields": [{ "key": "bio", "from": { "css": ".bio" } }] },
{ "from": "resultUrl", "max": 4, "leads": true }
],
"limits": { "pages": 5, "delayMs": 1500 }pathis a template on the entity the page is about, resolved against that page: one more page of the same site.from(engine 3) is the key of a field on the first page whose values are links to open — the results of a search. At mostmaxof them, and never pastlimits.pages, which counts the first page as one of its five.fieldsis read on the page the step opened, exactly as the rule's own fields are read.leads(engine 3) means: read this page with the page scan's detector and keep what it recognises. The emails, handles, phone numbers and domains on the page become entities linked to the one Trace asked about, labelled "found on a page that mentions it", with the page they were found on as the link's source. At most twenty per capture, a type at a time. A name is never minted:person,companyandvehicleare types a person names, not ones a pattern finds in prose.- A page a
fromstep opens is read by its own host's rule when the reader has one installed, and by the detector only when none does. - Nothing followed warns about anything. A
fromstep's fields may not carrytags(a rule that tries is refused), a borrowed rule's tags are dropped before it runs, and a lead is a link, never a fact and never a vote on a verdict. - Steps run in order, at
limits.delayMs(1500 ms at the least) between pages. A link Coeus never reads — a blocklisted host, or something that is not a web page — is skipped, and a page that asks for a person is left for the reader: the host is named on the entity and the rest of the pages are still read.
Searching: dork templates as data
A search rule is an ordinary rule whose read is a search engine's results URL and whose follow
opens the results. The query per type — the dork — is data in the rule, written exactly as it
appears in the URL's query string (+ for a space, %22 for a quote, {ioc} for the value):
"lookup": {
"person": "https://html.duckduckgo.com/html/?q=%22{ioc}%22+site:linkedin.com+OR+site:github.com",
"email": "https://html.duckduckgo.com/html/?q=%22{ioc}%22+filetype:pdf",
"phone": "https://html.duckduckgo.com/html/?q=%22{ioc}%22+OR+%22{ioc:national}%22",
"domain": "https://html.duckduckgo.com/html/?q=site:{domain}",
"hash": "https://html.duckduckgo.com/html/?q=%22{ioc}%22+site:any.run"
}A rule carries one read template per key, so a second request for a type is a second rule. The built-in pack ships three per engine, and a Trace runs them in this order:
| Rule | Asks for | Keys it serves |
|---|---|---|
duckduckgo-search | the value quoted on its own | person username email phone company domain ipv4 hash |
duckduckgo-records | the sites where that kind of thing is filed | the same eight |
duckduckgo-documents | the value inside a published document | person email phone company domain hash |
hash is a group key: one row serves md5, sha1 and sha256. The rows live in
packages/core/src/packs/captures/search.ts as a table with a sentence per row saying what it is
for; your own pack can carry your own dorks in the same shape.
What DuckDuckGo's HTML endpoint was measured to accept, on 13 September 2026:
- No brackets. A query containing
(or), encoded or not, comes back as a 236-byte error page with no results, so anORchain is written without them. - A quoted value and one operator,
site:orfiletype:. An ordinary word or a second quoted phrase besidefiletype:pdfempties the result set, so write%22{ioc}%22+site:uspto.gov+filetype:pdfrather than addingassigneeto it. A second quoted phrase besidesite:is fine. - An
ORchain of at least eight hosts, and-site:to leave one out. - A search that finds nothing answers an ordinary "No results found" page, which is an answer: nobody published the value where the rule looked.
Reads: what Trace reads for an entity
A rule with read is one Trace runs by itself: for each entity whose type (or group) the rule
names, it renders the template with the entity's value and reads that URL. The rendered URL must
match the rule's own match, because that is how the engine finds the rule again.
{
"id": "keybase-user",
"read": { "username": "https://keybase.io/_/api/1.0/user/lookup.json?usernames={ioc}" },
"accept": "json", // "html", "json" or "text": what to ask for, and how to read it
"presence": true, // an account site: record whether it knows the value
"match": { "host": "keybase.io", "path": "/_/api/1\\.0/user/lookup\\.json", "query": { "usernames": "" } },
"about": { "type": "username", "value": { "fromQuery": "usernames" } },
"fields": [{ "key": "name", "label": "Name", "from": { "json": "them.profile.full_name" }, "required": true }]
}readmaps a type id (username,ipv4…) or a group id (ip,hash,file) to a URL template. A type's own template wins over its group's. Templates use the placeholders below. The retired fieldlookupis accepted for one release;readwins when both are present.acceptchooses theAcceptheader and the reader:jsonis parsed in the worker,textis read as plain text in the worker,htmlis parsed as a web page. Without it the engine goes by the answer's content type.presencemarks an account site. When Trace asked, a 404, a 410 or an answer without the rule's required field recordsfound: noon the entity instead of failing; an answer with it recordsfound: yesbeside the other facts. The entity page lists these under "Where it shows up".- The entity a Trace action asked about owns the facts, whatever the page calls itself, so a search
or a list can fill in an entity it does not name. A page opened by hand must still name its entity
through
about.
Validating DNS responses (engine 8)
A DNS JSON lookup can return HTTP 200 even when the resolver failed. Add this to
a fetch rule with accept: "json" and engine.min: 8:
"response": {
"kind": "dns-json",
"status": {
"key": "spfDnsResponse",
"label": "SPF DNS response",
"success": "Successful response (NOERROR)",
"notFound": "Name does not exist (NXDOMAIN)"
}
}The URL's single name and type query must match the response's single Question.
Case and an optional final dot are equivalent. A known entity's URL must also
match the question rendered by its declared read template. Only complete
responses (TC: false) with DNS status 0 or 3 are eligible; HTTP errors, resolver
errors, missing or malformed envelopes and contradictory answers fail without
replacing saved facts. notFound overrides cannot be combined with this check.
Existing field extractors see only records of the requested type at the final
name in a valid CNAME chain. Authority, Additional and diagnostics are excluded;
foreign owners, unexpected record types, forked/cyclic/overlong aliases and
malformed TXT presentation fail. Quoted TXT segments are concatenated without
adding spaces. DNSSEC record types may accompany the answer but do not become
policy fields. This does not verify DNSSEC or evaluate an entire SPF/DMARC policy.
Escaped DNS label forms and unsupported alias forms are refused, not called absent.
The bounded reader's limits live in packages/core/src/capture/dnsResponse.ts.
The configured status becomes separate source-attributed evidence, including when
presence records found: no. Its key must differ from found and all rule fields.
Use separate semantic keys for distinct questions: SPF and DMARC use
spfDnsResponse and dmarcDnsResponse, since their queried names differ. Reusing
one key would merge unrelated statuses into a false disagreement.
Status evidence cannot mint a lead or earn a tag. An empty successful answer and NXDOMAIN thus
remain distinguishable in Reported and Sources. The two existing DNS policy rules
opt into this check; other JSON readers retain their existing interpretation.
The website strips response for older engines and keeps the original fields,
read templates and rule IDs. This avoids removing installed readers, but also
preserves their old DNS error/absence behavior. A compatible pack update alone is
not the fix: an engine-8 extension release is required. Field verification dates
must not be advanced merely because synthetic tests passed. A new reader whose
basic meaning requires this guard must not rely on that downgrade: compatibility
stripping does not provide a mandatory-response capability. Review older-engine
behavior explicitly before admitting a new reader; the local AAAA proposal is
withheld for this reason and for incomplete address-data validation.
Selecting records from a shared answer
Two sources pick the entity's own entry out of a shared answer:
{ "json": "vendor", "in": "vulnerabilities", "where": "cveID", "ignoreCase": true }keeps only the items of thevulnerabilitieslist whosecveIDis the entity's value, then readsvendorfrom them. For one big list served to everyone, such as CISA's known-exploited list or LOLBAS. Leaveinout when the answer is the list — a JSON body that is a bare array, such as Feodo Tracker's blocklist — andwherepicks the entity's item straight out of it. Addis(engine 7) when whatwhereshould match is not the entity but a fixed value:{ "json": "value", "in": "records", "where": "type", "is": "MX" }reads every MX record out of an answer that lists all of them.- In a
regex,{value}is the entity's value and{value:8}its first eight characters, both escaped, so"^{value:8}\\t[^\\t]*\\t(.+)$"reads one line of a vendor list. A regex with a token reads the whole answer, up to the engine's size limit, rather than the first part of it.
Nothing a capture reads leaves the browser: the page is fetched by the extension, with the user's own connection, and the facts are saved in this browser.
What a service allows
A service's own allowance is the rule's ceiling, and the engine does not count for you. Nothing
paces a rule per hour or per day. What the engine does is narrower: inside one trace it reads a host
one request at a time, and it waits out a single Retry-After on a 429. That is enough for one
lookup per traced value and nothing more.
So a rule on a service with a small free allowance carries the figure, and where it was read, in a
comment beside the rule — and the figure is published with the pack, because the nightly check
records a rate-limited page as a skip: whoever reads that report has to know a run of skips
there is the quota rather than a rule that has stopped working. The built-in pack has two such
rules, both keyless: greynoise-community (10 lookups a day) and pulsedive-indicator (10 a day
and 1 a second). Past the figure a trace shows one failed read on that entity and nothing else
changes.
Write your own the same way, and never ask a service for more than a reader's own traces would. A rule that fires a second request at the same host for facts another rule already reads spends somebody's allowance twice for nothing.
lines: a CSV, a TSV, or one value to a line
Half the keyless feeds worth reading are a delimited file: a blocklist, a vendor export, a registry
dump. Before engine 7 a rule read one with a regular expression per column, which is where a pattern
turns into something nobody can check. lines says what it is instead.
{ "key": "malware", "label": "Malware",
"from": { "lines": { "sep": ",", "column": 3, "where": 1, "comment": "#", "skip": 1 } } }Against Feodo Tracker's ipblocklist.csv, that reads the malware family out of every row whose
second column is the address being traced.
| Field | What it is |
|---|---|
sep | the column separator: ,, \t, ; or |. Leave it out and the whole line is the value — which is what a one-address-per-line blocklist wants |
column | which column to read, counting from zero. Needs sep; without it the whole line is read |
where | a column that must equal the entity's value. Needs sep |
is | what where compares against, when it is not the entity's value |
matches | keep only lines matching this pattern |
ignoreCase | fold case, for matches and for where |
skip | header rows to drop — counted after comments and blank lines, so a feed that grows a banner does not shift it |
comment | drop lines starting with this: # for most feeds |
- A
,row is unquoted properly:2026-09-03,203.0.113.4,447,"Qakbot, banker"has four columns and the last isQakbot, banker. The other separators are taken literally, because nothing quotes a tab. wherewith nothing to compare against — no entity value and nois— finds nothing, never everything.- The walk stops at 20,000 lines, at 50 values, and at the engine's time budget, whichever comes first. A feed that goes past any of them is recorded as a skipped field, not a wrong answer.
- Pair it with
"accept": "csv"(engine 7), which asks the server fortext/csvand reads the answer as text.
embedded: JSON a page carries in its script
Plenty of pages hold their data in a script rather than their markup: window.__DATA__ = {…}, a
__NEXT_DATA__ block, a JSONP callback. embedded is a regular expression whose group is the JSON
literal, and json is a path into it, exactly as it is into a JSON response.
{ "key": "displayName",
"from": { "embedded": "__DATA__\\s*=\\s*(\\{.*\\});", "json": "user.name" } },
{ "key": "handles",
"from": { "embedded": "^cb\\((.*)\\);", "json": ".", "scope": "text" } }json: "."is the whole of the parsed value, which is what a JSONP callback handing back a bare array wants.grouppicks a capture group other than the first;scopereads the page's text instead of its HTML.- The literal is read with
JSON.parseand never evaluated. A page whose data is only reachable by running its script —window.__DATA__ = atob('…')— is a page this finds nothing on. That is the right answer, not a missing feature: nothing in a pack has ever executed and nothing will. - A capture that is not JSON after all, or a page that changed shape, is nothing found. It is not a failed read and it never throws.
notFound: the other ways a site says it has nothing
A presence rule already reads a 404 and a 410 as found: no. Plenty of sites never send one: a
register answers 200 with "No results found", an API answers 403 for a name it does not hold, a
profile host bounces an unknown handle to its own front page. Without notFound (engine 7) each of
those is recorded as a failed read, which tells the reader that Coeus could not tell — when the
site said plainly that it has nothing.
"presence": true,
"notFound": { "status": [403], "matches": "No results found", "redirected": true }statusadds up to four more statuses. 404 and 410 never stop meaning it.matchesis a pattern over the answer's body, case-insensitive and across lines, read in the first 100,000 characters. Write something the page only ever says when it has nothing.redirectedmeans a bounce away from the address asked for. A trailing slash, a dropped query parameter, or http becoming https is the same page arriving tidily and does not count; a different host or a different path does.- The status tests are decided before the body is downloaded, so a 403 that means "no such record" costs one set of headers.
notFoundis the one engine 7 field an older Coeus is served the rule without — a presence rule with it removed still records the 404s it always did. Everything else engine 7 added changes what the rule reads, so a rule using one of those is kept whole and an older Coeus ignores it whole.
accept: xml and csv
accept chooses what to ask the server for and how to read the answer.
accept | Asks for | Read as | Engine |
|---|---|---|---|
| (omitted) | a web page, JSON accepted | by the answer's content type | 1 |
html | a web page | a document | 1 |
json | JSON | parsed in the worker | 1 |
text | plain text | text | 1 |
xml | RSS, Atom and XML | a document, so select works on its elements | 7 |
csv | text/csv and text/tab-separated-values | text, for a lines source | 7 |
An xml answer is read as a document, so a feed's elements are selected the way a page's are:
{ "css": "item title", "many": true } reads a feed's headlines. One caveat worth knowing: it is
parsed the way a browser parses a page, so an element the HTML parser treats as self-closing —
Atom's <link> — has no text content of its own. Read that one with a regex over scope: "html",
or with attr: "href".
when: only for values of the right shape
A rule may carry when (engine 6): the rule runs only for an entity whose value matches, anchored
end to end exactly as match.path is.
"when": { "value": "[A-HJ-NPR-Z0-9]{17}", "ignoreCase": true }vehicle is a number plate in one country and a VIN in the next, and a VIN decoder can only answer
for a VIN. Without a gate such a rule fires for every plate, asks a decoder about it, and writes a
"not found" that says nothing about the vehicle. With one, a value the gate turns away is treated
exactly like a type the rule does not name: no request, no fact, no failure row, and the entity's
coverage does not claim the source was read for it.
valueis a regular expression, bounded like every other pattern in a rule (500 characters, and it must compile). It is anchored for you, so it is a shape and never a substring.ignoreCasefolds case. The value is matched trimmed.- The gate is about the entity, so it applies wherever a rule is picked for one — a
read, or a rule reached through one of your sources' URLs. A page you open by hand is still read by its own rule. - A rule with
whenmust declare"engine": { "kind": "capture", "min": 6 }. An older Coeus keeps it and ignores it, rather than running it for every value.
headers: the one or two headers a rule may set
A fetch rule may carry headers (engine 6), from a short allow-list:
"mode": "fetch",
"headers": { "Api-User-Agent": "Coeus/2 (+https://coeus.tools)" }| Header | What it is for |
|---|---|
Api-User-Agent | Wikimedia's policy: a browser client cannot set User-Agent, and is "encouraged to include the Api-User-Agent header" instead. Name your tool and a contact URL — a major line, not a release, because nothing rewrites a pack when a version ships. |
Accept-Language | Which language a register answers in. |
Nothing else is accepted, and the list is an allow-list on purpose. A rule arrives in a pack you
subscribed to, and the fetch carries your cookies for that host: a rule that could name its own
header could rewrite Accept to defeat its own reader, forge an Origin, or smuggle a credential to
a host of its choosing. So every other name is refused, whatever its case — Authorization,
Cookie, Host, Origin, Referer, User-Agent, Content-Type, anything beginning Sec-,
Proxy- or X-Forwarded-, and anything that reads like a credential. Asking to widen the list is a
change to the engine, not to a pack.
The rest of the shape:
fetchmode only; atabrule is refused.Acceptstays the engine's, chosen byaccept.- At most four headers, each value printable ASCII on one line, at most 200 characters. A value carrying a carriage return or a newline would split one request into two, so it is refused.
- A header is never templated:
{ioc}and the rest are not substituted, and a value containing{or}is refused. A header is fixed text about your client, never the entity being looked up. - A rule with
headersmust declare"engine": { "kind": "capture", "min": 6 }.
Placeholders
Placeholders are substituted into every url template. Unknown {...}
placeholders are left exactly as written, and a template with no placeholder at
all is opened unchanged.
| Placeholder | Result | https://a.b/c?d=1 becomes |
|---|---|---|
{ioc} | the value, URL-encoded | https%3A%2F%2Fa.b%2Fc%3Fd%3D1 |
{ioc:raw} | the value, untouched | https://a.b/c?d=1 |
{ioc:noscheme} | leading http://, https://, hxxp://, hxxps:// removed, then encoded | a.b%2Fc%3Fd%3D1 |
{ioc:b64} | base64url of the value, no padding (the VirusTotal URL id) | aHR0cHM6Ly9hLmIvYz9kPTE |
{ioc:urlscan} | : and / escaped for urlscan search, then encoded | https%5C%3A%5C%2F%5C%2Fa.b%5C%2Fc%3Fd%3D1 |
{ioc:defanged} | the defanged form | hxxps://a[.]b/c?d=1 |
{domain} | host of a URL or email, the domain itself for a domain, otherwise empty | a.b |
{type} | the type id that matched | url |
Modifiers chain left to right, as in {ioc:basename:lower}. The result is URL-encoded unless the
chain includes raw, b64, defanged, md5 or sha256; an unknown modifier leaves the placeholder as
written.
| Modifier | What it does | Example |
|---|---|---|
lower | lowercase | Hikaru → hikaru |
upper | uppercase | as15169 → AS15169 |
digits | digits only | AS15169 → 15169 |
md5 | MD5 of the trimmed, lowercased value (Gravatar's key) | an email → 205e460b… |
basename | the last segment of a path | C:\Windows\…\powershell.exe → powershell.exe |
stem | the value without its extension | powershell.exe → powershell |
slug | lowercase, every run of other characters as one - | powershell.exe → powershell-exe |
national | a phone number as dialled at home: +44 becomes 0, +1 is dropped | +44 20 7946 0958 → 02079460958 |
network | a CIDR's first address | 8.8.8.0/24 → 8.8.8.0 |
rev | an IPv4 address with its octets reversed, for DNS-based lookups | 1.2.3.4 → 4.3.2.1 |
lat | the latitude of a lat,lon pair | 51.5007,-0.1246 → 51.5007 |
lon | the longitude of a lat,lon pair | 51.5007,-0.1246 → -0.1246 |
leaf2 | the last two segments of a registry path | HKLM\…\CurrentVersion\Run → CurrentVersion\Run |
year | the year a CVE id names, for a dataset kept one file per year; any other value gives its first run of exactly four digits, and one with none is unchanged | CVE-2021-44228 → 2021 |
sha256 | SHA-256 of the trimmed value, lowercase hex. Unlike md5 it does not lower-case for you, so Gravatar's v3 key is {ioc:lower:sha256} | an email → a919f0e9… |
Legacy placeholders
Templates written for Coeus 1.x keep working, byte for byte:
| Placeholder | Result |
|---|---|
%s | the value with every http://, https://, hxxp:// and hxxps:// removed, not encoded |
%r | the value, not encoded |
%urlscan | the value with : → \: and / → \/, not encoded |
Prefer the {...} forms in new packs: %s does not encode, so a value with an
& or a space in it can break the URL you meant to build.
Example pack
{
"schema": 1,
"id": "acme-soc",
"name": "ACME SOC",
"version": "2026.09",
"homepage": "https://example.com/soc",
"sources": [
{
"id": "acme-reputation",
"name": "ACME reputation",
"desc": "House reputation score for hosts and addresses",
"tags": ["reputation", "internal"],
"url": {
"ip": "https://intel.example.com/reputation?q={ioc}",
"domain": "https://intel.example.com/reputation?q={ioc}",
"url": "https://intel.example.com/reputation?q={ioc:noscheme}"
}
},
{
"id": "acme-case-search",
"name": "ACME case search",
"desc": "Past tickets mentioning this entity",
"tags": ["cases"],
"auth": true,
"url": {
"ip": "https://cases.example.com/search?q={ioc}&type={type}",
"domain": "https://cases.example.com/search?q={ioc}&type={type}",
"hash": "https://cases.example.com/search?q={ioc}&type={type}",
"cve": "https://cases.example.com/search?q={ioc}&type={type}"
}
}
]
}Save it, serve it at a URL the other investigators can reach, and add it on the Packs page. Site access is granted at install. If you changed Coeus to On click in Chrome's extension settings, it asks for this origin before the first fetch; if you decline, the subscription is not added.
Subscribing to an older list
A pack URL (Packs → Advanced · add a pack by URL) may serve any of four shapes; Coeus works out which one it has, in this order.
1. A pack — the format above. Used as-is.
2. A Coeus 1.x tool list. The ✅ / 🔐 prefixes are stripped from names,
and 🔐 becomes "auth": true.
{
"tools": [
{
"name": "✅ ACME intel",
"url": { "ip": "https://intel.example.com/ip/%s" },
"desc": "House reputation",
"types": ["ip"]
}
]
}3. A type map — one list of URL templates per type. Templates are grouped by hostname, so a site listed under several types becomes a single source named after its host.
{
"ip": ["https://intel.example.com/ip/%s", "https://cases.example.com/?q=%s"],
"domain": ["https://intel.example.com/domain/%s"]
}4. A bare list of sources — a JSON array of Source objects, without the pack wrapper.
In every case:
- ids are derived from the name (lowercased, punctuation and emoji removed) and
de-duplicated with
-2,-3, … urlkeys that are not a type or group id are dropped- entries with no name, or with no usable template left, are skipped
A list that comes out with no usable source is refused, and the subscription is not added. To see exactly what was kept, convert the list to a pack (the format above) and host that instead.