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 → /) |
/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 |
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 a build-time decision¶
MkDocs writes absolute URLs into every page's <link rel="canonical"> and into
sitemap.xml, from site_url. That value is baked in when the frontend image
is built, not when it starts:
# docker-compose.yml, service: frontend
build:
args:
SILENTSWARM_DOCS_URL: https://${SILENTSWARM_DOMAIN:-silentswarm.nexpatch.ai}/docs/
Set SILENTSWARM_DOMAIN in .env before building the frontend. An image
built with the wrong domain publishes someone else's canonical links, which is
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.
docker compose build frontend # re-bake after changing SILENTSWARM_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.
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, where the important pages are, and an "Accurate as of this page" section that states plainly what the marketing copy could be read to imply:
- 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
# Titleis 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
.mdfiles become absolute documentation URLs - a model following../03_operations/auth.mdout 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.
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.xmlhttps://<domain>/docs/sitemap.xml
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.
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
.gitdirectory (see.dockerignore), so a git-derived date would silently become the build date for every post - henceuse_git: false; - the plugin pulls in GitPython, which raises at import time when there is no
gitbinary on the image, before any config is read.use_git: falsealone does not help there, so the docs stage setsGIT_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 is methodology with an explicitly empty results table. 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 the reason a future number there will mean something.
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 afterloadwas 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-motionis honoured already, and the hero carries a visible pause control. og-image.pngis 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 ifhttps://appears in the policy. scripts/frontend_smoke.mjsallows 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.fontisfalseinmkdocs.yml(so Material emits no stylesheet of its own) anddocs/assets/extra.cssdeclares the faces against/vendor/fonts/.... The docs are served under/docs/on the same origin, so there is one copy and one cache. A standalonemkdocs servehas 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.
Language, and why there is no cookie banner¶
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.
Why that means no consent banner¶
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,
"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.
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.
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. The same content lives in
SECURITY.md
for people who found the repository rather than the deployment.
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