Swarmchasing Report a swarm

Browser toolkit

On this page
  1. Building and serving
  2. How the site is built
  3. Pages
  4. Notices
  5. Report a swarm
  6. The landing page
  7. The workbench
  8. Check my logs
  9. Sets of files
  10. The analysis worker
  11. Agent identity
  12. Sharing sightings
  13. The Field Manual
  14. Rules explorer
  15. Evaluation page
  16. Edited rules
  17. Evidence bundles
  18. Loading and size
  19. Agent-facing files
  20. Page references
  21. Link cards and search
  22. Printing
  23. Accessibility
  24. Safety

The public site on GitHub Pages: the notices, the docs, the rules and their evaluation, and tools that run the same analysis as the CLI in the browser, compiled to WASM from sc-wasm. Nothing a visitor pastes or loads leaves the browser.

Building and serving#

scripts/ci/build-site.sh                    # WASM modules, then every page, into _site/
scripts/ci/build-site.sh --pages-only       # pages only, reusing the built modules
python -m http.server -d _site 8080         # then open http://localhost:8080

_site/, site/pkg/ (the engine, and the tools module in site/pkg/tools/) and site/pkg-rules/ are generated and gitignored. CI builds them at deploy (runner). Set SITE_BASE_URL for the feed, the sitemap and the link cards; it defaults to http://localhost:8080/.

How the site is built#

