Architecture
How Rabbithole dynamically generates and serves entire websites on demand.
Overview
Rabbithole is a Rust web server that sits between a browser and an LLM. When a visitor requests any URL, Rabbithole checks a local SQLite database for a cached page. If none exists, it calls the LLM with a prepared prompt, receives complete HTML in response, stores it in the cache, and serves it to the visitor — all in a single request cycle. The LLM also returns a structured mappings block that tells Rabbithole what prompts to use for future pages linked from the generated HTML.
No templates, no CMS, no static files. The entire site is a lazily-evaluated graph of prompts and cached HTML responses.
System Diagram
Core Components
1. The Seed Prompt
Every Rabbithole site starts with a single seed prompt: a string describing what
the homepage (/) should be. This is the only piece of human-authored content
required to bootstrap an entire multi-page website. The seed prompt is stored in the mappings
table under the key / before the server starts accepting requests.
seed_prompt = "A documentation site for a Rust library called 'widgets'. Use a clean minimal style. The homepage should introduce the library, show a quick install snippet, and link to the API reference, getting started guide, and changelog."
2. The SQLite Cache
Rabbithole maintains two tables in a local SQLite database:
| Table | Key | Value | Purpose |
|---|---|---|---|
pages |
url (TEXT, PRIMARY KEY) |
html (TEXT) |
Stores the fully-generated HTML for each URL. Served directly on cache hit. |
mappings |
url (TEXT, PRIMARY KEY) |
prompt (TEXT) |
Stores the prompt to use when a URL is requested but not yet generated. |
On every incoming request, Rabbithole first queries pages. A cache hit means an
immediate response with no LLM call — sub-millisecond latency after first generation.
A cache miss triggers the generation pipeline.
pages table. See Configuration
for options controlling cache behavior.
3. The Generation Pipeline
When a URL is not cached, Rabbithole runs the following steps:
mappings table for the URL. If no mapping exists,
serve a 404. If a mapping exists, retrieve the prompt string.
---MAPPINGS--- sentinel.
Everything before it is the HTML document; everything after is a JSON array of
{"url": "...", "prompt": "..."} objects.
pages under the current URL. Store each
mapping pair into the mappings table, without overwriting any URL that
already has a cached page or an existing mapping.
Content-Type: text/html.
4. The Mappings Block
The most important design constraint in Rabbithole is that the LLM must emit a
---MAPPINGS--- separator followed by a valid JSON array at the end of every
response. Each entry describes a page that the generated HTML links to:
<!DOCTYPE html>
<html>
... full page HTML ...
</html>
---MAPPINGS---
[
{"url": "/api-reference.html", "prompt": "API reference for the widgets library..."},
{"url": "/changelog.html", "prompt": "Changelog page for the widgets library..."},
{"url": "/examples.html", "prompt": "Examples page showing usage of widgets..."}
]
Rabbithole parses this block with a simple string split on ---MAPPINGS---.
The JSON is then deserialized and each pair upserted into the mappings table.
This means pages are registered lazily: a URL only enters the mappings table when
some other page links to it and declares its prompt.
5. Page Isolation
Each page is generated in complete isolation. The only information available
to the LLM when generating /api-reference.html is the prompt string stored for
that URL. There is no shared session, no memory of previously generated pages, and no access
to the HTML of the homepage or any other page.
For a site to feel visually and tonally consistent, every prompt must be self-contained. It must describe the color scheme, font choices, navigation structure, overall theme, any recurring characters or terminology, and the specific content for that page — as if briefing a designer who has never seen the rest of the site.
Short, vague prompts like "API reference page" will produce pages that look
completely disconnected from each other. Rich, detailed prompts produce cohesive sites.
See Web Tools for a discussion of how the system prompt helps
encode cross-page conventions.
6. Depth Limiting
Because each generated page can register new mappings for further pages, and those pages can in turn register yet more mappings, the site graph could grow without bound. Rabbithole enforces a configurable depth limit to prevent runaway generation chains.
Depth is tracked per URL: the root / is depth 0, pages it links to are depth 1,
their links are depth 2, and so on. Mappings registered at or beyond the depth limit are
stored in the mappings table (so their URLs resolve correctly if visited manually)
but the server will not proactively pre-generate them.
# In rabbithole.toml max_depth = 3 # Links beyond depth 3 are stored but not eagerly generated
Request Lifecycle (Detailed)
The System Prompt
Every LLM call includes a fixed system prompt that instructs the model on output format requirements. This includes:
- Start output with
<!DOCTYPE html>immediately — no preamble. - End the HTML with
</html>. - On the very next line after
</html>, output---MAPPINGS---. - On the line after that, output a raw JSON array of
{"url", "prompt"}pairs. - All CSS must be inline or in
<style>tags — no external stylesheets. - All JS must be in
<script>tags — no external scripts. - All local links must use absolute paths starting with
/. - Generate many local links (5–10+) to make the site richly explorable.
- Each prompt in the mappings must encode full context for the target page.
The system prompt is the backbone of format correctness. If the LLM fails to include the
mappings block, Rabbithole will serve the HTML anyway but no new URL mappings will be registered.
Parsing is lenient: if ---MAPPINGS--- is absent, the entire LLM output is treated
as HTML and an empty mappings list is assumed.
See Configuration for how to customize the system prompt, and Web Tools for how tools like web search integrate into the generation step.
Concurrency Model
Rabbithole is built on async Rust. Multiple requests can be in-flight simultaneously.
The SQLite layer uses a connection pool with write serialization so that concurrent cache
writes do not collide. Two simultaneous requests for the same uncached URL will both trigger
LLM generation independently — the second write to the pages table will simply
overwrite the first with an equivalent result. This is safe but wasteful; a future version
may add per-URL in-flight deduplication.
Web Tool Integration
When web tools are enabled (see Web Tools), the system prompt instructs the LLM that it may call external search and fetch functions before generating the HTML. This allows pages to contain real, up-to-date information rather than relying entirely on the LLM's training data. Tool calls happen during the generation step and add latency but significantly improve content quality for factual pages.
Data Flow Summary
| Event | Read from DB | Write to DB | LLM Called? |
|---|---|---|---|
| Cache hit | pages.html |
— | No |
| Cache miss, mapping exists | mappings.prompt |
pages.html, new mappings rows |
Yes |
| Cache miss, no mapping | — | — | No (404) |
| Server startup | — | mappings row for / (seed prompt) |
No |
Design Tradeoffs
Prompt Context vs. Prompt Brevity
The biggest practical tension in Rabbithole is between context richness and prompt size. For a site to look coherent, each prompt needs to re-describe the full design system, navigation, color scheme, and tone of the site. This means prompts can become very long, consuming tokens on every generation call. The alternative — short prompts that assume the LLM "knows" the site — produces inconsistent, disconnected pages.
A practical strategy is to define a prompt template or
style preamble string once in the configuration and have the LLM prepend it
to every child prompt it emits. See Configuration for
the prompt_preamble option.
Permanent Caching vs. Staleness
By default, pages never expire. This is ideal for static-feeling content and avoids
runaway LLM costs. For sites where content should update (e.g. a "current news" page),
you can delete specific rows from the pages table, or enable the
cache_ttl option to expire pages after a configurable duration.
Laziness vs. Eagerness
Pages are only generated on first request. A freshly seeded site with no visitors is
an empty SQLite database — just one row in the mappings table. Pages come
into existence the moment someone clicks a link to them. This makes cold starts fast
but means the first visitor to a new page experiences LLM latency. The
Deployment guide covers strategies for pre-warming the cache.
Source Code
The full source is available at github.com/ajbt200128/rabbithole. Key source files:
| File | Responsibility |
|---|---|
src/main.rs |
Server startup, config loading, route registration |
src/handler.rs |
HTTP request handler; cache lookup and generation dispatch |
src/db.rs |
SQLite abstraction: pages and mappings CRUD |
src/llm.rs |
LLM client; prompt construction; response parsing and mappings extraction |
src/tools.rs |
Web search and fetch tool implementations |
rabbithole.toml |
Configuration file (seed prompt, LLM settings, depth limit, etc.) |
See Also
- Getting Started — install and run your first site
- Configuration Reference — all
rabbithole.tomloptions - Web Tools — how search and fetch integrate at generation time
- Deployment — production setup, cache pre-warming, reverse proxies
- Examples — sample seed prompts and the sites they produce
- Prompt Design Guide — strategies for writing effective, self-contained prompts
- LLM Providers — configuring Claude, OpenAI, Ollama, and others
- SQLite Schema Reference — full schema and indexing details
- FAQ — common questions about generation failures, cost, and consistency