# Multi-Site Networks

> Wire a family of separately-hosted documentation sites together so search engines and AI agents can see them as one ecosystem

**Site index:** [https://boilerplate.2plot.dev/llms.txt](https://boilerplate.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 8 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://boilerplate.2plot.dev/sitemap.xml  


---

.. llms_copy::Multi-Site Networks

.. toc::

### Introduction

This page is for the case where you run **more than one** app: a hub, a set of
subdomains, some projects on unrelated domains, and dependencies on third-party
documentation. If you run one site on one host, you don't need any of it —
`add_llms_routes(app)` is the whole story, and the [AI/LLM Integration](/examples/ai-integration)
page covers that.

Everything below is already wired up in this boilerplate. Fork it and you
inherit the wiring; what you have to change is *one value*, and the last
section explains why getting it wrong is the most expensive mistake available
in this template.

---

### The problem: every SEO surface is scoped to one origin

Each thing the package serves stops at the host boundary, by design:

| Surface | Scope |
|---|---|
| `sitemap.xml` | Its own origin only — that's the sitemap spec, not a limitation to work around |
| Internal prose links | Within the app |
| `robots.txt` | One host |

So a network of a dozen hosts is a dozen disconnected islands. Nothing in any
of them says the other eleven exist.

The consequence differs by audience, and the second one is the expensive one:

- **Search engines** treat cross-host links as weak signals, but they do have
  crawlers that follow them. Given enough time and inbound links, Google finds
  your subdomains.
- **Agents don't crawl.** A model answering a question about your ecosystem
  fetches one or two URLs and reasons from what comes back. If it lands on
  `leaflet.2plot.dev`, it sees one library. There is no second request, no link
  traversal, no accumulation over time. Whatever that one response says *is*
  the ecosystem, for that answer.

Fixing it needs an explicit, machine-readable statement of the relationships,
served from **every** host.

---

### The three tiers

`register_network()` takes three lists, and keeping them distinct is the point
— collapsing them would either overclaim ownership of other people's sites or
bury your own network in an undifferentiated pile of links.

#### `peers` — same network, same operator

The hosts you own that are genuinely one product family. Peers get
`<link rel="related">` tags in `<head>`, a `## Network` section in `/llms.txt`,
and **followed** links in the prerendered body. This is the tier that builds
the cross-host graph you actually own, so put real relationships here and
nothing else.

#### `affiliated` — yours, on its own domain

Projects you built on domains that aren't part of the primary brand. Listed
under `## Related projects` with followed links. Separating these from `peers`
is what lets an agent answer *"what is the 2plot network?"* without sweeping in
every unrelated domain you happen to own — while still being able to answer
*"what else has this author built?"*.

#### `external` — third-party references

Documentation you depend on or point readers at. **Not yours.** Listed under
`## External references` and emitted with `rel="nofollow noopener"` — they are
references, not endorsements, and shouldn't pass ranking signal. Point at the
machine-readable URL when one exists:

```python
external=[
    {"name": "Dash Mantine Components",
     "url": "https://www.dash-mantine-components.com",
     "description": "The UI component layer these docs are built with.",
     "llms_txt": "https://www.dash-mantine-components.com/llms.txt"},
]
```

---

### One definition, imported everywhere

The peer list lives in `lib/network_directory.py` — **one file, copied verbatim
into every satellite**, not twelve hand-edited variants. A directory that
disagrees with itself across hosts is worse than no directory: an agent that
gets two different answers about the same network learns to trust neither.


```python
# File: lib/network_directory.py

"""Cross-host directory for the 2plot network — one definition, every satellite.

Why this file exists
--------------------
Search engines follow links between hosts weakly; agents don't follow them at
all. A model answering "what does this ecosystem provide?" fetches one or two
URLs and reasons from what came back. Landing on ``leaflet.2plot.dev`` it sees
one library, with nothing in the markup saying the other eleven hosts exist.
``sitemap.xml`` cannot fix that — a sitemap is scoped to its own origin by
design — so ``dash-improve-my-llms`` 2.1 emits an explicit machine-readable
directory instead: ``<link rel="related">`` tags in ``<head>``, a ``## Network``
section in ``/llms.txt``, and followed links in the prerendered body.

Keep the definition **here**, in the template, and import it. Twelve
hand-maintained copies of the same peer list will drift, and a directory that
disagrees with itself across hosts is worse than no directory at all.

Three tiers, and the distinction is load-bearing:

``PEERS``
    Same network, same operator. These build the cross-host graph you own.
``AFFILIATED``
    Yours, on unrelated domains. Findable when asked "what else did you
    build?" without being swept into "what is the 2plot network?".
``EXTERNAL``
    Third-party docs you reference but don't own. Emitted ``rel="nofollow"``
    — references, not endorsements.

Usage in a satellite's ``run.py``, before ``add_llms_routes(app)``::

    from lib.constants import BASE_URL
    from lib import network_directory

    app._base_url = BASE_URL
    network_directory.apply(BASE_URL)
"""

from __future__ import annotations

from typing import Any, Dict, List

# Only list hosts that are actually live. A directory entry pointing at a
# subdomain with no site is a dead link an agent will follow once and then
# distrust the rest of the list for. muicharts.2plot.dev and
# flexlayout.2plot.dev have no docs site yet — add them in the same change
# that ships them, not before.
PEERS: List[Dict[str, str]] = [
    {
        "name": "2plot.ai",
        "url": "https://2plot.ai",
        "description": "Network hub and account origin.",
    },
    {
        "name": "2plot.dev",
        "url": "https://2plot.dev",
        "description": "Package index for every open-source component in the network.",
    },
    {
        "name": "Documentation boilerplate",
        "url": "https://boilerplate.2plot.dev",
        "description": "The markdown-driven documentation template every satellite site is built from.",
    },
    {
        "name": "dash-leaflet2",
        "url": "https://leaflet.2plot.dev",
        "description": "Leaflet 2 maps as Dash components.",
    },
    {
        "name": "dash-mui-scheduler",
        "url": "https://muischeduler.2plot.dev",
        "description": "MUI X Scheduler — calendars and event scheduling for Dash.",
    },
    {
        "name": "dash-flows",
        "url": "https://flows.2plot.dev",
        "description": "Node-graph editors built on React Flow.",
    },
    {
        "name": "dash-pannellum",
        "url": "https://pannellum.2plot.dev",
        "description": "360° panorama and virtual-tour viewer.",
    },
    {
        "name": "dash-emoji-mart",
        "url": "https://emojimart.2plot.dev",
        "description": "Emoji picker component.",
    },
    {
        "name": "dash-email",
        "url": "https://email.2plot.dev",
        "description": "Email composition and delivery components.",
    },
]

AFFILIATED: List[Dict[str, str]] = [
    {
        "name": "Pip Install Python",
        "url": "https://pip-install-python.com",
        "description": "The original component documentation site.",
    },
    {
        "name": "Pirate's Bargain",
        "url": "https://piratesbargain.com",
        "description": "Deal aggregator built on the same Dash stack.",
    },
    {
        "name": "ai-agent.buzz",
        "url": "https://ai-agent.buzz",
        "description": "Agent tooling directory.",
    },
]

EXTERNAL: List[Dict[str, Any]] = [
    {
        "name": "Dash Mantine Components",
        "url": "https://www.dash-mantine-components.com",
        "description": "The UI component layer these docs are built with.",
        "llms_txt": "https://www.dash-mantine-components.com/llms.txt",
    },
    {
        "name": "Plotly Dash documentation",
        "url": "https://dash.plotly.com",
        "description": "Upstream framework documentation.",
    },
]

NETWORK_NAME = "The 2plot network"
NETWORK_DESCRIPTION = (
    "Open-source Dash component libraries by Pip Install Python. Each component "
    "has its own documentation site and its own llms.txt; 2plot.dev indexes all "
    "of them, and 2plot.ai is the hub."
)
HUB_URL = "https://2plot.dev"

# The mark drawn in the header of the rendered llms.txt view: "2" + morse
# encoding of "plot" + "ai", as columns of dots and dashes.
#
# No period glyph between the halves — the morse block already separates them,
# and a literal "." next to it reads as punctuation dropped into a graphic.
# The renderer turns a suffix ending in "i" into an upward flourish, so "ai"
# draws as "a" plus that mark; `label` carries the real domain for screen
# readers and the SVG <title>, which is the only place the dot belongs.
#
# Defined here rather than per-app because this module is copied verbatim into
# every satellite — that is what keeps one mark across the network instead of
# twelve slightly different ones.
WORDMARK = {
    "morse": "plot",
    "prefix": "2",
    "suffix": "ai",
    "label": "2plot.ai",
}


def peers_for(app_url: str) -> List[Dict[str, str]]:
    """`PEERS` with this app removed.

    A site listing itself as its own peer reads as generated rather than
    curated, and it wastes a slot in a list an agent may only skim.
    """
    own = app_url.rstrip("/")
    return [p for p in PEERS if p["url"].rstrip("/") != own]


def apply(app_url: str) -> None:
    """Publish the directory for the app served at ``app_url``.

    Degrades rather than fails on older releases of the package. A satellite
    pinned behind this file should still boot: losing the directory, or losing
    the wordmark, is a degradation — refusing to start is not.

    That matters during a staged rollout, when this module reaches satellites
    before the new package does. ``register_network`` arrived in 2.1 and its
    ``wordmark`` argument in 2.2, and Python raises ``TypeError`` on an unknown
    keyword, so the argument is only passed when the installed signature
    actually accepts it.
    """
    try:
        from dash_improve_my_llms import register_network
    except ImportError:  # pragma: no cover - only on <2.1
        import warnings

        warnings.warn(
            "dash-improve-my-llms is older than 2.1, so the cross-host network "
            "directory will not be published. Upgrade to publish it.",
            RuntimeWarning,
            stacklevel=2,
        )
        return

    import inspect

    extra: Dict[str, Any] = {}
    if "wordmark" in inspect.signature(register_network).parameters:
        extra["wordmark"] = WORDMARK

    register_network(
        name=NETWORK_NAME,
        description=NETWORK_DESCRIPTION,
        hub_url=HUB_URL,
        peers=peers_for(app_url),
        affiliated=AFFILIATED,
        external=EXTERNAL,
        **extra,
    )
```


Two details in there that are easy to get wrong:

1. **`peers_for()` filters the app's own URL out.** A site listing itself as
   its own peer reads as generated rather than curated, and burns a slot in a
   list a reader may only skim.
2. **`apply()` degrades instead of failing** when the installed
   `dash-improve-my-llms` predates 2.1. Losing the directory is a degradation;
   refusing to boot over it is not proportionate.

Only list hosts that are **actually live**. An entry pointing at a subdomain
with no site is a dead link an agent follows once, and then discounts the rest
of your list. Add a subdomain in the same change that ships it.

---

### The wordmark

The same module carries the network's mark, drawn in the header of the
rendered `llms.txt` view:

```python
WORDMARK = {
    "morse": "plot",
    "prefix": "2",
    "suffix": "ai",
    "label": "2plot.ai",
}
```

`"2"` + the morse encoding of `plot` drawn as columns of dots and dashes +
`"ai"`. There is deliberately **no period glyph**: the morse block already
separates the halves, and a literal `.` beside it reads as punctuation dropped
into a graphic. The renderer turns a suffix ending in `i` into an upward
flourish, so `"ai"` draws as `a` plus that mark, and `label` carries the real
domain for screen readers and the SVG `<title>` — which is the one place the
dot belongs.

It renders as self-contained inline SVG: no external fonts, no image requests,
no script, because it lands in a documentation page that has to work behind any
CSP. Like the peer list, it lives in this module rather than per-app, which is
what keeps one mark across the network instead of twelve near-identical ones.

### The bulletin

`configure_bulletin(url=...)` points an app at a hub-published JSON document of
tips and announcements, rendered in the same header — so a twenty-site network
says "here is what changed" once instead of in twenty repositories.

This boilerplate ships it **commented out** in `run.py`, and a satellite should
too until the hub actually serves the endpoint. It is opt-in by design: with no
call, the package makes no outbound requests at all. When it is enabled, the
fetch happens on a daemon thread behind a TTL cache, so a request never blocks
on it and a dead endpoint degrades to "no bulletin" rather than a 500.

---

### Wiring it into a satellite

In `run.py`, before `add_llms_routes(app)`:

```python
from lib.constants import BASE_URL, require_owned_base_url
from lib import network_directory

require_owned_base_url()
app._base_url = BASE_URL
network_directory.apply(BASE_URL)

add_llms_routes(app, LLMSConfig(warn_missing_llms_doc=True))
```

Order matters: the package reads the registered directory when it builds
`/llms.txt` and the prerendered HTML, so registration has to happen first.

Leave `warn_missing_llms_doc=True` on. It names every page with no prose, which
is exactly the list of pages that serve a stub body to crawlers. Turning it off
doesn't fix anything — it hides the to-do list. If a page genuinely shouldn't be
indexed, `mark_hidden()` it; hidden pages are excluded from the warning by
design.

---

### The one value you must change

`BASE_URL` drives `<link rel="canonical">` on every page, the absolute URLs in
`sitemap.xml`, and the "this app" entry in `/llms.txt`.

A satellite forked from this template that never changes it emits:

```html
<link rel="canonical" href="https://boilerplate.2plot.dev/some-page">
```

…on every one of its own pages. That tells Google the entire satellite is a
duplicate of the boilerplate and asks for it to be dropped from the index.
Traffic disappears, and **nothing in the app looks broken** — which is why this
is the highest-consequence footgun in the template and why it gets a guard
rather than a comment.

So `lib/constants.py` reads the origin from the environment, and
`require_owned_base_url()` refuses to boot in production if you left it at the
default:

```python
DEFAULT_BASE_URL = "https://boilerplate.2plot.dev"
BASE_URL = os.environ.get("APP_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
```

Set `APP_BASE_URL` in your host's environment (Render → Environment →
`APP_BASE_URL=https://yoursite.2plot.dev`) and change `DEFAULT_BASE_URL` to
your own domain while you're forking.