The pages are static HTML generated by the sc-site binary in sc-publish. It reads committed inputs only:

  • notices/*.md, each checked as notice lint checks it;
  • docs/**/*.md, rendered with a table of contents (links to other docs go to their pages, links to a schema to the site's copy, links to other repository files to GitHub);
  • rules/default.toml, for the rules explorer;
  • samples/eval/*.ndjson and coverage-allow.txt, for the evaluation summary (also served as assets/eval-summary.json), each file's labels marked with its name for the per-file slices;
  • schemas/**/*.json, published as they are under schemas/, so the agent-facing files and the submission page link the site's copy;
  • samples/showcase/*.ndjson, the synthetic records the landing page walks through, the workbench example loads and the showcase bundle is made from (showcase); a record not marked synthetic fails the build;
  • keys/bundle-signing.pub when git tracks it, the bundle signing keys (agent-facing files); a file that does not parse fails the build;
  • the static files in site/: styles, page scripts and the built WASM modules.

It refuses any path under data/ or $SWARMCHASE_DATA, so watch data and case files cannot reach the site. Unreviewed leads never appear on it. The one input that is not committed is optional: a snapshot of the providers' range files and key directories for the identity page, read only as identity describes.

Pages#

PathWhat it shows
/The mission, the notices at a glance, and the method walked through the synthetic showcase (landing page).
/notices/Every notice, filterable; how notices work in plain words; one page per notice (notices).
/methodology/, /docs/The methodology and every other doc.
/docs/manual/The Field Manual, with section and glossary links and a pager.
/docs/manual/sections/Every Field Manual section by id, with search.
/operators/The operators' guide, with links to Check my logs and the workbench's access-log preset.
/check-logs/Check my logs: an access log or a set of them, the rules and the identity check, a plain summary (check my logs).
/rules/The rules grouped by kind, filterable by every SC-T tactic, each with a URL tester; an editor and an in-browser evaluation (rules explorer).
/eval/The summary of the bundled rules on the labelled set: each tier in sentences, then intervals, every slice as a forest plot; tiers recomputed in the page for edited rules (evaluation page).
/analyze/The workbench.
/unwrap/The layers inside one URL, the rules each triggers, and the URL's score and tier.
/identity/The agents an operator's log declares, checked against the providers' published ranges and keys (agent identity).
/tools/Evidence bundle check, security.txt reader, defang.
/llms.txt, /llms-full.txt, /.well-known/Plain files for AI agents and their operators (agent-facing files).
/submit/Report a swarm: a form that builds an SC-Submission document in the page, and how to send it.
/contact/The private channel, and where each audience's replies go. Every page's footer links it.
/schemas/An index of the JSON Schemas of the specs, each file published as committed.

The navigation has Notices, then the Learn, Investigate and Reference groups, then Report a swarm on its own. Each group is a <details> menu, so it works without scripts. On phones the header is two rows: the brand, Report a swarm and the theme switch, then the navigation. The docs sidebar groups the docs by directory, with the guides for the people who run the project (admin workflow, AI assistance, evaluating leads) last under Maintainers.

The layout has three widths, named at the top of the stylesheet: phone (up to 720 px), tablet (up to 960 px) and laptop (up to 1260 px). Every width query in the stylesheet and the page scripts uses one of them.

Notices#

The index is a plain table without scripts; with them, a filter form appears and keeps its state in the URL hash (#sector=health&from=2026-08-01), so a filtered view can be linked. On phones each notice is a card and the form starts folded. Beside the Atom feed the page links index.json, and it ends with how notices work in plain words (#how-notices-work): what a notice is, why names come later, what a notice never shows, who decides, and how to follow them.

Each notice page shows its status and stage (indicators only, or the date of its latest naming update), the rules and toolkit version, the body, then its dated updates as a timeline (corrections and retractions marked, statements quoted verbatim with who they are from). Affected hosts appear only once a naming update names them. notices/index.json, notices/<id>.json and notices/feed.xml are written as notice build writes them. The feed has a subtitle saying what it carries; with no notice yet, its updated date is the build's commit date (notice build uses the day it runs).

Report a swarm#

/submit/ is for agents, researchers and operators who saw agent activity. A form builds a valid SC-Submission v1 document as they fill it in: who is reporting (and whether it is an AI agent), the claim, and evidence items typed by how they can be checked.

The first and last seen times are read in the browser's time zone, or as UTC when the visitor picks it, and the page shows the UTC times the document will hold. The page checks each item's identifying fields as the schema does, shows the JSON as inert text, and offers it to copy or download. It sends nothing: the CSP allows no request but the site's own files and form-action 'none' blocks submission. It then names the ways to send the file (transports) and says what the project does with it. An agent can skip the form and write the document from the spec and the schema, which the site publishes at schemas/sc-submission-v1.json.

The landing page#

Cards under the mission route each audience: operators, providers, researchers, and AI agents with the people who run them (the submission page, llms.txt and the SC-Submission spec). A last card, across the row, is for the public and the press: how notices work, and the feed to subscribe to. Under the cards a strip gives the notices at a glance, from the committed notices only: how many there are and the latest update (or that there is none yet), with links to the index, the Atom feed, the JSON index and how notices work.

The landing page then walks one synthetic incident through the method: unwrap, match, link, time, grade agent likelihood, assess scope, attribute, report. Every step has prose, a figure drawn by the generator, its table and the Field Manual sections it rests on, so the page reads without scripts. On phones every figure after the first starts folded, so the walk reads as text first.

The match step draws one record's score as its contributions: each independence group once, context hits under their cap, and weight that fired but does not count as dashed outlines. The scope step draws each cluster's four questions and S1 to S7 tests, with payload present and out of scope in different shapes. Its agent column reads yes only from L4 on the grade ladder, and the step says so; the showcase runs are short and small, so no synthetic cluster reaches L4. Run it live loads the engine, re-runs the showcase in the page, and adds an unwrap box for any URL, the runs with zoom, the interaction graph, each cluster's agent-likelihood ladder and each cluster's scope tests.

Until there is a real notice, the notices section shows the indicator notice the engine drafts from the showcase, marked as a synthetic example: each affected host appears by class, as stage one publishes it, and hosts of one class share an endpoint row with their count. The empty notice index links it. Plant signals and declared testing never appear on the landing page.

The workbench#

The workbench analyses pasted or loaded records, including an operator's own access log (/analyze/#preset=access-log; see for operators). Try an example loads the synthetic showcase. A link to #try=example or #preset=access-log followed while on the page applies it as arrival does. Arriving from Check my logs with #preset=access-log&from=check-logs takes the log and host checked there (check my logs) and analyses them.

An empty workbench is one dashed area: paste records, drop files or folders on it, Load files, Load folder or Try an example; the empty views pane says what will show there and offers the example too. A dropped file is read in the page, as a picked one is; a file over 50 MB is read only after the page says how big it is and the visitor presses Read it anyway. Analyse is offered once there is input, and while an analysis runs it says Analysing… and is disabled, so a second press does nothing; drafting the report, preparing sightings and the CSV downloads wait for an analysis. The analysis runs off the page's thread (the analysis worker): a line under Analyse gives the stage and its counts, and Cancel stops it.

Several files, a folder or an archive load as a set of files in place of the records box; one plain file goes into the box.

An input from which no record is read shows no dashboard: an alert says nothing was read, quotes the first problem the parser met, and lists the formats the workbench reads. After a run the records box folds to a line with the record count and Edit, so the clusters sit under the summary.

On a wide screen it has three panes: the records and clusters, the views, and the context of what is selected, with the flagged records and the endpoints at full width below them. Phones step through them; the tables belong to the Explore step. The views are:

  • Runs: a lane per cluster and a bar per run, pauses shaded, shared pauses outlined. Fit to activity (on by default) shrinks each idle stretch to a narrow hatched gap that names its length, so runs days apart stay readable; a zoomed window is always drawn to scale;
  • Runs zoom: drag across the plot, or focus the chart and use +, -, the arrow keys and 0;
  • Fleet: clusters as nodes and the hints between them (shared pauses, probe chains, payload dialects, soft links, candidate fleets) as edges, one style per soft-link kind in the legend; a kind the page does not know is drawn in the generic style under its own name. Clusters sit on an ellipse in order of first activity, except that a candidate fleet's members sit together on a light band that names it, as on the landing page;
  • Swarm: runs as nodes on a time axis and the interaction hints between them (S1 to S6) as arcs, styled by kind and weighted by strength;
  • Rules × clusters: a heatmap whose cells filter the records;
  • Rhythm: records by weekday and hour, UTC;
  • Endpoints × time and Hourly.

The context panel shows a selected cluster's agent-likelihood ladder (every rung from L0 to L5, the signals on each, counter-signals, and any cap that holds the grade below what its signals reach; the grade is labelled unmeasured on held-out data), its scope assessment, its sources and corroboration, its tactics as a small bar chart, its timing, and the hints the analysis found, among them relay stacks, benchmark targets, stale knowledge and the languages of prose in parameters.

The scope section (SC-FM 15) answers the four questions as separate chips (payload present, agent present, out of scope, coordinated), states the evident task and the target classes reached, and lists the S1 to S7 tests with their outcome, detail and records; selecting a record shows it. Payload present is observed from the tier and drawn plainly; out of scope is an inference from the tests and drawn in the alert colour, so a payload in scope reads differently from scope exceeded. A selected record whose score is less than its rule weights add to says why (groups and the context cap).

Show attribution layers lists the developer, host, harness and model-family candidates, gated at the naming level chosen for the report. Plant signals sit in a collapsed Internal review section: they concern the visitor's own file and never appear on a generated page. A signal most clusters in the input carry shows its base rate and is marked as context that does not count toward review. Declared testing is left out: the page drops it on receipt, and it reaches only an Internal case notes draft, as in the CLI.

The links panel lists shared pauses, dialects, probe chains, common features, escalations, candidate fleets, soft links and interaction hints. The kinds with nothing found share one None found line, each with what finding none means as its tooltip. Chains and escalations timed by archive captures show no pace.

Chart text is at least 12 px at the scale the chart is drawn. Every view has a table view and a Download SVG button, which saves the chart as drawn, with its colours, as a standalone file.

The flagged records table leads with what flags a record (tier, score, rules, the defanged URL), then its time and cluster. It sorts by score, time or cluster, and a filter narrows it to records whose defanged URL or rule ids contain the text typed. A host never breaks inside its cell; a URL wraps where it must. Rule ids here, in the context panel, on the unwrap page and in the bundle check link to their row on the rules page.

The flagged records (as the table lists them, filter and order included) and the endpoints each download as CSV: hosts and URLs defanged, times in UTC, and a cell a spreadsheet would read as a formula prefixed with a quote mark. The endpoints table gives times to the minute with UTC in its header, and its Hits / all column counts the suggestive or significant records to a host out of every record that targets it.

Every mark that selects something is a keyboard button (Enter or Space), focus stays on it after the views redraw, and Escape clears the selection. The flagged records table is one tab stop (a grid whose arrow keys move along the rows), and so are the Runs view's lane labels and its run bars. A tooltip shows on hover, focus and tap; Escape, or a tap elsewhere, dismisses it. Edges, shared pauses and rhythms are hints to check, not evidence (methodology). The selection is kept in the URL hash as ids only (a cluster key, a record number, a rule id), never hosts or URLs.

The last step drafts a disclosure, on Draft the report or when the section scrolls into view, from the analysis the page shows (loading). A change to the selection or the report options redrafts it after a short pause, and only while the section is on screen; Copy and Download .md redraft a stale draft first. It follows the CLI's naming gate: pick the weakest evidence that may name a developer (the choices fill in, at the default level, when the engine loads), and paste claims from case work (the JSON swarmchase report --claims reads). Outside case notes, a developer below that level is not named (attribution).

Check my logs#

/check-logs/ is the operator's short path: paste or drop an access log once and name the host. The tools module runs the workbench's analysis on it (format access_log, the host in the field map) and the identity check with the providers' files the site was built with, then the page says in plain words how many requests the rules flagged and which rules, lists the strongest of them defanged, and counts the agents that name themselves: verified, claims only, and spoofed (grouped by address and declared product, in an alert).

It ends with what to do next: look closer in the workbench or on the identity page, share sightings, report a swarm, or reach the project privately. The next steps are links in the page's markup; the script only shows them. Try an example runs a synthetic log on a reserved host against synthetic range files, labelled as such. Nothing is uploaded or fetched beyond the site's own files.

It takes a set of files as the workbench does. A pasted log is read as an access log, or detected when it starts as JSON; so is each file of a set.

The workbench link under the flagged requests, and the next steps' link to the access-log preset, carry the logs over: the page puts the log and the format it read it in (or each file's name and text) and the host's field map in the tab's sessionStorage ({v: 1, log, format, map} or {v: 2, files, map}) and opens /analyze/#preset=access-log&from=check-logs. The workbench reads it once, removes it and the flag, and analyses it in that format; a log carried without one is read in the format Check my logs picks for it.

The logs are never in the URL; a set past about 4 MB of text, or one too large for the browser's session storage, is not carried, and the page says to load the files in the workbench. A file over 50 MB is read only after the visitor confirms, and Check my log is disabled while it runs.

Sets of files#

Check my logs and the workbench take several files at once, a folder (Load folder, or files and folders dropped on the log area), and archives: .zip (stored or deflate, ZIP64 read), .tar (ustar, GNU long names, pax paths), .tar.gz, .tgz and .gz. An archive inside an archive is read one level deep. The kind is read from the first bytes, not the name. archive.js unpacks them with the browser's DecompressionStream, a stream at a time, in the analysis worker with the rest of the analysis; inputs.js reads them into the module, and the progress line names the files read, the bytes unpacked and the records so far.

A .gz of several members (logs joined with cat, bgzip, pigz) reads as their texts joined: the reader finds each member's end by its header and deflate blocks and hands the browser's decompressor one member at a time, because some browsers stop at the first member. Zeros after the last member are ignored.

Each file is parsed on its own (inputs_add in sc-wasm), in its own format under auto-detect, with one field map for all of them; then one analysis runs over every record (inputs_analyze), so clusters and timing span files. The module numbers each record's id by its file (f2:log-17); file names stay in the page. A per-file table follows: file (its path inside the archive), the format it was read as, lines, records, lines skipped and the first problem, then each entry not read with why. In the workbench, the record's context names its file, a File filter narrows the flagged records, the text filter matches file names, and the records CSV gains a file column.

The table lists files with a problem first and at most 100 rows, and opens by itself for a few dozen files or fewer; in Check my logs it follows the flagged requests. Why a set stopped stays in sight above it.

LimitValue
Unpacked text, whole set448 MB; the set stops there
Records, whole set2,100,000; the set stops inside the file that reaches it
Files and archive entries10,000, an archive itself not counted; the set stops there
One file or entry, unpacked256 MB; that entry is skipped
Unpacked to packed ratio400, past the first 8 MB of an entry; that entry is skipped
Tar headers and entries passed over unreadCount toward the unpacked limit and, in a compressed tar, the ratio; past either, the rest of that archive is not read
Tar directories, links and other entries that are not filesCount toward the files and entries limit
A zip inside an archiveHeld in memory to be read; counts toward the unpacked limit and its enclosing entry's ratio

Entries are skipped, with the reason shown, when they look binary (a NUL or many control bytes in the first 8 KB), are encrypted, use a zip method other than stored or deflate, or are tar links, devices or pipes; a tar that ends before its end blocks says so; directories and macOS metadata (__MACOSX/, ._*) are left out silently. Entry names are labels, shown as text and never used as paths, so a name that climbs out of the archive or carries markup is harmless. A pax path is read by its byte lengths, so a name in any script comes through whole. Payloads stay inert as everywhere on the site. No outside library and no request is involved.

The record and size limits come from the module's memory: it holds every record and the analysis in at most 4 GB, and a record of an ordinary access log costs it a little over 1 kB. A set that unpacks to over 50 MB, as its gzip trailers and zip directories say (a gzip's trailer holds only its last member's size, modulo 4 GiB), is read only after the visitor confirms. Should the module still run out of memory, the page says so in plain words, keeps the per-file account, and suggests splitting the set or the CLI; the worker is ended and the next run starts a new one.

An analysis of more than 50,000 records stays in the worker: the page gets its counts, clusters, links and timing, and its flagged records (at most the 100,000 highest scoring) with their indexes, not a result for every record. A record the page does not hold is fetched by its index when the URL names it (held_results). The analysis JSON download then holds those records with result_index, and a line under the flagged records counts any left out.

check-archive.mjs tests the reader in Node on archives built at run time; the XSS check loads a hostile archive on both pages.

A site built without the providers' files (no snapshot, or one that holds no range file or key directory) shows a notice under the heading of this page and of /identity/: spoofing cannot be checked, and a declared agent stays a claim. The identity page hides it while the operator's own files or the example are in use.

The analysis worker#

The workbench and Check my logs run the analysis in a module worker, engine-worker.js, so a large set leaves the tab responsive. The worker loads the tools module and holds the analysis it made; the endpoints, the report draft, the sightings file, the attribution layers and the identity check read it there. Messages carry text, picked files and results, never markup. The page keeps its own copy of the module for the quick helpers (defang, the rules and claims checks).

While it runs, a line under the run button shows a bar, the stage (starting, reading files, analysing, checking declared agents) with files, bytes and records, and Cancel. A screen reader hears each stage once and the outcome, not every count. Cancel ends the worker, and the analysis it held with it, so the page clears the dashboard; the next run starts a new worker.

When no worker starts (no module workers, or a script the browser refuses), runner.js runs the same code on the page; Cancel then stops a set before its next file. worker-src 'self' in the CSP admits the site's own worker script, and the one Trusted Types policy, in runner.js, passes that script's URL to new Worker() and nothing else.

Agent identity#

/identity/ checks the AI agent identities an operator's own log declares, with the tools module's identity_check as swarmchase identity check runs it (identity). The log input works as the workbench's: paste or load, a format, a field map naming the host. Try an example checks a synthetic log from documentation addresses against synthetic range files, labelled as such.

The result counts every class (signature verified, address verified, in range undeclared, signature unchecked, user agent claim only, signature failed, spoofed, none) and each provider product per class, lists spoofed records first in an alert, then every record with a declared identity: client address, class, provider, product or signer, the file that decided it, and the reason. Records are text; a signer URI and every detail are defanged.

The providers' files table lists each range file and key directory in the catalog with its address as text, which copy is in use (the build's snapshot with its fetch date and age, the operator's own copy, or none) and a picker for a newer copy. Load files takes several at once by the names identity refresh writes (<id>.json) or the last part of a catalog URL; a name two files share, such as http-message-signatures-directory, is loaded from its row. The page never fetches a provider's file (why); without files, it says every declaration stays a claim.

Sharing sightings#

Under the report, Share sightings turns the analysis into an SC-Sightings v1 file, so an operator who checked its own log can let the same swarm be matched across operators without sending the log. Prepare sightings loads the tools module and shows the whole file, with a list of what it holds and what it never holds; Download saves exactly the text shown. Ticking Include my host names or editing the optional description redraws the preview first.

The file carries keyed hashes of each flagged cluster's linking features and captured secrets, its scaffold, its hour buckets, client ASNs and its grade; never a URL, path, query value, address, user agent or cookie. The page sends nothing; the operator chooses whether to send the file, and to whom (for operators).

The Field Manual#

The manual's pages link FM-n.m section ids and SC-Tn tactic ids in running text to their sections, and the first use on a page of each multi-word glossary term to its definition, with the short definition as a tooltip. Each page ends with previous and next; the hand-written index, previous and next line each chapter opens with, for reading on GitHub, is dropped. The section index lists every section; its search runs in the page over assets/fm-index.json (ids, titles, chapters and the start of each section's text), so no module loads.

Rules explorer#

The rules are grouped by kind. Each is a row (its id, weight and flags such as significant alone, and while closed its description cut to one line) that opens on the rest: the description, what it matches, its independence group if it has one, its tactic and Field Manual section, the pattern, the URL tester and its hits on the labelled set. The id wraps in its own column, so the row's chevron stays on its first line. Rows are closed in the markup and the page script opens them on a wide screen; on phones they stay closed, as do the filters, and the rule a link names (#rule-<id>) opens and is scrolled to. A kind whose rules the filters all hide is hidden with its heading. The lede gives the tier thresholds; how a score adds up is folded under it.

Test a URL unwraps a URL with the engine, says whether that rule fires and on which layer, and lists the other rules that fire; the URL is decoded, never opened, and shown defanged. The tactic filter lists SC-T1 to SC-T9; SC-T8 and SC-T9 are cluster- and fleet-level judgments that no rule makes, and the page says so.

Evaluation page#

The page opens with the tiers in plain words: for each, how many agent records the rules flag and miss, how many of the records they flag are benign, and the range the share of right flags could take by chance. The sentences are made from the summary at build, on the records collected rather than written for the set when there are any, else on all of them. The cards with each rate's 95% interval, the confusion matrices and the forest plots follow. The chart of hits per rule, a bar for every rule, is folded under its section with its table beside it.

Edited rules#

The rules page edits the rules in place. Saved edits are kept in that browser and used by the workbench, the unwrap tool, the bundle check and the evaluation page, which say so. Test against labelled set scores the bundled and the edited rules side by side and lists records whose tier changed (evaluation).

The WASM modules read rules as JSON. Edited rules are TOML, as the CLI reads them, and a small second module (sc-rules-wasm) converts them; it loads only when there are edited rules. The browser module's regex has no Unicode property classes (\p{…}); a rule that needs one works in the CLI only.

Evidence bundles#

The tools page checks an evidence bundle. Pick its files or its folder, or Load the showcase bundle, and the page:

  1. checks MANIFEST.sha256.sig in the tools module against the keys the site publishes (read from bundle_signing in its swarm-report.json, so a site without a key file is never asked for one), and shows the bundle as signed by its key id, signature invalid (which fails the check), unsigned, or not checked when the site publishes no key (signed bundles);
  2. checks each SHA-256 against the manifest;
  3. re-runs the analysis with the bundled rules, or the edited ones;
  4. compares every tier and cluster with the stamp, as case verify does;
  5. shows the stamp with the method and evaluation snapshot it cites, the attribution layers, the runs per cluster, the clusters, any candidate fleets and escalations, and the records, defanged.

The attribution table has a row per layer (developer and operator, hosting and runtime, harness and framework, model family) with each party or candidate, its label (Confirmed, Suspected, Possible, Unattributed) and the strongest likelihood word that label allows, the evidence, and the claim tag. Developers are shown only as a report at the default naming level would name them. Pick the case's claims.json (the array report --claims reads) with the bundle to merge in claims from case work.

The showcase bundle is made at site build from the showcase records, as case bundle makes one, under the bundled rules and with their figures on the labelled set. Its case id is synthetic-showcase, and the page labels it synthetic when it shows it. It lets a visitor see the check work without a real bundle. It is never signed: a signature would vouch for records nobody collected.

Loading and size#

The browser runs three modules, each built with its own size budget:

ModuleLoads
Engine (sc-wasm)On arrival on the unwrap page; on the landing, rules and tools pages when asked.
Tools (sc-wasm --features tools)On the workbench, alone, at the first sign of input; elsewhere for the bundle check, the security.txt reader, the identity check and the labelled eval.
Rules converter (sc-rules-wasm)Only when there are edited rules.

The tools module carries the engine's bindings as well as its own, and one analysis slot that both read: the report draft, the sightings file and the attribution layers come from the analysis its own analyze made. So the workbench loads that one module for its analysis and its report, never both; its analysis worker loads the same file, which the browser has cached. It loads nothing on arrival: focus on the records box, a picked or dropped file, or a pointer on Try an example starts the download, and Analyse waits for it. Keeping drafts, bundle checks and evaluation out of the engine keeps the module the unwrap page and the landing page's live run download small. Other pages load no WASM.

Only the unwrap page preloads a module, the engine. The tools page preloads nothing. The bundle check and the security.txt reader load the tools module on first use, and defang loads the engine. Each card has its own status line, so a module that fails to load leaves the other cards working, and the next use retries it. A loading message gives the module's download size, gzip-compressed as the host serves it; the build measures it. The only render-blocking script is the few lines of theme.js, which apply a stored theme before the first paint.

Each page lists every module its scripts import, directly or not, as a modulepreload link, so the browser fetches them together instead of one import level at a time. Scripts, the stylesheet and the WASM modules are published under content-hashed names (js/site.<hash>.js): GitHub Pages sets its own cache headers, so a changed file gets a new name and a cached one is never stale. The generator rewrites the references in pages and between modules, and check-site.py fails a page that loads an unhashed asset.

Until a module is ready the page says so and the controls that need it stay disabled, or, on the workbench, wait for it. The release build fails when any module is over its budget (the engine's is 2 MiB; the tools module's is the engine's plus 448 KiB for its own part, since it carries the engine) and prints each module's size and headroom.

Agent-facing files#

The build writes four files that tell an AI agent, or the person who runs it, what the project is and how to report to it. Code: agents.rs.

PathFormat
llms.txtThe llms.txt convention: title, summary, sections of links.
llms-full.txtllms.txt, then the full text of the policies, the guides for operators and providers, and the submission spec.
.well-known/security.txtRFC 9116, for the project itself; Expires is 180 days after the build.
.well-known/swarm-report.jsonThe same reporting facts as JSON; descriptor names its version.
keys/bundle-signing.pubThe bundle signing keys, only when the repository commits that file.

They point at the submission page (submit/), the submission spec (docs/specs/submission-v1.md) and its schema (schemas/sc-submission-v1.json, the site's copy), and the private contact. They state that a person reads every submission, that nothing is published unreviewed, and that each claim is checked against independent public records before it reaches a report or notice.

The text is plain statements of fact. It never addresses the reader as a system prompt would, never asks an agent to do anything beyond an optional report, and never asks it to contact anyone else. It names only this site's pages and the repository, never a host from a record.

Both .well-known files say whether evidence bundles are signed. swarm-report.json carries bundle_signing: {"signed": false}, or the algorithm, the key file's URL and each key's id and base64 public key. security.txt says it in a comment, because RFC 9116 has no field for it: Encryption names a key for encrypting reports sent to the project, the opposite direction, and an unregistered field would read to a parser as a contact detail. The providers page carries the key id, or says no key is published yet.

No convention for a "where to report agent activity" descriptor exists. Agent cards and plugin manifests describe an agent or its API, not an intake, so swarm-report.json is a plain document under a well-known path (RFC 8615). The site is a GitHub Pages project site (<owner>.github.io/<repo>/), so the files sit under the project path, not the host root that RFC 9116 and RFC 8615 name, and a tool that looks only at the root does not find them. Every page therefore links them: a rel="alternate" link to llms.txt in its head, and links to llms.txt, the submission page and security.txt in its footer. The Pages upload includes hidden files so .well-known/ is published.

The same holds for robots.txt and the Sitemap line in it. Crawlers read robots only at the host root, so on a project site they never see the site's copy; a crawler finds the pages by their links, and the sitemap only when it is submitted to the search engine.

Page references#

Every page and agent-facing file carries a canary token: a reference distinct per file and per month (swc-<YYMM>-<12 characters>). A token found in a public record shows that something read that surface and repeated the token, no earlier than that month. That may be an agent; the token never says who.

The build embeds tokens when SWARMCHASE_CANARY_SEED holds the secret seed, and drops the reference line (in swarm-report.json, the reference member) when it is unset, so a fork or a CI run without the secret builds cleanly (configuration). Tokens are inert, the narrow exception in the passivity test (FM-3.2): no instruction, nothing asked of the reader, no third party, and only on hosts the project controls.

A token is an HMAC of the file's path and the month under the seed. The month is in clear, the path is not, and neither the seed nor any other token can be worked out from published ones, so a copy cannot be backdated. Code: canary.rs.

After a build with a seed, swarmchase canary register _site checks each token against the seed and records it in <data>/canary/registry.json. canary scan <records> and every watch run look for registered tokens in records (watch). A sighting says that a surface was read and the token repeated, never who: anyone can copy a token from the page, so a token never links or merges records (methodology).

Every page carries Open Graph and Twitter card tags with its title and description; a notice's description is the first paragraph of its body, cut to a sentence-sized length, then its status and date. With an https base URL (a deploy), it also carries its canonical URL and the preview image og-image.png (1200 by 630) with its text alternative, for both Open Graph and the Twitter card; a local build on http://localhost leaves the absolute URLs out.

The home page describes the site and the project as JSON-LD (WebSite, Organization), and each Field Manual chapter as a TechArticle that is part of the manual. JSON-LD is a script element of a data type, which browsers never run, so the CSP does not apply to it; its text escapes <, > and &, so no value can close the element, and check-site.py allows no other inline script.

The sitemap gives a notice its latest update date, a doc or the rules page its last commit date, and a tool page (the workbench, Check my logs, unwrap, identity, tools, submit) the last commit date of its page script, so a rebuild of the same commit writes the same sitemap. A shallow clone cannot date its files, so the build then leaves lastmod out; the deploy checks out full history for it.

Printing#

A print stylesheet drops the header, footer, navigation, tables of contents and buttons, prints on white with black text, opens folded sections, keeps cards and table rows from splitting, and spells out external link addresses in guides and notices.

Accessibility#

Every page has a banner, navigation, main and footer landmark, one h1 and headings in order, a skip link and a visible focus ring. Charts are labelled groups with a table view. Status and tier colours reach 4.5:1 against every surface in both themes. The two tiers differ in lightness as well as hue (about 2:1 between them in each theme) and in shape: a significant mark is filled, a suggestive one is a ring around a light tint, in charts, legends and tier pills. Disclosure rows and their links on the rules page are at least 24 px tall. Motion stops under prefers-reduced-motion.

Safety#

Records, notices, claims and edited rules can carry attack payloads, so the site treats them as inert text.

  • Every page carries a strict Content-Security-Policy: only the site's own scripts, styles, WASM modules and worker, no inline script or style, no requests elsewhere, and require-trusted-types-for 'script', so every HTML and script sink refuses a plain string. The page scripts use none but new Worker(), whose URL passes through the one policy (the analysis worker).
  • Generated pages escape every value; page scripts insert text, never HTML. URLs from records are shown defanged and never as links. The only links a script makes are to the site's own pages by a relative path and a fragment (a rule's row), with the fragment URL-encoded.

check-site.py checks the sources in CI and the pre-commit hook, and every generated page and the build's inputs after a build; on a build it also checks that every link with a #fragment to a page of the site names an id on that page (fragments the page scripts read, such as #rule-, #try= or #preset=, aside).

site-xss-check.mjs builds the site with a hostile notice, loads every page in headless Chrome, drives the workbench with XSS probes, hostile claims and hostile edited rules, runs Check my logs on a hostile access log, loads an archive with hostile entry names and lines on Check my logs and the workbench (in the worker, and again on the page with workers unavailable), picks a hostile evidence bundle in the bundle viewer, checks the notice filters on synthetic notices (crates/sc-publish/tests/fixtures/notices/, never published), and fails if any of them runs; CI runs it at deploy.

Source: docs/reference/browser-toolkit.md