Skip to content

The public web surface

Everything a leader serves to someone who is not logged in: the landing page, the documentation you are reading, the files crawlers and security researchers look for, and the first-party numbers that say whether any of it worked.

This page is about a public deployment. A leader on a trusted LAN serves the same files; they just have no audience.

What is public

Path Served by Purpose
/, /de leader Landing page, English and German (/en → 301 → /)
/for/heat, /for/compute, /for/research, /for/cities (+ /de/for/...) leader Business pages: for compute heat pump makers, compute buyers, research labs and cities, with a contact form (see below)
/marketing/{name} leader Screenshots captured for the business pages
/docs/ frontend (nginx) This MkDocs site, baked into the frontend image
/robots.txt leader Crawl policy; advertises both sitemaps
/sitemap.xml leader The app's public pages, with hreflang alternates
/docs/sitemap.xml frontend Generated by MkDocs from nav
/llms.txt leader Short brief for language models (llmstxt.org)
/llms-full.txt leader The documentation as one Markdown file
/.well-known/security.txt leader Vulnerability reporting contact (RFC 9116)
/legal/* leader Imprint, privacy, accessibility, Leichte Sprache
/docs/blog/ frontend Notes - the project blog, with an RSS feed
/docs/feed_rss_created.xml frontend RSS for the blog
/docs/compare/ frontend How SilentSwarm compares to the alternatives
/api/v1/public/stats leader Aggregate community numbers for the landing page
/favicon.ico, /apple-touch-icon.png leader Favicon (SVG) and the 180 px home-screen icon
/silentswarm-logo.svg, /nexpatch-logo.svg leader The SilentSwarm logo and the NexPatch logo in the footer
/og-image.png leader The social card that shared links show

Everything else - the dashboard, pools, buckets, the admin pages, the whole API - is Disallowed in robots.txt and gated by route_policy.py. The Disallow list is not access control; it keeps pages out of search results that users should not reach through Google in the first place.

The docs are public

They used to sit behind a soft PIN cookie (docs_pin) on the frontend. That gated nothing sensitive - the source is published - but it answered every crawler with a 302, so the reference corpus was invisible to search engines and to the LLM crawlers that /llms.txt points at /docs/.

The gate is gone. /docs-gate.html now permanently redirects to /docs/ so links that were shared while it existed still work.

Canonical URLs are set when the frontend starts

MkDocs writes absolute URLs into every page's <link rel="canonical">, the OpenGraph tags, the breadcrumb JSON-LD, the RSS feeds and sitemap.xml, from site_url. The frontend image builds the docs once against a placeholder origin, and the container rewrites it at start (docker/nginx/entrypoint/40-swarm-docs-origin.sh) to:

  1. SILENTSWARM_DOCS_URL, if set;
  2. otherwise https://$SILENTSWARM_DOMAIN/docs/;
  3. otherwise the project's public docs - the same pages, so the canonical link is honest for an install that names no domain.
# docker-compose.yml, service: frontend
environment:
  SILENTSWARM_DOMAIN: ${SILENTSWARM_DOMAIN:-}

One image therefore serves correct links for any domain, and a domain change is a restart, not a rebuild. Wrong canonical links are the one SEO mistake that is hard to undo - search engines will happily drop your pages in favour of the URL you told them was authoritative - so check the start log line docs served as https://... after changing the domain.

Per-page social cards

overrides/main.html adds OpenGraph and Twitter tags to every docs page, so a link pasted into Slack, Mastodon or a chat shows a title, a description and the project image rather than a bare URL. Material's social plugin does the same thing but pulls in Cairo and Pango, which the docs image and CI would both have to grow; the template costs nothing.

A page can override the description with front matter:

---
description: One sentence, 150-160 characters, describing this page.
---

Without it the site-wide site_description is used. The description tag itself comes from Material's base.html, which reads the same front matter; main.html adds only the share tags, so every page has exactly one.

Sitemap dates

MkDocs stamps every page with the build date, so a sitemap built today claims all sixty pages changed today - and search engines learn to ignore a lastmod that is never accurate. scripts/mkdocs_lastmod.py (a hook in mkdocs.yml) records each page's last commit instead, and overrides/sitemap.xml writes <lastmod> from that only. The frontend image builds the docs without .git or a git binary, so the production sitemap carries no dates at all: an absent date is neutral, a wrong one is noise.

What a model reads

People increasingly ask a model before they ask a search engine, and a model summarising this project from the marketing page alone tends to invent the economics. Two files exist so it does not have to guess, both following the llmstxt.org convention. Their text lives in leader/web/llms_txt.py.

/llms.txt - the brief

A page of Markdown: what SilentSwarm is, the measured numbers, when it fits and when something else is the better tool, every public page (the pages for labs and companies included), and an "Accurate as of this page" section that states plainly what the marketing copy could be read to imply.

It is written as answers to the questions people actually put to an assistant - can a 14B model run on small cards, how fast, how does it compare to llama.cpp or exo, is it free for a university - because an assistant quotes what it finds stated plainly. The numbers are the benchmark page's, and tests/unit/leader/web/test_llms_txt.py fails on one that is not there.

The honesty section says:

  • contributing a machine does not pay money today;
  • the worker is not sandboxed;
  • the licence is free for noncommercial use and is not OSI open source;
  • the project is in open beta and accounts are approved by an admin.

That section is the reason the file exists. Keep it, and keep it first.

/llms-full.txt - the documentation, concatenated

Eleven documentation pages as one Markdown document (~80 KB), so a model that wants the detail reads it in one request instead of crawling and stitching twenty HTML pages. The honesty section is repeated at the top: a reader that gets only the first screen still has to come away with it.

Three things happen to each page on the way in:

  • its own # Title is dropped, because the section heading above it already says the same thing;
  • remaining headings drop one level, so the result is one document with one # rather than eleven competing ones;
  • relative links to other .md files become absolute documentation URLs - a model following ../03_operations/auth.md out of a concatenated file has nowhere to go.

The page list is curated, not globbed. PAGES in llms_txt.py names each file. docs/planning/ is internal, and a glob over docs/ would publish it the moment somebody built an image from a working tree - so docs/planning/ is in .dockerignore as well, and a test asserts every listed page exists and appears in the published MkDocs nav. Adding a page is a deliberate edit.

A deployment without the docs tree (a wheel-only install) serves a short body pointing at /docs/ rather than a 404 on a URL that /llms.txt advertises.

The leader finds the tree at SWARM_DOCS_DIR - set to /app/docs in the leader image, which copies the Markdown sources with the repo - and otherwise at docs/ next to the source checkout.

swarm://docs - the documentation over MCP

Agents connected to /mcp read the same Markdown as resources (toolset meta, always listed, read scope): swarm://docs is the index - each page's name, title, front-matter description, section and site URL, user guide first - and swarm://docs/{page} one page (text/markdown), e.g. swarm://docs/06_user_guide/training. Unlike /llms-full.txt this is every page the site publishes, not a curated reading list: the set is every .md file under the docs directory minus mkdocs.yml's exclude_docs (planning/), and a test keeps EXCLUDED_DIRS in leader/mcp/tools/docs.py equal to that list. A page name is lower case, /-separated [a-z0-9_-] segments; it is looked up in that set, never joined onto a path, and the file must resolve inside the directory - .., encoded slashes and symlinks out of the tree all answer "unknown page".

The user-facing MCP guide is Use SilentSwarm from an AI agent; the MCP tool reference is generated from the registry by scripts/gen_mcp_reference.py (a test fails while it is stale).

Crawl policy for AI agents

robots.txt names GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-User, Claude-SearchBot, PerplexityBot, Google-Extended, Applebot-Extended and CCBot, and allows them. User-agent: * already permitted them, so nothing a crawler does changes - it is a statement that being read by a model is intended.

There is a trap in doing this by hand. A crawler applies the group that names it and ignores the wildcard entirely, so an agent named without repeating the Disallow lines is an agent invited into /admin/. Every group is therefore generated from one _ROBOTS_RULES list in app.py, and a test parses the served file and fails if any group has drifted from the wildcard's rules.

To refuse an AI crawler, give it its own group with Disallow: / - do not just remove it from the list, which only returns it to the permissive wildcard.

Registering with search engines

Sitemaps are discovered from robots.txt, but a property in Google Search Console and Bing Webmaster Tools is the only place the ranking data lives. Both accept a meta tag as proof of ownership; both are read from the environment, so claiming a property is a compose edit rather than a commit with a token in it:

SILENTSWARM_GOOGLE_SITE_VERIFICATION=<token from Search Console>
SILENTSWARM_BING_SITE_VERIFICATION=<token from Bing Webmaster Tools>

Restart the leader (not the frontend) and the tags appear in the landing page's <head>. DNS TXT verification works too and is preferable if you own the zone - it covers the whole domain rather than one page.

Submit both sitemaps once verified:

  • https://<domain>/sitemap.xml
  • https://<domain>/docs/sitemap.xml

IndexNow: telling Bing after a deploy

A sitemap is read when a crawler gets round to it. IndexNow is the push side: one request names the URLs, and every participating engine (Bing, Yandex, Seznam, Naver) hears about them within minutes. Bing's index is the one several AI search products draw on, so this is also how a new page reaches the assistants that cite their sources.

Pick a key - 8 to 128 letters, digits or dashes - and set it in .env:

SILENTSWARM_INDEXNOW_KEY=$(openssl rand -hex 16)

Restart the leader: it now serves the key at /indexnow-key.txt (404 without one), which is how the engines check that the ping comes from whoever controls the host. Then, after every deploy that changes public pages:

docker exec silentswarm-leader swarm-leader indexnow --dry-run   # list the URLs
docker exec silentswarm-leader swarm-leader indexnow             # send them

The command reads both sitemaps from https://$SILENTSWARM_DOMAIN (or --base-url), keeps the URLs on that host, checks that the key file is live - the engines answer 403 otherwise, usually because the leader was not restarted - and posts them to api.indexnow.org (leader/web/indexnow.py). It is the only thing the leader sends to an outside service about the site, and only when an operator runs it.

The site links the source in the top bar, the footer, the landing page's trust answers, its structured data (sameAs, downloadUrl), /llms.txt, security.txt and the docs header. While the repository is private every one of those would answer 404, so the repository is linked only once SILENTSWARM_SOURCE_URL names it:

SILENTSWARM_SOURCE_URL=https://github.com/<owner>/<repo>

Restart the leader and rebuild the frontend (the docs header is baked in at build time from the same variable). Until it is set, the top bar, footer and docs header carry no repository link, and "the source" points at /api/v1/bootstrap/source.tar.gz - the archive every joining machine installs from, which is the public copy of the code. Only an https:// value counts.

How the landing page is drawn

The landing page is only served to visitors without a session - / and /de redirect to the dashboard once there is one - so it does not carry the app's sidebar. It has its own top bar instead: the logo, "Who it's for", Docs, GitHub (once the repository is public - see the source link), the language switch, and Log in / Register. The skip link and the admin-banner slot come first, exactly as in the sidebar's nav.html. Below 700 px the bar wraps to two rows and stops being sticky, so it never sits over the copy.

A few conventions hold across the page, and the tests in tests/unit/leader/web/test_landing_copy_spine.py pin them:

  • Colour means something. Coral is a machine and the capacity it gives; blue is answers and the member who uses the pool. The hero's two doors, the two halves of the exchange diagram and its flow arrows all follow that, so every part of one model is coral in both drawings.
  • Solid is built, dashed or hollow is planned. The exchange loop's return leg is dashed and labelled next; the earnings timeline fills only its live rung.
  • One text edge, two corner radii. Headings and running text start at the same inset in every section, banded or not. Controls and cards take the console's sharp --radius; surfaces nested inside a card take --radius-in.
  • Below the privacy floor the numbers section says what unlocks it. It names the configured floor (floor.min_nodes from /api/v1/public/stats) and never shows progress toward it - "3 of 5 machines" would publish the count the floor exists to withhold.
  • Concrete first, measured always. The headline names what runs (a 14B model on the computers you already have), with Lend a GPU, borrow a model kept as the line above it. Under the two doors sits one measured claim linked to the benchmark page, and the first section, What a pool of ordinary PCs can do, gives four jobs each with the number measured for it. Every figure has to appear on the benchmark page; a test compares them.
  • Order follows the reader's questions. What it is for, the live numbers, the two ways in, labs and companies, what runs on your machine, the benefits, the questions before installing, and only then whether contributing pays - a question for someone who is already convinced.

The two diagrams are partials in templates/public/diagrams/, each with a wide SVG and a stacked layout for narrow screens that carries the same facts as real text.

The business pages

Four pages beside the community landing page, each in English and German, for the two kinds of company the project talks to (heat E3), for research labs and for cities:

  • /for/heat - heat pump and heating manufacturers who want to build compute heaters and need the platform behind them: the heat loop drawn, what the platform does today (energy metering and the heat demand), a calculator that does the arithmetic with the visitor's own figures, and an honest what does not exist yet list.
  • /for/compute - companies that need GPU compute: open models behind an OpenAI-compatible API on machines they choose, the same status list, with the published benchmarks among what exists.
  • /for/research - university labs and research groups, who may use SilentSwarm free under the noncommercial licence: the measured numbers from the benchmark page, an honest comparison with Ollama/llama.cpp, vLLM, exo, Petals and cloud GPUs (each with when to choose it instead), three steps to a first pool with their docs pages, and the status list. Its form asks for an institution rather than a company and files under the research segment.
  • /for/cities - cities, municipalities and municipal utilities: the machines a city already runs (schools, libraries, offices) as one private cluster inside its network, joining after hours through availability windows, with the heat metered and steered by each building's demand. The live pool numbers, a calculator of what the city's machines add up to, a status list that says plainly what is missing for a city (Windows machines, document search, certifications, an offline install), and a licence answer that quotes what the PolyForm Noncommercial licence names - government institutions - and no more. Its form asks for a city or organisation and files under the cities segment.

The heat and cities pages carry a calculator (public/calculator.js, calculators C1-C2): each value is a slider beside an exact number box, a few fixed choices are chips, the hours of a day are a 24-hour strip a pointer can paint (the from and to selects under it are the way in for a keyboard), and the result is a sentence and a drawing - the heat page's year as a waterfall (electricity, the heat's value, compute revenue, the balance) with the break-even revenue marked on its slider, the cities page's model sizes against the pool's memory. The values go into the address bar once changed, so a link carries them; Reset to example values clears them. What a model size needs in the cities calculator is not the script's guess: the page is handed business.city_models() - the planner's 4-bit weight estimate (pipe/split/quantization.estimate_weights_gb) with room for the cache - and the per-card reserve, and a test holds them to the benchmark (its four 4-6 GB cards hold the 14B model, not the 32B one). A result is announced once a value is committed, never on every step of a drag.

They are reachable from four places, so a visitor never has to scroll for them: a Who it's for menu in the top bar of every public page (the four pages, and the landing page's section), a For labs and companies line under the landing page's hero buttons, the landing page's SilentSwarm for labs and companies section (right after Two ways in, one pool), and the shared footer. Like the landing page, the German pages are the English templates translated through a strict catalogue (leader/web/business.py; the business copy addresses the reader as Sie), and they are in the sitemap with hreflang alternates.

Live and measured numbers. The heat and compute pages read /api/v1/public/stats - machines, GPU memory, tokens per second, and for the heat page heat.power_kw and heat.energy_kwh - under the same privacy floor as the landing page: below it the machine numbers are withheld and the page says so. The research page quotes measurements instead, each of which test_business_pages.py checks against the benchmark page, so the copy cannot drift from the record it links to. The landing page's hero line and its What a pool of ordinary PCs can do section are held to the same check.

Questions, for readers and for assistants. Each page ends, before its contact form, with a short Questions ... ask section (id="faq"): can a 14B model run on 4-8 GB cards, is it free for universities, how is the heat measured, do existing OpenAI clients work. The answers repeat what the page and the docs already say, in a form an assistant can quote. They are also the page's FAQPage structured data (below), and every number in them is checked against the benchmark page like the rest.

Share cards. Each page and language has its own card, /og/<page>-<lang>.png, repeating the page's kicker and headline - a link pasted into LinkedIn shows the research card, in German on the German page. node scripts/render_og_image.mjs renders them all (and the default /og-image.png, copied to the docs) with the app's own fonts; re-run it when a headline changes. A page whose card is missing falls back to the default.

Contact form. POST /api/v1/inquiries stores the message (rate-limited per IP, with a honeypot field for bots) and, when SMTP is configured, mails every active admin a copy. The sender gets no mail. Inquiries are read and deleted on /admin/inquiries or with the admin_inquiries MCP tool. The privacy policy (/legal/privacy, served from the leader's data directory) should name this form: it stores name, company, email address and message to answer the inquiry.

Screenshots of the real platform

The pages show screenshots only when they exist, so they never carry a placeholder or a seeded demo. Capture them from a running leader with a real pool, signed in as yourself:

npm i --no-save puppeteer-core          # once; uses the system Chromium
SWARM_SESSION=<your swarm_session cookie> \
  node scripts/capture_marketing.mjs --base https://<your leader> --pool <owner>/<pool>

It opens the pool's overview (pool health, heat output), its nodes, and the training and inference consoles, masks every machine name, member name, email and IP address on screen (names come from the pool's own API; --mask a,b adds more), and writes the images plus manifest.json to leader/web/templates/assets/marketing/. Look at every image before committing - a name typed into a pool description is not something the API knows to mask - then commit them and rebuild the leader image. Shots whose element is missing (no heat card yet because no machine reports power) are skipped, not faked.

SilentSwarm is a NexPatch product, and the logo is drawn so that reads at a glance: NexPatch's own hexagon (the path is copied, not redrawn) with an S where NexPatch has its N. One 45° cut runs through the S at the angle of the diagonal in the NexPatch N. The wordmark copies NexPatch's two-weight split the other way round - SILENT light, SWARM bold - in the NexPatch orange #EA4D20.

The files live in leader/web/templates/assets/:

File Used for
silentswarm-favicon.svg /favicon.ico. A heavier frame and cut than the full mark, so it holds at 16 px. docs/assets/favicon.svg is a copy
silentswarm-logo.svg Mark + wordmark: the app sidebar and the landing page's top bar
silentswarm-logo-endorsed.svg The same with "a product of NexPatch" underneath: the social card
apple-touch-icon.png 180 × 180 home-screen icon, full-bleed on the page background

docs/assets/logo.svg is the mark alone, for the MkDocs header. Emails set the wordmark as live text instead of an image, because most mail clients block SVG and hide remote images until the reader allows them.

Every page links the favicon and the home-screen icon through __PAGE_HEAD__ in auth_ui.py, so no template carries its own <link rel="icon">. tests/unit/leader/web/test_brand_assets.py checks that each file still carries the NexPatch frame and that the docs copies match the app's. The wordmark is set in Montserrat (SIL Open Font License) and converted to outlines, so the SVGs don't depend on any font being installed. After changing the logo, re-run node scripts/render_og_image.mjs and copy og-image.png to docs/assets/.

What the landing page says to a search engine

The <title> and meta description are written for someone typing a problem into a search box; the OpenGraph and Twitter titles keep the softer community wording, because a link pasted into a chat is not a search result. Both are budgeted - about 60 characters for the title, 160 for the description - and a unit test fails when either overruns in either language. German runs roughly 15% longer than English, so the German versions are written to the budget rather than translated into it.

Structured data

The landing page carries four JSON-LD types:

Type What it is for
Organization NexPatch AI UG, with an @id the other nodes reference
WebSite The site itself, in both languages
SoftwareApplication What SilentSwarm is, and its licence terms
FAQPage The questions the trust section answers

Two things about it are deliberate:

The SoftwareApplication description carries the vocabulary the page does not. The reader-facing copy avoids "pipeline stage", "activations", "NAT" and "VRAM" on purpose - a visitor should not need to know what a pipeline stage is, and a unit test enforces it. But a search engine that never sees the word "pipeline" cannot rank the page for it, and a model summarising the project needs the mechanism named. Structured data is where those words cost the reader nothing, and it says exactly what the docs say.

The FAQ entries are extracted from the template, not restated. Structured FAQ data that does not match the visible page is a lie told to a crawler, so _faq_entries() in leader/web/auth_ui.py reads the trust section's <dt>/<dd> pairs out of landing.html. A copy edit cannot leave the markup describing answers that are no longer on the page, and a test compares the two sets. The German rendering comes for free: the localiser rewrites the JSON block along with the prose, because both carry the same English source strings.

The business pages carry their own graph (business.structured_data): the same Organization by @id, a WebPage that is part of the landing page's WebSite, a two-step BreadcrumbList (the language's home page, then the page) and an FAQPage read from the page's #faq section. It is built from the finished page after translation, so the German markup is German without catalogue entries of its own, and the calculators' results - also a <dl> - are never mistaken for questions.

Docs pages carry BreadcrumbList instead (overrides/main.html), two levels deep - the docs home and the page. The nav's sections are headings rather than pages and have no URL of their own; inventing one by pointing at a section's first child would tell a search engine that a page is something it is not.

The audit in CI

scripts/frontend_smoke.mjs runs Lighthouse over / and /de when the lighthouse package is installed, and fails on an SEO score below 0.95. Performance and best-practices are printed, not gated:

  • performance on a shared CI runner varies by more than any regression worth catching;
  • best-practices counts the 401s that anonymous identity bootstrap legitimately logs to the console.

The audit runs before the smoke test logs in. With a session cookie, / redirects to the dashboard, and the audit would be scoring an app page that is deliberately not indexed at all - which reads as "page is blocked from indexing" and looks like a real failure.

Publishing

The blog

Material's blog plugin, with posts in docs/blog/posts/. A post needs front matter with a date, a category from the allowed list in mkdocs.yml, an author from docs/blog/.authors.yml, a description (it becomes the meta description and the RSS summary), and a <!-- more --> marker for the excerpt - post_excerpt: required, so a post without one fails the build rather than showing the whole article on the index.

The RSS feed comes from mkdocs-rss-plugin and takes its dates from front matter, not git. That is not a style preference - it is what makes the docs buildable in the frontend image at all:

  • the image builds from a context with no .git directory (see .dockerignore), so a git-derived date would silently become the build date for every post - hence use_git: false;
  • the plugin pulls in GitPython, which raises at import time when there is no git binary on the image, before any config is read. use_git: false alone does not help there, so the docs stage sets GIT_PYTHON_REFRESH=quiet, which is the documented escape hatch.

If a docs build starts failing with Bad git executable, that pair is what came undone.

Adding the plugin block to mkdocs.yml means search has to be listed explicitly - naming any plugin replaces MkDocs' default set, and leaving it out silently removes the docs search box.

Comparison and benchmarks

Two pages exist because they answer the two questions this audience arrives with, and both are written to be believed rather than to win:

  • How SilentSwarm compares names the cases where Petals, Exo, GPUStack, Ray or a plain single-node stack is the better answer, and carries a table of what is not built yet. It deliberately publishes no throughput comparison against other projects: we have not run them on identical hardware, and a table assembled from other people's published numbers is marketing.
  • Benchmarks publishes our own measured numbers - Qwen3-14B across two home PCs since 2026-10-16 - with the setup, the method and the limits next to every one. The rules on it - state the link, report distributions not means, publish the unflattering configuration, keep the quality cost next to the compression ratio - are why the landing and research pages may quote it and nothing else.

CHANGELOG.md records what shipped, including a "Known limitations" section, so the honest account is in the repository as well as on the site.

Performance notes

Compression is done at the edge, in docker/nginx/frontend.conf, not in the leader. The pages inline their CSS and JS, so there is a lot of compressible text in each response - about 104 KiB on the landing page. It is not GZipMiddleware on the leader because Starlette's implementation writes into a GzipFile without flushing per chunk: SSE frames would sit in the compressor's buffer and the training metrics stream would stop being live. text/event-stream is likewise absent from gzip_types for the same reason.

Two measurements worth recording, so they are not re-derived:

  • The hero canvas is not the LCP element. The largest contentful paint is the <p class="lead"> text. Deferring the rotating node-cloud animation until after load was tried and measured: LCP 3493 ms before, 3502 ms after, total blocking time 0 ms in both. The animation yields between frames and never creates a long task, so the change was reverted rather than shipped on a hypothesis. prefers-reduced-motion is honoured already, and the hero carries a visible pause control.
  • og-image.png is not in the render path. It is referenced only from <meta> tags, so it is fetched by social scrapers and never by a browser rendering the page. 104 KB of PNG costs a visitor nothing, and PNG is the format every scraper handles - converting it to WebP would trade support for bytes nobody waits on.

The fonts are self-hosted

Space Grotesk, Inter and JetBrains Mono are vendored by scripts/vendor_frontend.py - pinned, SHA-256-checked @fontsource tarballs, extracted into templates/vendor/fonts/ and served same-origin from /vendor/fonts/. They used to come from fonts.googleapis.com, which was wrong twice over:

  • the stylesheet blocked the first paint on every page (Lighthouse scored it as ~660 ms of render-blocking work; that audit now passes);
  • hotlinking it sent every visitor's IP address to Google, which a court has held to be an unlawful transfer for a German site (LG München I, 20.01.2022, 3 O 17493/20).

Only the weights the stylesheets ask for, and only the latin and latin-ext subsets - German needs nothing beyond latin (ä, ö, ü and ß all live under U+00C0-00FF), and latin-ext is there for names that do. Twenty files, ~390 KB on disk; a visitor downloads the two or three faces their page actually renders, because each @font-face carries the unicode-range from the upstream package.

The generated vendor/fonts.css is inlined with the rest of the CSS rather than linked - a linked stylesheet is one more render-blocking request for about three kilobytes. Two faces are preloaded in the <head>: the body face the largest paint is made of, and the display face the h1 is set in. Font preloads need crossorigin even same-origin, or the browser fetches the file twice.

Consequences worth knowing:

  • The CSP now has no external origin at all - font-src 'self' data:, style-src 'self' 'unsafe-inline', and a test fails if https:// appears in the policy.
  • scripts/frontend_smoke.mjs allows no third-party hosts. The guarantee that nothing leaves the origin is now literally true, and enforced per page.
  • The docs use the same files: theme.font is false in mkdocs.yml (so Material emits no stylesheet of its own) and docs/assets/extra.css declares the faces against /vendor/fonts/.... The docs are served under /docs/ on the same origin, so there is one copy and one cache. A standalone mkdocs serve has no /vendor/ and falls back to the system stack.
  • A fresh checkout that has not run the vendor script renders in system fonts. Every family stack ends in a real fallback, so that is a plainer page rather than a broken one - and a test asserts it.

The language a visitor gets

/ and /de are two URLs with their own canonical and hreflang links, so / redirects (302) a browser that asks for German rather than answering the same URL with different content - which would make both of those links a lie.

The signal is the browser's Accept-Language header, not a geo-IP lookup. Where someone is says much less about what they read than what they configured, and looking it up would mean processing an IP address to infer something about a person for a worse answer. An explicit choice always wins: negotiate_lang() reads the swarm_lang cookie before it reads the header, so the switcher works for a German browser instead of bouncing it back on every click.

Both language URLs send Vary: Accept-Language, Cookie. Without it a proxy can hand the English page to a German visitor, or hand everyone the redirect.

The cookies this site sets

Cookie When Lifetime Purpose
swarm_lang only when a visitor picks a language (/lang/{code}) 1 year Remembers that choice
swarm_csrf on pages that can submit a form 14 days Double-submit CSRF token
swarm_session on login session TTL Authentication (HttpOnly)
swarm_pool when a signed-in member switches pool 1 year Which pool the consoles act on

There is no analytics cookie and no third-party cookie. The landing-view counter is server-side and aggregate (see below), the fonts and UI libraries are served from this origin, and the Content-Security-Policy allows no external origin at all.

Consent under § 25 TDDDG (the German implementation of the ePrivacy Directive) is not required for storage that is strictly necessary to provide a service the user explicitly requested. Session, CSRF and active-pool are plainly that. A language preference is the textbook exempt case - the EDPB names "user-interface customisation" cookies explicitly - provided it is stored as a result of the user's own choice.

That proviso is why viewing a page now stores nothing. The landing page used to write swarm_lang on every render, before anyone had chosen anything, with a one-year lifetime; that was the single piece of storage here that would have been hard to defend, and it bought nothing Accept-Language does not already answer. A test asserts the whole public entry point - /, /de, the legal pages, robots.txt - sets no cookie at all.

Adding a banner anyway would be worse than useless: it would ask for consent that is not needed, train people to click through one, and lower the conversion of the page it covers. What a public deployment does need instead is a privacy notice (Art. 13 GDPR) and, in Germany, a provider identification (§ 5 DDG) - see SWARM_PRIVACY_FILE and SWARM_IMPRINT_FILE in Users and authentication. Those are served as placeholders until an operator configures them, and a placeholder is not a privacy policy.

Reading the funnel

The landing page loads no third-party script, so there is no analytics vendor to ask how many people saw it. The numbers are first-party and aggregate:

curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  https://<domain>/api/v1/admin/funnel?days=30
{
  "days": 30,
  "landing_views": {"human": 4120, "bot": 980, "pages": [...], "window_days": 30},
  "waitlist_total": 63,
  "waitlist_by_source": {"landing": 58, "earnings": 5},
  "signups_by_intent": {"host": 21, "use": 34, "unknown": 2},
  "signups_total": 57,
  "nodes_registered": 9,
  "nodes_ready": 7,
  "members": {"registered": 57, "node_joined": 12, "node_served": 5, "came_back": 31},
  "daily": [{"day": "2026-09-01", "human": 210, "bot": 44}]
}

Four rungs, in order: saw the page → left an email → finished a registration → a machine actually joined. The last one is the only number that means the project works; everything above it is a ratio that tells you which step is leaking.

members follows people rather than machines, for a beta success metric such as "hosts whose node served someone within a week": registered → added a node (a node that joined with the member's own token) → that node served a chat → came back (signed in again at least a day after registering). Two limits, stated so nobody reads more into them: "served" counts chat requests from anyone, the host included - who asked is recorded only once the credits ledger's usage events exist - and a training run is not yet attributed to the members whose nodes ran it.

Members can also write to you: Send feedback in the app's sidebar stores a note in the inquiries inbox (/admin/inquiries, segment feedback) with their account email, and mails every admin a copy when SMTP is configured.

landing_views is windowed by days; every other total is lifetime. The field names say so because dividing a 30-day numerator by a lifetime denominator is the easiest way to talk yourself into a bad decision.

What is stored

One row per (UTC day, path, language) with two integers - human views and crawler views. No IP address, no cookie, no user-agent string, no session identifier. Nothing that identifies a visitor, so this needs no consent banner and adds nothing to a data-subject request. Counts are buffered in memory for up to 30 seconds and flushed on shutdown, so a launch peak is a write every few seconds rather than one per page render.

Crawler traffic is counted separately rather than dropped: during a launch, the bot share is the difference between "the post did nothing" and "the post worked and half of it was Bingbot". Classification is by User-Agent and deliberately errs towards calling things crawlers, so the human number is a floor.

Load

exp/scripts/load_test.py <leader-url> holds 50 connections per target for 30 seconds against the landing page, the docs, the public stats and the chat API (unauthenticated by default, so it exercises auth and routing without putting work on the GPUs). Its user agent counts as a crawler, so it does not inflate the funnel. Production on 2026-10-16 (run from the leader's own host, so this is server capacity, not the network):

Target Req/s p50 p95 Status
/ 255 179 ms 309 ms all 200
/docs/ 500 71 ms 284 ms all 200
/api/v1/public/stats 618 57 ms 230 ms 429 after the first 41: rate-limited per address, as intended
/v1/chat/completions 355 94 ms 420 ms all 401

No errors in the leader's log, and both containers stayed up. Chat under real load - streams answered by the GPUs - is what the benchmarks measure.

Reporting a vulnerability

/.well-known/security.txt carries the contact, the policy link and an Expires date computed 180 days ahead on every request - a security.txt that expired last year reads as an abandoned project, which is the opposite of the signal it exists to send. Reports go to security@nexpatch.ai; please do not open a public issue. The same content lives in .github/SECURITY.md in the repository, for people who found the repository rather than the deployment. While the repository is private, the file names this section as its policy; once SILENTSWARM_SOURCE_URL names a public GitHub repository, it adds the repository's advisory form as a second contact and its SECURITY.md as the policy.

After a redeploy

Docs changes ship in the frontend image; app and landing changes ship in the leader image. Rebuilding only the leader after editing docs/ ships nothing. Check both containers are up - a stopped frontend surfaces as Bad Gateway even while the leader is perfectly healthy:

docker compose ps                                   # both must be running
curl -sI https://<domain>/docs/ | head -1           # expect 200, not 302
curl -s  https://<domain>/robots.txt | grep Sitemap # expect two lines
curl -s  https://<domain>/.well-known/security.txt | head -1

If public pages changed and an IndexNow key is set, tell the engines:

docker exec silentswarm-leader swarm-leader indexnow