The guard also rejects platform-generated hostnames — `*.onrender.com`,
`*.herokuapp.com`, `*.fly.dev`. Those keep resolving after you attach a custom
domain, so canonicals pointing at them split link equity across two hosts for
as long as nobody notices. This is not hypothetical: one satellite in this
network shipped with `dash-pannellum.onrender.com` as its canonical host and
spent months telling search engines its `2plot.dev` URLs were the duplicates.

---

### One URL, two audiences

From 2.2.0 on, `/<page>/llms.txt` content-negotiates. Agents, crawlers, curl
and link unfurlers get the Markdown byte for byte; a browser gets it rendered
behind the header described above. `?raw=1` forces Markdown, `?format=html`
forces the view, and both variants send `Vary: Accept` so a CDN cannot hand
cached HTML to the next agent.

The document also opens with a navigation block — the site index, the network
index one level up the hub chain, and the sitemap. Before that, a page's
`llms.txt` was a dead end: it described one page and gave an agent nothing to
follow, which matters because an agent fetches a URL rather than crawling from
one.

Nothing to configure. But two things are worth checking on a satellite, because
both fail silently:

- **The viewer's chrome must never reach a plain fetch.** If it does, every
  agent in the network pays tokens for decoration and it appears in no
  dashboard.
- **Any route you add must not shadow `/<page>/llms.txt`.** Mount custom routes
  *before* `add_llms_routes`, as `lib/asgi_routes.py` does. A custom handler on
  that path swallows both surfaces at once.

