Skip to content
Sources

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 cidrgroup ip
md5 sha1 sha256group hash
process filepathgroup file
domain url email cve asn mac btc registry username phone person company image coords vehicle evmno 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 uuidno 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 the https://…s3.amazonaws.com/… URL it was written as), a repository is owner/name, a PyPI project is normalised per PEP 503, an IBAN has its spaces stripped, a Telegram channel loses its t.me/.
  • sha512, jarm, ja3, sshfp and ssdeep are not in the hash group. A hash template is asked for MD5, SHA-1 and SHA-256 only, so a source that also reads SHA-512 needs a sha512 key 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.

TransformWhat it doesEngine
trimcollapse runs of whitespace, then trim1
lower / upperlowercase / uppercase1 / 7
datea written date to YYYY-MM-DD1
unixepoch seconds or milliseconds to a date1
agea date to whole days since it; a future date is negative, an undated value is unchanged7
numberthe first number in the value, commas removed1
digitsdigits only1
bytesa 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 unchanged7
satoshisatoshi to BTC1
weian 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 is4
asn15169 → AS151691
yesnotrue, 1 or "yes" → yes; false, 0 or "no" → no; the rest unchanged1
urlresolve against the page's address1
linka link that carries another address in a query parameter — a search engine's own redirect — becomes the address it points at; anything else is unchanged3
base64decode 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 was7
hexdecode a run of hex pairs, with or without 0x and with or without : or - between them. Same rule: what will not decode is unchanged7
urldecodepercent-decoding. An unfinished escape (100%) is left as written7
firstkeep the first value1
countreplace the values with how many distinct ones there were1
uniquedrop repeats, keeping the order they arrived in7
sortsort the values, ignoring case, numbers in numeric order (ns2 before ns10)7
joinall the values as one, , between them7
spliteach value split on , ; | and newlines, into several7
redactkeep 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 said7

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:

minWhat it added
1fields, reads, presence
2tags
3follow with from, leads, a field's max, the link transform
4the wei transform, the year and sha256 template modifiers
5first-party pages fetched without cookies
6headers, when
7the 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" }] }
  • tone is malicious, suspicious, notable or plain. The first two are warnings: a rule that raises one votes on the entity's verdict at its loudest warning, one vote per rule. notable is worth a glance; plain is 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.
  • when is any of equals (a string or a list, compared trimmed and case-insensitively), matches (a case-insensitive regular expression) and min / max (bounds on the first number in the value; 1,200 reads as a thousand). All given tests must pass.
  • label is 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 tags must 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 }
  • path is 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 most max of them, and never past limits.pages, which counts the first page as one of its five.
  • fields is 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, company and vehicle are types a person names, not ones a pattern finds in prose.
  • A page a from step 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 from step's fields may not carry tags (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:

RuleAsks forKeys it serves
duckduckgo-searchthe value quoted on its ownperson username email phone company domain ipv4 hash
duckduckgo-recordsthe sites where that kind of thing is filedthe same eight
duckduckgo-documentsthe value inside a published documentperson 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 an OR chain is written without them.
  • A quoted value and one operator, site: or filetype:. An ordinary word or a second quoted phrase beside filetype:pdf empties the result set, so write %22{ioc}%22+site:uspto.gov+filetype:pdf rather than adding assignee to it. A second quoted phrase beside site: is fine.
  • An OR chain 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 }]
}
  • read maps 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 field lookup is accepted for one release; read wins when both are present.
  • accept chooses the Accept header and the reader: json is parsed in the worker, text is read as plain text in the worker, html is parsed as a web page. Without it the engine goes by the answer's content type.
  • presence marks an account site. When Trace asked, a 404, a 410 or an answer without the rule's required field records found: no on the entity instead of failing; an answer with it records found: yes beside 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 the vulnerabilities list whose cveID is the entity's value, then reads vendor from them. For one big list served to everyone, such as CISA's known-exploited list or LOLBAS. Leave in out when the answer is the list — a JSON body that is a bare array, such as Feodo Tracker's blocklist — and where picks the entity's item straight out of it. Add is (engine 7) when what where should 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.

FieldWhat it is
septhe column separator: ,, \t, ; or |. Leave it out and the whole line is the value — which is what a one-address-per-line blocklist wants
columnwhich column to read, counting from zero. Needs sep; without it the whole line is read
wherea column that must equal the entity's value. Needs sep
iswhat where compares against, when it is not the entity's value
matcheskeep only lines matching this pattern
ignoreCasefold case, for matches and for where
skipheader rows to drop — counted after comments and blank lines, so a feed that grows a banner does not shift it
commentdrop 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 is Qakbot, banker. The other separators are taken literally, because nothing quotes a tab.
  • where with nothing to compare against — no entity value and no is — 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 for text/csv and 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.
  • group picks a capture group other than the first; scope reads the page's text instead of its HTML.
  • The literal is read with JSON.parse and 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 }
  • status adds up to four more statuses. 404 and 410 never stop meaning it.
  • matches is 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.
  • redirected means 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.
  • notFound is 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.

acceptAsks forRead asEngine
(omitted)a web page, JSON acceptedby the answer's content type1
htmla web pagea document1
jsonJSONparsed in the worker1
textplain texttext1
xmlRSS, Atom and XMLa document, so select works on its elements7
csvtext/csv and text/tab-separated-valuestext, for a lines source7

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.

  • value is 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.
  • ignoreCase folds 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 when must 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)" }
HeaderWhat it is for
Api-User-AgentWikimedia'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-LanguageWhich 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:

  • fetch mode only; a tab rule is refused. Accept stays the engine's, chosen by accept.
  • 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 headers must 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.

PlaceholderResulthttps://a.b/c?d=1 becomes
{ioc}the value, URL-encodedhttps%3A%2F%2Fa.b%2Fc%3Fd%3D1
{ioc:raw}the value, untouchedhttps://a.b/c?d=1
{ioc:noscheme}leading http://, https://, hxxp://, hxxps:// removed, then encodeda.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 encodedhttps%5C%3A%5C%2F%5C%2Fa.b%5C%2Fc%3Fd%3D1
{ioc:defanged}the defanged formhxxps://a[.]b/c?d=1
{domain}host of a URL or email, the domain itself for a domain, otherwise emptya.b
{type}the type id that matchedurl

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.

ModifierWhat it doesExample
lowerlowercaseHikaru → hikaru
upperuppercaseas15169 → AS15169
digitsdigits onlyAS15169 → 15169
md5MD5 of the trimmed, lowercased value (Gravatar's key)an email → 205e460b…
basenamethe last segment of a pathC:\Windows\…\powershell.exe → powershell.exe
stemthe value without its extensionpowershell.exe → powershell
sluglowercase, every run of other characters as one -powershell.exe → powershell-exe
nationala phone number as dialled at home: +44 becomes 0, +1 is dropped+44 20 7946 0958 → 02079460958
networka CIDR's first address8.8.8.0/24 → 8.8.8.0
revan IPv4 address with its octets reversed, for DNS-based lookups1.2.3.4 → 4.3.2.1
latthe latitude of a lat,lon pair51.5007,-0.1246 → 51.5007
lonthe longitude of a lat,lon pair51.5007,-0.1246 → -0.1246
leaf2the last two segments of a registry pathHKLM\…\CurrentVersion\Run → CurrentVersion\Run
yearthe 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 unchangedCVE-2021-44228 → 2021
sha256SHA-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:

PlaceholderResult
%sthe value with every http://, https://, hxxp:// and hxxps:// removed, not encoded
%rthe value, not encoded
%urlscanthe 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, …
  • url keys 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.