### Verifying a deployment

Six checks worth running after every deploy — all of these failures are silent
and expensive:

```bash
APP=https://yoursite.2plot.dev

# 1. Canonical must point at THIS host.
curl -s $APP/ | grep -o 'rel="canonical" href="[^"]*"'

# 2. No page may serve the JavaScript stub.
curl -s -A "Googlebot/2.1" $APP/ | grep -c "requires JavaScript"   # expect 0

# 3. The directory is published.
curl -s $APP/llms.txt | grep -A3 "## Network"

# 4. Every peer llms.txt in the directory resolves.
curl -s $APP/llms.txt | grep -oE 'https://[^ ]+/llms\.txt' | sort -u \
  | while read -r u; do printf "%s %s\n" "$(curl -s -o /dev/null -w '%{http_code}' "$u")" "$u"; done

# 5. Agents get Markdown; the viewer's chrome must not leak into it.
curl -sI $APP/networks/llms.txt | grep -i 'content-type\|vary'   # text/markdown, Vary: Accept
curl -s $APP/networks/llms.txt | grep -c 'dv-banner'             # expect 0

# 6. Browsers get the rendered view.
curl -s -H 'Accept: text/html' $APP/networks/llms.txt | grep -c 'mk-wordmark'   # expect 1
```

Checks 4 and 5 are the ones worth automating: a directory of dead links and
chrome leaking to agents both degrade quietly, and nothing else will tell you.
This repo runs all six against the live deployment via
[`scripts/smoke_live.py`](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/blob/main/scripts/smoke_live.py)
in `.github/workflows/cd.yml` after every release.


---

*Source: /networks*
