Cruma gives anything running on your machine a public HTTPS URL — from a quick CLI tunnel to a full desktop app with request inspection. Free for personal dev, with paid tiers for custom domains, end-to-end encryption, and built-in authentication. It also functions as a capable web and proxy-server with many built-in features for all your developer needs, entirely free.
Free tunnels for quick testing, paid tunnels for custom domains, stronger privacy, and consistent performance. Free is for individual, non-commercial development use — not company-wide rollout.
Anonymous$0no signup
Disposable quick tunnels
Best for trying Cruma fast or sharing a localhost app for a short test.
Cruma is a focused tunnel for developers, small teams, and homelabs. We optimize for fast setup, local-first tooling, and privacy options rather than enterprise policy suites. High-level comparison with Cloudflare Tunnel and ngrok based on publicly documented features.
Legend✓ Available~ Limited / extra config$ Paid plans× Not available
Feature
Cruma
Cloudflare Tunnels
ngrok
Account-free quick tunnels
✓ Yes — anonymous mode for quick tunnels without an account. Aggressively rate-limited for fair usage.
✓ Yes — supported via "Quick Tunnels" at trycloudflare.com. Aggressively rate-limited; relaxed with a free account.
× No — requires an account and auth token.
Custom domains with E2E encryption
$ Yes (subscribed) — create a CNAME to your tunnel's domain; the agent terminates TLS locally and forwards to your app. Requires a paid subscription.
× No (for E2E) — custom domains supported, but HTTP(S) terminates at Cloudflare's edge, so plaintext is visible at that point.
$ Paid — custom domains on paid plans.
Multi-endpoint
✓ Yes — serve any number of endpoints using different hostnames.
✓ Yes — serve any number of endpoints using different hostnames.
$ Paid — multiple endpoints on paid plans only.
Protocol forwarding
~ TCP — TCP forwarding today; UDP is on the roadmap.
~ TCP — ngrok's public service currently supports TCP forwarding.
Agent uplink protocols
✓ QUIC + HTTP/2 — agents keep TLS-encrypted QUIC and HTTP/2 sessions for lower latency and resilience.
✓ QUIC + HTTP/2 — cloudflared can use QUIC uplinks and HTTP/2 over TLS in supported configs.
~ HTTP/2 — speaks HTTP/2 to its control plane; no public QUIC uplink.
Local UX & tooling
✓ TUI + desktop app — desktop app for config and request inspection, plus a terminal TUI. Headless mode available.
~ CLI + hosted dashboard — configured via CLI and Zero Trust dashboards; no full local web UI.
~ CLI + remote inspector — request inspector and many options live in the cloud dashboard.
HTTP request inspection
✓ Local UI — inspect in the TUI and desktop app; request logs never leave your machine.
~ Zero Trust dashboards — routes through Cloudflare's edge, so logs live in their SaaS; no local inspector.
× Remote-only — primary inspector UI runs in their cloud dashboard.
Serve local directories
✓ Built-in directory server — serve a local directory straight from the agent, no nginx needed.
× No built-in static server — run your own web server or use Cloudflare Pages.
× No built-in static server — run a local server yourself and point ngrok at it.
Built-in access controls
✓ IP allow-lists, forms & API keys — allow-list source IPs per tunnel, or put form auth / header API-key enforcement in front of any HTTP backend.
~ Via Zero Trust policies — HTTP access control via Cloudflare Access, managed in their SaaS control plane.
$ Paid — access control and traffic-shaping on paid plans.
End-to-end encryption
✓ Supported (subscribed) — TLS terminates on the agent as soon as a cert is generated, so Cruma only sees encrypted payloads. TLS passthrough also supported.
× Not provider-blind — for standard HTTP(S) tunnels, TLS terminates at Cloudflare's edge, so plaintext is visible there.
Cruma Sites turns a Git repository of Markdown into a fast, self-contained
website that Cruma builds and hosts for you. You write content; Cruma bakes it
into a single, themed, searchable page and serves it at a Cruma subdomain (or
your own custom domain).
It also happens to be how this website (cruma.io) is created and hosted!
Point Cruma at a Git repo. From the dashboard, connect a public repo — or
a private one via the GitHub App. The repo holds your content/ and a
site.toml; nothing else is required.
Cruma builds it. On every push, Cruma clones the repo, runs the generator,
and publishes the result. There's no build step for you to run and no
framework to configure.
It's served live. Your site appears at <your-site>.pages.cruma.io, and
you can attach a verified custom domain.
What a site looks like
A Cruma site is just a folder:
my-site/
site.toml # site-level settings (title, theme, …)
content/
home.md # a page
not-found.md # the 404 page
images/ # images, served from /images/<name>
_components/ # reusable HTML components (optional)
_themes/ # custom themes (optional)
book/ # documentation books (optional)
blog/ # dated posts (optional)
The only truly required pieces are site.toml and a content/ directory with
at least a home.md. Everything else is opt-in.
The rest of this book covers each piece in turn:
Content & pages — pages, front matter, and
how to link between them.
Everything under content/ becomes part of your site. There are a few special
kinds of content, distinguished by where the file lives.
Pages
A content/*.md file is a page. Pages are hash-routed (#home, #pricing)
and share the site's chrome. Optional front matter at the top of the file controls
how the page appears:
---
slug: pricing # the #hash / tab id (default: file name)
title: Pricing # nav + tab label (omit to hide from the nav)
order: 2 # nav order (default: 1000)
layout: splash # page shape (default: content)
---
# Pricing
Your Markdown here…
The Markdown is CommonMark plus tables and strikethrough, and raw HTML passes
through — so you can drop in a <div> or a component
wherever you need one.
Front matter reference
Every field is optional. The common ones:
Key
What it does
slug
The page's #hash id. Defaults to the file name without .md.
title
Nav/tab label. Omit it to keep the page off the nav (still reachable by its slug).
order
Position in the nav; lower comes first (default 1000).
layout
content (a carded article, the default) or splash (a full-bleed landing page with no card).
eyebrow
A small kicker line shown above the first heading.
hidden: true
Drop the page from the nav and search (used by not-found.md).
chrome: off
Hide the top bar while this page is active — for pages that bring their own nav/footer.
on renders every top-level list on the page as feature cards (default off).
Pages also accept the per-page banner keys (image, header-mode, header-fade, …)
covered in Banners & headers, and blog metadata (date,
authors, description) covered in Books & blog.
Linking between pages
Links are plain Markdown. What you put in the target decides where it goes:
[Pricing](#pricing) <!-- another page, by its slug -->
[Introduction](#cruma-sites/introduction) <!-- a book chapter: #<book>/<chapter> -->
[Latest release](#blog/v2-launch) <!-- a blog post: #blog/<slug> -->
[All posts](#blog) <!-- the blog index -->
[Cruma](https://cruma.io) <!-- an external link, opened as-is -->
To a page — use its slug: [text](#slug).
To a book chapter — use #<book-folder>/<chapter-slug> (the folder name,
then the chapter's slug). Ordering prefixes like 01- are dropped from slugs,
so content/book/guide/02-setup.md is #guide/setup.
To a blog post — #blog/<post-slug>; the index itself is #blog.
To a heading on the current page — headings get an automatic id from their
text, so [Front matter](#front-matter-reference) scrolls to that section.
External — any http(s)://, root-absolute /…, or mailto: link is left
untouched and opens normally.
Inside a book, you can also link to a sibling chapter with a relative
Markdown path and Cruma rewrites it to the right hash for you:
See [Themes](07-themes.md) for the full list. <!-- → #cruma-sites/themes -->
Only relative *.md links are rewritten; everything else passes through as
written.
Lists as cards
Some themes (like the Cruma and Tako themes) can render a bulleted or numbered
list as a grid of feature cards. This is opt-in — by default every list
renders as a normal list. To turn a single list into cards, put a <!-- cards -->
comment on the line right before it:
<!-- cards -->
- **Fast** — builds on every push
- **Secure** — HTTPS out of the box
- **Yours** — bring your own domain
To make every list on a page render as cards, set list-cards: on in the
page's front matter; a <!-- plain --> comment before a list opts that one back
out. Themes that don't style cards just show a normal list.
The 404 page
content/not-found.md is served for unknown paths. If you don't ship one, Cruma
provides a sensible default.
Images
Put images in content/images/ and reference them as images/<name>:

Cruma serves them from your site's asset store, so they stay fast and cached.
Keep the folder flat — one level, no subdirectories.
Search
Every site ships a search palette that indexes all pages, chapters and posts.
Readers open it from the … button in the top bar or with a keyboard
shortcut (Ctrl/⌘+K by default — change it with
search-key).
Books and blog
Two folders get special treatment — content/book/ for documentation books and
content/blog/ for dated posts. Both have their own chapter:
Books & blog.
Navigation & the top bar
The bar across the top of every page is built for you — from your pages, plus a
few site.toml keys when you want more control.
How the nav is built
Each top-level page with a title becomes a nav tab, ordered by its order
front matter (lowest first). To keep a page out of the nav, omit its title
(it stays reachable by its slug) or set hidden: true.
Books and the blog add their own entries — see
Books & blog. When there are more tabs than fit,
the rest collapse into the searchable … menu.
Books in the nav
By default each book gets its own tab. If you have several books, fold them into
a single Books menu:
books-nav = "folded" # one "Books" tab; default is "tabs" (a tab per book)
Books that share a _book.tomlcategory are grouped under one nav entry per
category (this documentation sits under Docs). See
Books & blog.
Brand
The top-left brand is the site title by default. Override it in site.toml:
title = "Acme" # browser tab + default brand text
brand = "Acme Docs" # brand text (overrides the title here)
brand-link = "#home" # where the brand links (default: #home)
logo = "images/logo.svg" # a logo image instead of text
Use a #hash for brand-link to point at one of your own pages; an external
URL opens in a new tab.
External links & a call-to-action
Add links to other sites (docs, a status page, social) and one prominent
call-to-action button (Sign in, Get started, Book a demo…) to the top bar:
[[nav.links]]
label = "Docs"
url = "https://docs.example.com"
[[nav.links]]
label = "Changelog"
url = "https://example.com/changelog"
new_tab = false # optional; external links open in a new tab by default
[nav.cta]
label = "Sign in"
url = "https://app.example.com/login"
style = "solid" # solid | outline | plain
[[nav.links]] — repeat the block for each link. label and url are
required; new_tab defaults to true for external URLs.
[nav.cta] — a single button. style picks how much it stands out:
solid (filled with the theme's accent), outline, or plain (a plain link).
The colours come from the active theme, so the button
always looks native.
On a phone the call-to-action stays visible while the external links fold into
the … menu, so the bar never overflows.
Banners & headers
A banner is the wide image strip at the top of a page. You can set one for the
whole site, override it per page, or let a page take the header over entirely.
A site-wide banner
Set a default image (and optional tuning) in site.toml. It appears as a band at
the top of every page unless a page opts out:
banner = "images/hero.jpg" # the default header image
header-height = "320px" # band height (a CSS length)
header-position = "center" # focal point — any CSS background-position
header-blur = "0" # blur radius (px); 0 = off
header-fade = "0" # bottom fade height (px); 0 = off
header-position keeps the important part of a photo in frame on every screen
size — e.g. top, or 50% 20% to favour faces near the top.
To hide the banner on a single page, add banner: off to that page's front
matter.
A per-page banner
Give a page its own image with the image key, and tune it with the same
header-* controls (without the header- prefix as banner-* also works):
aligned — the image sits within the content measure, lined up with the
rest of the page.
full — the image runs edge-to-edge and the page's title sits over it.
A per-page image overrides the site-wide banner for that page; the two can also
coexist (a site band plus a page cover) depending on the theme.
Splash pages
For a landing page with no card and the header taking the whole hero, use the
splash layout together with a full header:
---
title: Home
slug: home
layout: splash
image: images/landing.jpg
header-mode: full
---
# Build docs your way
Spacing controls
Two more site.toml keys fine-tune the gap between the chrome and your content —
handy if a fixed top bar or a tall banner crowds the first line:
header-pad = "110px" # top padding for content that sits under a banner
nav-clearance = "72px" # gap below the floating nav on pages with no banner
Per-page front matter of the same names overrides these for a single page.
Books & blog
Two folders get special layouts: content/book/ for multi-chapter documentation
and content/blog/ for dated posts. This book you're reading is, itself, a book.
Books
A folder under content/book/ is a book. Each .md inside is a chapter,
rendered with a table-of-contents sidebar, a sub-section list, and prev/next
links.
content/book/guide/
_book.toml # book settings (optional but recommended)
01-introduction.md
02-setup.md
03-configuration.md
Chapter front matter
A chapter is just a page in a book, so it takes the same front matter as any
page — title, order, image (a per-chapter
cover), eyebrow, banner: off, the header-* overrides, and so on.
Chapter order follows each chapter's order; without one, the 01-,
02- filename prefixes decide. Those prefixes are dropped from the slug, so
02-setup.md is reached at #guide/setup.
Titles come from title (or the file name).
_book.toml
The optional _book.toml names the book, places it in the nav, and sets
defaults for its chapters:
title = "The Guide" # the book's nav label + TOC heading
order = 10 # position among top-level nav entries
category = "Docs" # group several books under one nav entry
description = "Set up and go." # blurb for the library card
image = "images/guide.jpg" # default banner for chapters that set none
banner = "off" # opt the book's chapters out of the site banner
# header-mode / header-fade / header-height / header-blur / header-position —
# book-level defaults, each overridable per chapter (see Banners & headers).
Books that share a category are collected under a single nav entry (labelled
with the category) with a small library index; a category with one book links
straight to it. Uncategorised books each get their own tab. image and the
header-* keys are inherited by every chapter that doesn't set its own — see
Banners & headers.
The table of contents
Every book has a TOC sidebar. Move or hide it site-wide in site.toml:
book-toc = "left" # left (default) | right | off
Blog
content/blog/ is an optional blog: one post per file, with an
auto-generated index sorted newest-first. Each post routes at #blog/<slug>.
---
title: v2 is here
date: 2026-07-11 # ISO YYYY-MM-DD; sorts newest-first
authors: [Ada Lovelace]
description: What changed in the 2.0 release.
image: images/v2-cover.jpg # optional post cover
---
# v2 is here
Post body…
date is shown on the post and drives the index order.
description feeds the index card and link previews.
The generated index lives at #blog. To give it a title, position, or intro
text, add content/blog/_index.md:
---
title: Blog
order: 20
---
News and release notes from the team.
Custom components
Components are reusable HTML snippets you can drop into any page or chapter with a
{{name}} tag. They're how the Cruma marketing site builds its hero, pricing
cards, and comparison table without repeating markup.
Defining a component
Create content/_components/<name>.html. Inside it, use placeholders:
{{ attr }} — a named attribute, HTML-escaped.
{{ &attr }} — a named attribute inserted raw (already-safe HTML).
{{ body }} — the content between the opening and closing tags.
Invoke it from any Markdown file. Attributes are key="value" pairs; the content
goes between the tags:
{{callout tone="warn" title="Heads up"}}
Custom domains need a verified DNS record first.
{{/callout}}
When a component takes no body, self-close it:
{{callout tone="info" title="Note" /}}
Styling and behaviour
A component can bring its own CSS and JS: put them beside the template as
<name>.css and <name>.js in content/_components/. Cruma bundles them into
the page automatically, only when the component is actually used.
That's the whole system — plain HTML in, {{placeholders}} filled, scoped CSS/JS
attached. The cm-* components that build this very site are just a larger set of
the same thing.
Themes
A theme controls the whole look of a site — colours, typography, the top bar, and
the footer. Cruma ships several built-in themes, and you can add your own.
Choosing themes
List the themes you want in site.toml, active one first:
themes = ["cruma", "default", "kanagawa"]
The first theme is the default the site loads with.
Listing more than one adds a theme picker; listing a single theme drops the
picker and strips the others' CSS from the output.
This site uses a single cruma theme for a consistent, branded look.
Custom themes
Add a stylesheet at content/_themes/<name>.css and it becomes selectable like a
built-in one. A theme scopes its rules under html[data-theme="<name>"], so it
only applies while active:
content/_themes/<name>.footer.html — a site footer.
The cruma theme uses exactly this to render the CRUMA.IO brand bar and the
Umbra·Yurei footer.
Site branding
A couple of site.toml keys feed the chrome regardless of theme:
title = "Cruma" # browser tab + default brand
logo = "images/logo.jpg" # navbar logo image (else the title as text)
site.toml reference
site.toml holds your site-level settings. Every key is optional — a bare
title and a content/home.md are enough to publish. Keys are grouped below;
several have a whole chapter of their own, linked inline.
Identity & SEO
title = "Acme" # browser tab + default brand
brand = "Acme Docs" # brand text (overrides title in the bar)
brand-link = "#home" # where the brand links (default: #home)
logo = "images/logo.svg" # a logo image instead of brand text
favicon = "images/favicon.png" # tab icon
description = "Docs for Acme." # <meta description> for SEO / link previews
This guide should be simple enough to get your locally hosted web-site or service accessible on the public Internet within 1-3 minutes.
What is Cruma?
Cruma is a local-first tunneling agent that gives you a public URL for local or private services with a single command. It comes with a TUI (terminal UI) and a desktop app for inspection and control. It supports HTTP, HTTPS, TCP, raw TCP, serving local directories, and hosting processes.
If you bring your own domain via CNAME, TLS is terminated on your agent using a certificate automatically obtained from Let's Encrypt (ACME TLS-ALPN-01). This provides end-to-end encryption between clients and your agent — Cruma infrastructure only forwards the encrypted stream and sees control-plane metadata.
Key concepts
tunnel_id defines the public FQDN and routing pool; agents sharing a tunnel ID are load‑balanced together.
profile (config-file setting) scopes the cached identity (useful for multiple anonymous identities).
temp (config-file setting) creates a one‑off identity for a fresh FQDN each run.
Accessing the agent
Cruma is now in public beta. The service is free during this period while features, limits, and pricing evolve based on real‑world use.
Download the tunnel agent at https://cruma.io and pick your platform. On first run you'll see a TUI-based setup wizard; you can also run cruma --help to see all options.
Install & run (fast path)
Download the agent from https://cruma.io and install for your OS.
Start your local service (for example on port 3000).
Run:
cruma proxy http 3000
That's it — you'll get a public URL printed in the terminal.
Anonymous quick test
You can run an anonymous tunnel without a tunnel ID or secret. A unique FQDN is assigned and persisted in the agent cache directory (see cruma show-cache):
cruma proxy http 3000
This is great for quick local tests; anonymous tunnels have tighter limits (see Rate Limits & Fair Use).
Anonymous mode is zero‑setup. When you're ready for custom domains or higher limits, add --tunnel-id and --secret-key from your account.
For a fresh anonymous FQDN each run, set temp: true in your config file. For multiple stable anonymous identities, set profile: <id> in your config file.
Basic usage with credentials
This assumes your service is running locally on port 8080. Replace TUNNEL_ID and SECRET_KEY with the values from your account.
For multi-target setups, use a config file (YAML or JSON). The config uses backends (where traffic goes) and frontends (hostname routes that point to backends):
tunnel_id: "demo-tunnel"
tunnel_secret: "beta-secret-123"
backends:
- id: web
kind: http
destination: "127.0.0.1:3000"
frontends:
- hostname: "app"
backend_id: web
Run it with:
cruma start ./cruma.yaml
You can also manage configs from the CLI without editing YAML by hand:
cruma config add http 127.0.0.1:3000 --hostname app
cruma config show
See the configuration guide for the full reference including processes, listeners, middlewares, and local-only mode.
Forwarding TCP services
Use proxy tcp to forward plain TCP to your backend after TLS termination:
For assigned FQDNs (*.tun.cruma.io), TLS is terminated at the Cruma ingress. For CNAME'd custom domains, TLS is terminated on your agent. Either way, your backend receives a plain TCP stream.
Use proxy raw for TLS pass-through to your backend (the backend handles TLS itself):
cruma proxy raw 127.0.0.1:4943 --tunnel-id TUNNEL_ID --secret-key SECRET_KEY
Note: raw only provides true TLS pass-through when using a CNAME'd custom domain. With assigned FQDNs, TLS is always terminated at the Cruma ingress, so raw behaves the same as tcp.
The platform will provide the reachable endpoint once the tunnel is up.
Upstream protocol
When proxying HTTP/HTTPS traffic, you can select the protocol used to talk to your backend with --upstream-protocol:
h1 — HTTP/1.1 (default, most compatible)
h2 — HTTP/2 (ALPN-negotiated for HTTPS backends, h2c upgrade for HTTP)
h2pk — HTTP/2 Prior Knowledge (speak HTTP/2 directly without negotiation)
The Quick Start gets you online from the terminal with a single command. But Cruma is also a full desktop application — a visual control panel for the exact same agent. Everything the CLI does, you can do here by clicking, and you get live dashboards, request inspection, and a built-in assistant on top.
This chapter is a guided tour of that app. It assumes no prior experience with proxies or web servers — if a term is new, we explain it as we go.
💡 Three ways to run the same agent
Cruma is one program with three faces: the desktop app (a graphical window), the TUI (a text interface that runs inside your terminal, great over SSH), and headless (no interface at all, for servers and CI). They all drive the same runtime and the same config file — pick whichever fits where you are. On macOS, Windows, and Linux desktops the graphical window opens by default; add --tui for the terminal UI or --headless for none.
New to proxies? The 30-second mental model
Cruma sits in front of your own programs and decides where each incoming web request should go. Picture a receptionist at a front desk:
Someone walks in the door → that's a request arriving.
The receptionist reads who they're here to see (the website address, or hostname) → that's routing.
They send the visitor to the right office → that's your service (a website, an API, a folder of files).
Along the way they can check ID, turn away banned visitors, or stamp a form → those are middlewares (auth, rate limits, header tweaks).
Cruma is that receptionist. Three nouns describe the whole job, and the app has one page for each:
Noun
Plain meaning
Page
Listener
The door traffic comes in through — a local port, or the Cruma tunnel from the internet.
Listeners
Frontend
A rule: "requests for this address go to that service," plus any checks to run first.
Frontends
Backend / Process
The destination — a service you point at (localhost:3000), a folder of files, or a program Cruma runs for you.
Backends / Processes
Once those three click, the rest of the app is just windows onto the running system.
First launch: the setup wizard
The first time you open the app, a short wizard walks you through connecting the tunnel and exposing your first service — no config file to write by hand. It can detect programs already listening on your machine and offer to route to them in one click. You can re-open it any time from the Dashboard.
The Dashboard — your home base
The Dashboard is where you land. At a glance it shows:
Tunnel status — whether you're connected to Cruma's cloud ingress, your assigned public domain (e.g. abc123.cruma.io), and your plan.
Your routes — every frontend you've configured and where it points.
Live activity — recent connections, errors, and events as they happen.
It has two looks, switchable in Settings:
Modern — a card per site, each with a QR code, copy-link, and quick-open buttons. Great for grabbing a URL onto your phone.
Classic — a denser split view: route list on one side, a live event stream on the other.
📝 The domain follows your agent, not your config
You don't pick your *.cruma.io hostname — it's tied to your agent's identity on disk. Reinstall with the same identity and you get the same domain back. See Custom Domains to put your own domain in front of it.
The three pages you'll use most
These map directly onto the mental model above.
Listeners — where traffic enters
A listener is a door. Cruma has three kinds:
http / https — bind a local port (like 80 or 8443) so browsers on your machine or network can reach you. https also handles the encryption (TLS) for you.
cruma — the tunnel door. It dials out to Cruma's cloud and receives traffic from your public domain. This is what makes a local service reachable from the internet without opening any ports on your router.
The page shows each listener with its port and whether it's currently bound (a listener can be configured but fail to bind if another program already holds that port — the page tells you when that happens). HTTPS listeners also serve HTTP/3 (QUIC) by default for faster connections.
Frontends — the routing rules
A frontend answers "when a request comes in for this address, what do I do with it?" Each one has:
A match — a hostname and (optionally) a path, e.g. app.example.com or abc123.cruma.io/api.
A target — the backend, process, folder, or Kubernetes service to send it to.
An optional list of middlewares — checks and transforms applied before the target sees the request: login walls, IP allow/deny, rate limits, CORS, header rewrites, and more.
The editor lets you build all of this visually. One hostname can even fan out to different targets by path (an /api route to one service, everything else to another).
Backends & Processes — the destinations
A Backend is where a frontend sends traffic:
a host:port address (localhost:3000, or a remote server) — with optional load balancing across several addresses, health checks, and a maintenance-mode switch;
a dir_server — Cruma's own built-in static file server for a folder on disk (directory listings, Markdown-to-HTML, single-page-app fallback, image thumbnails).
A Process is a program Cruma runs and supervises for you — it starts, stops, restarts, and can relaunch on crash or when the binary changes. Set its command, working directory, and environment; if it opens a web port, Cruma wires a route to it automatically. Advanced options include pinning it to specific CPU cores.
Watching what's happening
The app isn't just for setup — it's a live lens on your traffic:
Requests — turn on recording and every request through Cruma is captured with its headers, timing, status, and body (up to a size limit). Click one to inspect it, copy it as a curl command, or hand it to the assistant with Ask AI.
Statistics — traffic volume, error rates, and a per-backend breakdown.
Observations — a combined stream of agent events and the logs of the processes Cruma is hosting.
Service Map — a live diagram of what has actually talked to what, drawn from real traffic rather than your config.
TLS & certificates, handled for you
The Certificates page shows the encryption certificate for each of your HTTPS names and its status. Cruma can obtain and renew free certificates automatically (Let's Encrypt via ACME), use a self-signed one for local testing, or use PEM files you provide — no manual certificate wrangling required. See Security & TLS for the full picture.
Access control & integrations
Custom Auth and Local OAuth2 — set up who can reach a site: HTTP Basic, a hosted login form, or sign-in with GitHub/Google (OAuth2). Cruma can even act as its own OAuth2 provider.
MCP Server — expose the running agent to AI coding tools (Claude Code, Cursor, Copilot) as a controllable API, with its own auth. Handy if you want an assistant to inspect or reconfigure Cruma for you.
Kubernetes — point frontends at services in a Kubernetes cluster.
Settings & Profiles
Settings — the config file in use, dashboard look, ingress region, launch-at-login, and MCP options.
Profiles — keep several independent config files (say, one per project) and switch between them. Useful when you want separate public domains or isolated setups.
The built-in assistant
Desktop builds include an AI assistant page: a chat that understands Cruma and can see your live setup (when you allow it). Ask it things like "why isn't my site loading?" or "add a login wall to my API route" in plain English. It can be backed by Anthropic Claude, GitHub Copilot, an OpenAI-compatible endpoint, or the Claude Code CLI, depending on what you connect. The Ask AI button on a captured request drops that request straight into the chat for analysis.
Your first site, in the GUI
Putting it together — the visual version of the Quick Start:
Open the app and confirm the tunnel shows connected (green) on the Dashboard.
Go to Backends → Add, choose host:port, and enter your local server (e.g. localhost:3000). Save.
Go to Frontends → Add, set the match to your assigned domain, and pick the backend you just made. Save.
Open https://<your-domain> — you're live. No DNS setup, no certificate step.
Flip on recording in Requests and reload the page to watch the request flow through.
That's the whole loop: a door (listener), a rule (frontend), a destination (backend). Everything else in the app builds on those three ideas.
💡 Prefer the terminal?
None of this is GUI-only. The same actions exist as cruma config add … commands and as pages in the --tui interface, and they all read and write the same config file. Mix and match freely — see Configuration for the file format behind the buttons.
Common Scenarios
Quick recipes for typical setups. Replace SECRET_KEY/TUNNEL_ID with your account credentials, or omit --tunnel-id and --secret-key entirely for anonymous mode.
cruma config add process node --arg server.js --start-on-request --hostname app
When a request arrives for a frontend backed by this process and the process is not running, the agent will start it and wait for it to become ready before proxying the request. With idle_timeout_seconds, the process is automatically stopped after being idle for the specified duration.
Local-only reverse proxy (no cloud tunnel)
The agent only connects to Cruma cloud when a cruma-kind listener is present. Simply don't define one to run the agent as a purely local reverse proxy — no traffic goes through Cruma cloud:
Now your service is available both at app.<tunnel-id>.tun.cruma.io and http://localhost:8080.
High-assurance (pinning/mTLS)
Use a custom hostname (CNAME to <tunnel-id>.tun.cruma.io). The agent automatically obtains a Let's Encrypt certificate via ACME TLS-ALPN-01 and terminates TLS locally — payloads are end-to-end encrypted between clients and your agent by default.
On paid plans with an active subscription, the agent can also terminate TLS for its assigned *.tun.cruma.io hostname after obtaining a certificate via ACME DNS-01. If that process is not ready or fails, Cruma falls back to ingress termination automatically.
For additional hardening, pin your agent's certificate in clients (or use mTLS) so only your cert is accepted.
Set CAA records on your domain to restrict certificate issuance to your chosen CA (and optionally to your specific ACME account).
CORS headers for an API
Use the allow_cors middleware on a frontend:
backends:
- id: api
kind: http
destination: "127.0.0.1:8080"
frontends:
- hostname: "api"
backend_id: api
middlewares:
- type: allow_cors
origins: { mode: any }
allow_credentials: false
handle_preflight: true
allow_private_network: false
Path-based routing with rewrites
Strip a path prefix before forwarding to the backend:
Available values: H1 (default), H2, H2PK (HTTP/2 Prior Knowledge).
⚠️ Casing differs between the CLI and the config file
The CLI flag takes lowercase (--upstream-protocol h2). The config-file value is uppercase — H1 / H2 / H2PK. A lowercase upstream_protocol: h2 in YAML/JSON/TOML will fail to parse ("unknown variant h2").
Choose Your Path
Pick the path that matches your experience. Each path gives you a minimal set of steps to get your site reachable.
Use separate tunnels for isolation between teams/apps.
Run local-only by omitting a cruma-kind listener for a purely local reverse proxy — no cloud tunnel (see Configuration).
Add middlewares (CORS, header manipulation, path rewrites, redirects) to frontend routes (see Configuration).
Enforce issuance and trust: set CAA on your domain, pin your agent's cert (or use mTLS) for custom hostnames (see Security & TLS).
Multiple Targets and Tunnels
One agent, many targets
You can expose multiple services from a single agent and tunnel. Use the config file to declare multiple backends and frontend routes; you usually do not need separate tunnels for each service on the same machine.
Shared tunnel IDs (important)
A tunnel ID defines the public FQDN and the routing pool at cruma.io. If you run multiple agents with the same tunnel_id, they all share that same public address and traffic is load‑balanced between them.
This is great for multi‑region or multi‑host deployments as long as the configs are the same across those agents. If configs differ (different hostnames, targets, or auth), routing becomes unpredictable because requests may land on a different agent than you expect.
Rule of thumb: if you need different hostnames/routes/configs, use different tunnel IDs. If you want the same public address served from multiple places, keep the same tunnel ID and keep configs identical.
Profiles are separate from tunnel IDs: profiles only scope the cached identity (useful for anonymous tunnels), while the tunnel ID controls the public FQDN and routing pool.
Example:
tunnel_id: "demo-tunnel"
tunnel_secret: "beta-secret-123"
backends:
- id: api
kind: http
destination: "127.0.0.1:8080"
- id: web
kind: http
destination: "127.0.0.1:3000"
frontends:
- hostname: "api"
backend_id: api
- hostname: "api.dev.yourdomain.com"
backend_id: api
- hostname: "react-dev"
backend_id: web
- hostname: "react.dev.yourdomain.com"
backend_id: web
Run with:
cruma start ./cruma.yaml
Multiple frontends can reference the same backend — for example, a shortname (api) and a custom domain (api.dev.yourdomain.com) both routing to the same service.
When one tunnel is enough
A few services owned by the same team/environment.
Shared credentials are acceptable (same tunnel secret).
Simple DNS: one tunnel ID with shortnames or custom hostnames.
When to use separate tunnels
You need different hostnames/routes/configs that should not be load‑balanced together.
Isolation per app/team/environment (different credentials and blast radius).
Different domains/hostnames that you want to keep apart.
Different usage profiles or rate-limit buckets.
Multiple agents for the same tunnel
You can run multiple agents with the same tunnel ID for load spreading and regional placement. Each agent connects to its nearest Cruma datacenter; callers are routed to the nearest datacenter where that tunnel is connected. Common patterns:
Regional presence: one tunnel, agents in EU and US to serve users closest to each region.
Simple load sharing: multiple agents behind the same tunnel ID on different servers.
Coordinate credentials and targets carefully when sharing a tunnel across agents. Use distinct tunnels if you need stricter isolation or different routing behavior.
Configuration via file
You can run cruma from a config file instead of passing flags. The CLI accepts YAML or JSON and will validate the structure before starting the tunnel. The agent watches the file and applies changes to credentials and targets automatically.
Quick reference
Command
Description
cruma config locate
Print the default config file path
cruma config init [PATH]
Create a new config with ANON credentials and no targets
cruma config reset
Delete and recreate the default config
cruma config profiles
List known profile IDs
cruma config show
Display the current configuration
cruma config set-credentials <TID> <SK>
Set tunnel credentials
cruma config clear
Remove all frontends, backends, and processes
cruma config add <type> ...
Add a backend + frontend route
cruma config remove <INDEX>
Remove a frontend/backend by index
cruma config remove-process <INDEX>
Remove a hosted process by index
cruma config update <INDEX> ...
Update a target by index
cruma config add-listener <PORT>
Add a local listener
cruma config remove-listener <INDEX>
Remove a local listener by index
cruma config update-listener <INDEX> ...
Update a local listener by index
cruma schema
Print the full JSON schema for the config file
All config subcommands accept -c <PATH> / --config <PATH> to target a specific file instead of the default.
Config file structure
The config file has two required top-level arrays — backends and frontends — plus optional sections for credentials, processes, listeners, and agent behavior.
⚠️ Warning
Older configs that used a flat targets array are no longer supported. The agent will reject them with an error and suggest running cruma config reset --config <path> to migrate.
When true, short-circuit the backend with a maintenance response instead of proxying
backend_timeout_seconds
no
Upstream response timeout in seconds (default: 10)
upstream_protocol
no
Protocol to the upstream: H1 (default), H2, H2PK. Uppercase in the config file.
middlewares
no
Backend-level middleware list (applied in addition to the frontend's)
Form-based and API key auth are configured as middlewares on a frontend, not as backend fields — see Access controls below.
Load balancing and health checks
Give a backend several upstreams with destinations and Cruma spreads traffic across them. Add a health_check and it will probe each one and route around the ones that fail.
backends:
- id: web
kind: http
destinations:
- "127.0.0.1:3000"
- "127.0.0.1:3001"
- "127.0.0.1:3002"
health_check:
kind:
type: http # or: { type: tcp }
path: "/healthz" # default "/"
expected_status: 200 # default 200
interval_secs: 5
timeout_secs: 2
healthy_threshold: 2 # consecutive OK probes to mark healthy
degraded_threshold: 2 # failures to mark degraded
down_threshold: 3 # failures to mark fully down
Set maintenance_mode: true on a backend to take it offline gracefully (clients get a maintenance response) without deleting its config.
Backend kinds
http — connect to the backend over plain HTTP.
https — connect to the backend over HTTPS (TLS to origin).
tcp — forward TCP to the backend. Whether the backend receives plain TCP or the raw (still-encrypted) TLS stream is controlled per-frontend by tcp_terminate_tls — see TCP pass-through below.
local-directory — serve static files from a local directory path.
There is no separate raw backend kind — raw is a CLI convenience (cruma proxy raw / cruma config add raw) for a tcp backend with tcp_terminate_tls: false on its frontend.
Frontends
A frontend maps a hostname pattern to either a backend or a hosted process. Each hostname gets its own frontend entry.
Field
Required
Description
hostname
yes
Hostname pattern (see below)
backend_id
one of
References a backend id. Mutually exclusive with process_id / kubernetes_target_id.
For a tcp backend, the frontend's tcp_terminate_tls controls what the backend receives:
true (default) — TLS is terminated before the backend; it receives a plain TCP stream.
false — the raw, still-encrypted TLS stream is forwarded untouched; the backend must terminate TLS itself (true end-to-end encryption between client and backend).
cruma proxy raw and cruma config add raw are shortcuts that set this to false on a tcp backend automatically; proxy tcp/config add tcp leave it at the true default.
This only gives true pass-through for CNAME'd custom domains, where TLS always terminates on your agent. For assigned Cruma hostnames (*.tun.cruma.io), where TLS may instead terminate at the Cruma ingress (see Security), tcp_terminate_tls: false still avoids local termination on the agent, but can't control a decision made upstream of it.
Hostname patterns
app — expands to app.<assigned-fqdn> (e.g. app.abc123.tun.cruma.io)
app.yourdomain.com — exact match on a custom domain (requires CNAME)
*.yourdomain.com — wildcard match for any subdomain
* — matches any hostname (not recommended)
Multiple frontends can point to the same backend (e.g. a shortname and a custom domain both routing to the same service).
Path-based routing
One hostname can fan out to different targets by URL path prefix using path_routes. Each entry needs a path_prefix and one target (backend_id, process_id, or kubernetes_target_id); requests that don't match any prefix fall through to the frontend's own backend_id/process_id.
frontends:
- hostname: "app"
backend_id: web # default: everything not matched below
path_routes:
- path_prefix: "/api"
backend_id: api
- path_prefix: "/admin"
process_id: admin-app
Longer prefixes win over shorter ones, so /api/v2 can override /api.
Kubernetes targets
Frontends (and path routes) can point straight at a service in a Kubernetes cluster. Define the target once under the top-level kubernetes_targets, then reference it by id:
The agent can supervise local processes and optionally create frontend routes backed by them. This is useful for running your app server alongside the tunnel in a single command.
Field
Required
Description
id
yes
Stable process identifier
command
yes
Binary or command to run
args
no
List of arguments
working_directory
no
Working directory for the process
env
no
Map of environment variables
auto_start
no
Start the process automatically (default: true)
start_on_request
no
Lazily start the process on first incoming request (default: false)
restart_policy
no
never, on-failure, or always (default: on-failure)
upstream_protocol
no
HTTP protocol to the process: H1 (default), H2, or H2PK. Uppercase in the config file (the CLI flag uses lowercase).
upstream_tls
no
Use HTTPS to connect to the process (default: false)
backend_timeout_seconds
no
Timeout for upstream response in seconds (default: 10)
idle_timeout_seconds
no
Auto-stop the process after being idle for this many seconds. Combined with start_on_request for full spin-up/spin-down lifecycle. 0 or omitted means never auto-stop.
binary_watch
no
Restart the process when its executable changes on disk (see cruma schema for fields)
shadow_copy
no
Run from a copied binary so the original can be replaced while running (see cruma schema)
To route traffic to a process, add a frontend with process_id instead of backend_id:
Listeners bind to local addresses so you can access your routes directly on localhost (or on a LAN) without going through the Cruma cloud ingress. They share the same routing table as the cloud ingress.
Field
Required
Description
kind
no
http, https, or cruma. When omitted, defaults to https if TLS is enabled, http otherwise.
port
yes
Port number to listen on (ignored for kind: cruma)
addr
no
localhost (default, loopback only) or all (0.0.0.0)
tls
no
Legacy field. Prefer using kind instead.
cert_mode
no
self_signed (default) or acme_alpn (Let's Encrypt via TLS-ALPN-01; requires port 443 reachable from the internet). from_frontend is a legacy alias for acme_alpn — per-frontend cert_mode_overrides always take precedence regardless of the listener's mode.
http3
no
Serve HTTP/3 (QUIC) alongside HTTP/1.1 + HTTP/2 on an HTTPS listener (default: true)
bind_ip
no
Bind to a specific IP instead of the addr preset
max_connections
no
Cap total concurrent connections on this listener
max_connections_per_ip
no
Cap concurrent connections from a single client IP
There's no dedicated local_only setting. The agent only connects to the Cruma cloud when a cruma-kind listener is present — so to run purely as a local reverse proxy, simply don't define one. Only your local http/https listeners will be active.
backends:
- id: web
kind: http
destination: "127.0.0.1:3000"
frontends:
- hostname: "web"
backend_id: web
listeners:
- kind: https
port: 8443
Access controls
Form-based and API key auth are middlewares on a frontend (see Middlewares), not backend fields.
Form-based authentication
Form auth provides a built-in login page with signed-cookie sessions:
Put a "Sign in with GitHub/Google/…" wall in front of a route. Define the provider(s) once at the top level under oauth2_providers, then reference them from an oauth2 middleware. The middleware needs a session_secret to sign session cookies.
oauth2_providers:
- id: github
label: "GitHub"
client_id: "your-client-id"
client_secret: "your-client-secret"
auth_url: "https://github.com/login/oauth/authorize"
token_url: "https://github.com/login/oauth/access_token"
userinfo_url: "https://api.github.com/user"
scopes: ["read:user"]
# For OIDC providers you can instead set `discovery_url` and let Cruma
# fill in auth/token/userinfo automatically.
frontends:
- hostname: "app"
backend_id: web
middlewares:
- type: oauth2
provider_ids: ["github"] # which providers to offer
session_secret: "replace-with-a-random-secret"
Cruma can also act as its own OAuth2 authorization server (issue tokens to your apps) via the top-level local_oauth2_server block and the GUI's Local OAuth2 page — see cruma schema for its fields.
Middlewares
Frontends support an ordered list of HTTP middlewares. These are applied in order for each request.
Note: X-Forwarded-Proto, X-Forwarded-For, and X-Forwarded-Host headers are automatically added by the proxy runtime — you don't need to configure them.
Use cruma schema to see the full JSON schema with all middleware fields and validation rules.
Managing configs with the CLI
Adding targets
The cruma config add command creates a backend and a frontend route in one step:
# Add an HTTP target
cruma config add http 127.0.0.1:3000 --hostname react-dev
# Add an HTTPS target with multiple hostnames
cruma config add https example.com:443 --hostname api --hostname api.dev.yourdomain.com
# Add a TCP target
cruma config add tcp 127.0.0.1:5432 --hostname db
# Add a raw TCP target
cruma config add raw 127.0.0.1:4943 --hostname raw-service
# Add a local directory with auth
cruma config add dir ./public --hostname docs --enable-index true --render-markdown --user admin:pass123
# Add a hosted process with a frontend route
cruma config add process node --arg server.js --hostname myapp --env "PORT=3000" --restart on-failure
# Add a process that starts lazily on first request
cruma config add process node --arg server.js --hostname myapp --start-on-request
# Add a process without auto-starting it
cruma config add process node --arg server.js --no-auto-start
Inspecting configuration
cruma config show
This prints an indexed view of all backends, frontends, processes, and listeners, which you need for remove and update commands.
Updating targets
# Change destination
cruma config update 0 --dest 127.0.0.1:4000
# Add/remove hostnames
cruma config update 0 --add-hostname new-host --remove-hostname old-host
# Clear all hostnames at once
cruma config update 0 --clear-hostnames
# Add form auth user
cruma config update 0 --add-user admin:password
# Clear all form auth users
cruma config update 0 --clear-users
# Set API key auth
cruma config update 0 --api-key "X-API-Key:secret123"
# Clear API key auth
cruma config update 0 --clear-api-key
# Enable/disable directory indexing (for directory targets)
cruma config update 0 --enable-index true
# Enable/disable markdown rendering (for directory targets)
cruma config update 0 --render-markdown false
Removing targets
# Remove by index (see 'config show' for indices)
cruma config remove 0
# Remove a process
cruma config remove-process 0
# Remove a listener
cruma config remove-listener 0
# Use the default config file
cruma start
# Use a specific file
cruma start ./cruma.yaml
# Shorthand: use -c / --config on the top-level command
cruma -c ./cruma.yaml
Default config vs. multiple configs
The default config file is just a convenience. You can run multiple agents by pointing each one at a different config file (with a different profile set in each config):
Use multiple configs when you want separate tunnel credentials or distinct target sets per agent. If you only need one agent with multiple services, keep a single config file and add multiple targets.
Top-level settings reference
Field
Required
Default
Description
backends
yes
[]
Backend services
frontends
yes
[]
Frontend routes
tunnel_id
no
ANON
Tunnel ID for the public FQDN
tunnel_secret
no
ANON
Tunnel secret key
tower_server
no
tower.cruma.io:443
Control plane endpoint
profile
no
—
Named profile to scope cached identity
temp
no
false
Use a temporary identity (fresh FQDN each run)
processes
no
[]
Hosted processes supervised by the agent
listeners
no
[]
Local listeners
kubernetes_targets
no
[]
Named Kubernetes services frontends can target
oauth2_providers
no
[]
OAuth2/OIDC sign-in providers referenced by oauth2 middlewares
local_oauth2_server
no
—
Configuration for Cruma acting as its own OAuth2 authorization server
global_env
no
{}
Environment variables shared by all hosted processes
acme_directory
no
—
ACME directory/account settings for automatic certificate issuance
root_dir
no
—
Base directory that relative paths in the config resolve against
performance
no
—
Data-plane/engine tuning (thread-per-core, native io_uring plane). Applied on restart.
Notes:
profile and temp are mutually exclusive.
Changes to tower_server, profile, or temp require a restart to take effect.
Other changes (credentials, backends, frontends) are hot-reloaded when the config file is saved.
There's no local_only field — omit a cruma-kind listener to run without the cloud tunnel (see Local-only mode above).
Bring your own domain
📝 Subscription required
Custom domains are available on subscribed (Basic/Pro) plans only. Anonymous tunnels and free/unsubscribed accounts cannot use custom domains or CNAMEs.
Each tunnel gets an assigned FQDN based on your tunnel ID (e.g., <tunnel-id>.tun.cruma.io). To use your own domain, add a CNAME and include that hostname in a frontend route.
Add the CNAME
Create a CNAME in your DNS provider pointing to the tunnel FQDN:
Multiple frontends can point to the same backend — here both the shortname (react-dev, which expands to react-dev.<tunnel-id>.tun.cruma.io) and the custom domain (app.yourdomain.com) route to the same service.
Wildcard hostnames
You can also use wildcard patterns for custom domains:
frontends:
- hostname: "*.yourdomain.com"
backend_id: web
This requires a wildcard CNAME (or individual CNAMEs for each subdomain) pointing to your tunnel FQDN.
TLS for custom hostnames
Once DNS propagates, your custom hostname will resolve through the tunnel just like the assigned address.
TLS for custom hostnames is terminated on your agent, not on Cruma's servers. The agent automatically obtains a trusted certificate from Let's Encrypt via ACME TLS-ALPN-01 — no manual certificate setup is needed. This means payloads are encrypted end-to-end between the client and your agent; Cruma infrastructure only forwards the encrypted TLS stream.
Assigned Cruma hostnames (*.tun.cruma.io and *.*.tun.cruma.io) now have two modes:
On paid plans with an active subscription, the agent attempts to obtain a certificate for its assigned hostname via ACME DNS-01. Once that certificate is ready, the agent can terminate TLS for the assigned hostname too.
If the agent cannot get the certificate yet, TLS falls back to the Cruma ingress using Cruma-managed certificates.
You can see the current assigned-hostname certificate status in the GUI on the Certificates page.
For additional hardening, you can set CAA records on your domain to restrict certificate issuance and consider pinning or mTLS. See Security & TLS for details.
Rate Limits & Fair Use
Cruma.io is built by a small team with limited resources. There is no formal SLA; we will do our best to keep tunnels responsive and reliable. Fair-use limits protect shared capacity.
What to expect
Throughput/latency: Best-effort. Typical dev workloads (web apps/APIs) should feel snappy; heavy load tests or bulk transfers may be throttled.
Concurrent tunnels/targets: Keep to a small number per beta account.
Request/byte volume: We monitor request counts and bandwidth. Sustained high volume may be rate-limited.
Burst control: Sudden spikes can be shaped to keep the service healthy for everyone.
Custom domains
Custom hostnames routed via CNAME are only available on subscribed (Basic/Pro) plans. Anonymous and free/unsubscribed (registered) tunnels cannot use custom domains.
Tiers and typical limits
Anonymous: Intended for quick, disposable tests. Hard limits apply:
No custom domains (CNAMEs)
Max 3 hours of uptime per session
Max 1 GB of traffic per session
Max 1 million requests per session
Free/Unsubscribed (Registered): Higher allowances than anonymous; suited for sustained dev use, moderate bandwidth, and a handful of tunnels/targets.
No custom domains (CNAMEs)
Subscribed (Basic/Pro): Highest allowances and more headroom on concurrency and throughput. Custom domains supported. Exact limits may evolve; the goal is to support heavier workloads reliably.
Bandwidth per TCP stream (caller ↔ target):
Anonymous: ~1 MB/s per stream
Registered: ~5 MB/s per stream
Subscribed: ~10 MB/s per stream
Agent and tunnel connection limits
We plan to enforce limits on how many agents can connect to a single tunnel, and on the number of concurrent agents per user. These limits are being refined and will be better defined after the beta period.
Security and TLS
Where TLS terminates
Assigned Cruma hostnames (*.tun.cruma.io and *.*.tun.cruma.io): On paid plans with an active subscription, the agent attempts to obtain a certificate for its assigned hostname via ACME DNS-01 and take over TLS termination itself. Once the certificate is ready and the agent has notified the cloud service, TLS is terminated on the agent. If certificate issuance is unavailable, still in progress, or fails for any reason, TLS falls back to the Cruma ingress using Cruma-managed certificates.
Custom CNAME hostnames: TLS is terminated on your agent using a certificate automatically obtained via ACME TLS-ALPN-01 (Let's Encrypt). Cruma infrastructure never sees the plaintext payload for these hostnames — it only forwards the encrypted TLS stream to your agent, which terminates it locally.
Local TLS listeners: You can choose between self-signed certificates (good for local development) or ACME TLS-ALPN-01 (requires port 443 reachable from the internet). See Configuration for details.
TCP backends: terminate or pass through
A tcp backend's frontend has a tcp_terminate_tls setting (see Configuration) that affects what happens after TLS termination. cruma proxy tcp/config add tcp leave it at its default (true); cruma proxy raw/config add raw set it to false:
Assigned Cruma hostname
CNAME'd custom domain
tcp_terminate_tls: true (tcp)
Agent terminates TLS when the assigned-hostname certificate is ready; otherwise Cruma ingress terminates TLS. Backend always receives plain TCP.
Agent terminates TLS (ALPN-01) → plain TCP to backend
tcp_terminate_tls: false (raw)
If the assigned hostname is agent-terminated, the agent terminates TLS and the backend receives plain TCP. If not, Cruma ingress terminates TLS and the backend still receives plain TCP.
Cruma forwards the encrypted TLS stream → agent passes it through untouched → backend handles TLS
Key takeaway: raw (tcp_terminate_tls: false) only provides true end-to-end TLS pass-through (where the backend terminates TLS itself) when using a CNAME'd custom domain. With assigned Cruma hostnames, TLS is still terminated before the backend, either on the agent or at the Cruma ingress, so raw and tcp both result in plain TCP at the backend.
What we can see
Assigned Cruma hostnames: Visibility depends on where TLS terminates for that hostname. If the agent has successfully provisioned its assigned-hostname certificate and is actively terminating TLS, Cruma only forwards the encrypted stream. If the hostname is still using fallback ingress termination, payloads are technically accessible to Cruma infrastructure. Today we only handle what's needed for routing and telemetry (e.g., request counts, health checks) and do not run MITM or payload-inspection features. If we ever add a feature that needs payload inspection, it would be explicitly opt-in.
Custom CNAME hostnames: Cruma sees only control-plane metadata (tunnel ID, target types, health/connection status) plus request/byte counts for abuse prevention. Because TLS terminates on your agent via ACME TLS-ALPN-01, payloads remain end-to-end encrypted between the client and your agent.
Assigned-hostname certificate flow
For eligible paid tunnels, the agent attempts to obtain a certificate for its assigned *.tun.cruma.io or *.*.tun.cruma.io hostname using ACME DNS-01. After the certificate is ready, the agent tells the cloud service that it is ready to terminate TLS locally.
If that process cannot complete, traffic continues to work with the normal fallback: the Cruma ingress terminates TLS using Cruma-managed certificates.
The GUI shows the current state of this handoff on the Certificates page so you can see whether the assigned hostname is agent-terminated or still using cloud termination.
How custom-domain TLS works
When you CNAME a custom hostname to your tunnel FQDN, the agent automatically provisions a Let's Encrypt certificate using the ACME TLS-ALPN-01 challenge. This happens transparently — you don't need to configure certificates manually. The Cruma ingress routes the raw TLS connection to your agent, which presents the certificate and terminates TLS locally.
This means:
Clients connect with a valid, publicly trusted certificate.
The payload is encrypted end-to-end between the client and your agent.
Cruma infrastructure forwards the encrypted stream but cannot decrypt it.
Inspection and opt-in
Ingress-terminated traffic can be inspected in principle because TLS ends on Cruma. No payload inspection is performed today beyond what's required to operate the service; any future feature needing payload visibility would be opt-in.
For custom CNAME hostnames, your agent terminates TLS and Cruma cannot inspect payloads. For additional hardening, see the CAA and pinning options below.
Hardening options (custom domains)
The automatic ACME TLS-ALPN-01 issuance on your agent already provides strong end-to-end encryption for custom domains. The options below are for users who want additional guarantees.
CAA records (restrict certificate issuance)
Set CAA records on your custom domain to restrict which Certificate Authority can issue certificates for it. This prevents anyone (including Cruma infrastructure) from obtaining a certificate for your hostname through a different CA or ACME account.
Example — permit only Let's Encrypt:
app.yourdomain.com. CAA 0 issue "letsencrypt.org"
For tighter control, use accounturi to limit issuance to your agent's specific ACME account:
If your security model requires it, you can pin the certificate your agent presents for custom hostnames. Clients will reject any different certificate, preventing a silent MITM even if someone were to obtain a valid cert for your domain through another path. Pinning is advanced and makes certificate rotation more complex — use it only when your threat model demands it.
Combine CAA with pinning (or mTLS) for the strongest guarantees: CAA restricts who can issue a certificate, and pinning ensures clients only accept the specific certificate your agent presents.
Restricting who can access a route
TLS secures the channel; access control decides who is allowed through. Both are configured as per-frontend middlewares (see Configuration → Access controls):
basic_auth — HTTP Basic, a static user list. Simplest, good for quick internal tools.
form_auth — a hosted login page with signed-cookie sessions.
oauth2 — "Sign in with GitHub/Google/…" via an OAuth2/OIDC provider; Cruma can also run as its own OAuth2 server.
authentication — API key or JWT (optionally verified against a jwks_url), for machine-to-machine access.
ip_filter — allow/deny by client IP (X-Forwarded-For aware), plus a firewall/WAF layer that runs before routing.
Put an auth middleware at the top of a frontend's middleware list so it runs before anything reaches your service.
How this compares
Assigned hostnames: Cruma can fall back to provider-edge termination for assigned *.tun.cruma.io hostnames, similar to Cloudflare, ngrok, and similar services for their default domains. On eligible paid tunnels, the preferred path is now agent-side termination once the assigned-hostname certificate is ready.
Custom hostnames: Terminating TLS on the agent via ACME TLS-ALPN-01 provides true end-to-end encryption by default — no manual certificate setup needed. This is stronger than providers that terminate custom-domain TLS at their edge. For additional hardening, layer on CAA and/or pinning.
What is Promenade?
Promenade is a drop-in widget that turns any web page into a small shared
place. Add one <script> tag to a site and the page itself becomes a playable
landscape: every letter of every paragraph is solid ground, so visitors control
a little stick figure that walks across headings, jumps between list items,
fires a jetpack to reach the skyline, and falls through the gaps between words.
Anyone else reading the same page appears as their own figure walking the same
text, in real time — and you can talk to them, by text chat or by voice and
video. It needs no cooperation from the host page's own code, and it renders
entirely inside a shadow root so it can't touch (or be touched by) the host
site's styles.
The fastest way to see it: There is a decicated site over at promenade.cruma.io which has the script running in server mode
What it's for
Presence on a quiet page. A blog or docs page stops being
read-once-and-forgotten — you can see who else is here right now and wander
over to them.
The page is the level. There's no authored level geometry. The article
is the terrain, so every page is a different place, for free. Change the page,
and you've changed the map.
Chat tied to a location. General chat, named channels, direct messages,
and a voice/video room — attached to a figure standing somewhere on the page,
not a faceless sidebar.
A serverless option. A single attribute (data-p2p) drops the server
entirely and runs the whole thing over a browser-to-browser mesh. Fewer
features, but no infrastructure at all.
Four ideas carry everything
The page is the level. Every non-whitespace glyph of every matched element
becomes its own small platform, measured from the page's real layout. Text-less
elements (images, inputs) become one block platform instead.
It is a guest on someone else's page. Everything renders inside a shadow
root, the scene is anchored to the document rather than pinned over your content,
and the host's own CSS is never touched. A widget that breaks its host has failed.
One origin, one world. Visitors are partitioned into universes keyed by the
website they're on. Each embedding site gets its own private area — people on
foo.com never see people on bar.com.
Two transports, one client.Server mode (a relay with persistence and
moderation) and P2P mode (a serverless browser mesh) run the same simulation
and the same UI. The only difference is where packets go and which features
exist.
Server mode vs P2P mode at a glance
Server mode
P2P mode (data-p2p)
Infrastructure
Talks to a Promenade server
None — browsers connect directly
Room identity
The site's origin → a universe
A room key you choose (default: the site origin)
Chat history
Persisted per site (owner-toggleable)
Ephemeral only
Moderation
Mute, ban, purge, reports, audit trail
None — local hide only
Voice / video
Yes, owner can gate it
Yes, open to everyone in the room
Identity
Stable per-browser id, hashed server-side
Random per page load (today)
Most sites use server mode by pointing the script at the managed server at
promenade.cruma.io — you get moderation, persisted chat, and the owner
dashboard with nothing to run yourself. Reach for P2P mode when you want
zero infrastructure and don't need moderation or history.
The next chapter shows how to add the widget to a page. If you just want to know
what the keys do, jump to Controls, chat & video. If
you're curious how the page-as-terrain trick actually works, see
How it works.
Embedding Promenade
The widget is a single script tag. It mounts all of its own DOM and styles
inside a shadow root, draws a non-interactive overlay pinned to your page, and
connects back to a Promenade server for presence and chat.
The one-line version
Point the script at the managed server and you're done:
That's the whole install. The async attribute keeps the widget from holding up
your page — it never blocks the parser and doesn't delay your DOMContentLoaded.
Promenade finds its own <script> tag to read the options below, so load order
never matters.
Because the script is loaded frompromenade.cruma.io, it also talks to that
server by default — everyone visiting your site lands in your site's own private
universe (keyed by your origin), and you never see visitors from other sites.
Hosting the script elsewhere
If you'd rather serve promenade.js from your own CDN but still use the managed
server, tell it where the server is with data-server:
See P2P mode for what changes, room keys, and the
trade-offs.
Where the widget appears
By default the widget fits itself into your page without growing it whenever
it can: it measures where your real content ends and, if the whole promenade band
fits in the empty space at the foot of the viewport, it tucks in there — no extra
scrollbar, the line stays visible above the fold. Only when your content is tall
enough that the band genuinely doesn't fit does it add a little padding at the
page foot and put the line below your content (scroll down to reach it).
You can override this with data-clearance (see the reference below).
Boxed embed
Instead of overlaying the whole page, you can mount the promenade inside a
specific element — a framed, clipped box the figure can't leave, perfect for a
"try it here" section:
Size the box with your own CSS on that element. A boxed embed is floor-only
(there's no host text inside it to stand on). The chat panel and the fullscreen
toggle still work — they float over the whole viewport rather than being trapped
in the box.
Caveat: a transform, filter, or contain on the mount element (or any
ancestor) makes it the containing block for otherwise viewport-fixed layers,
which would trap the chat panel and fullscreen stage inside the box. Keep those
off the container.
Attribute reference
All attributes go on the <script> tag.
Attribute
Effect
data-server
Which server to talk to. Defaults to the script's own origin.
Override the Nostr relays used for P2P discovery/signalling.
data-p2p-strict-pow
Hide P2P peers with missing/invalid proof-of-work instead of just flagging them. Off by default.
data-solids="h1,h2,img,…"
CSS selector for which elements become terrain. Defaults to headings, p, a, li, media, table cells, and form controls.
data-mount="#selector"
Bounded/"boxed" embed inside a container (implies floor-only).
data-max-width / data-max-height
Size caps (CSS px) for a boxed embed.
data-clearance="<px>"
Manual control of the headroom reserved above the promenade line. 0 keeps only the UI band; a negative value eats into the band so the page grows less (≈-64 zeroes the reserve). Leave it off to keep the automatic fit.
data-delay="<ms>"
Defer the first page measurement (and server connection) by N ms. Useful when your content slides/fades in with an entrance animation — measuring mid-animation bakes terrain a few pixels low. Set it to roughly the animation's duration.
data-chart-nodes="<selector>"
Make diagram nodes climbable platforms. Defaults to Mermaid flowchart nodes; point it at another library's node elements, or set empty to disable.
data-chart-edges="<selector>"
Make diagram edges walkable ramps (sampled into little staircases). Defaults to Mermaid edge paths; override or set empty to disable.
data-lamp-switch
Turn the lamppost into a light switch: walk up to it and press Enter (or click) to flip the page between light and dark. Writes data-theme on <html>; the choice is local to the browser and remembered across reloads.
data-debug
Outline every solid platform, for debugging your terrain.
Cooperating with your page's own theme toggle
If you use data-lamp-switchand your page has its own dark-mode control,
they should agree. Promenade drives the page the same way a normal theme button
would — by setting data-theme (and color-scheme) on <html>, and persisting
light/dark under the localStorage key pm-theme.
Use the same source of truth so you don't get two competing preferences. Two ways
to stay in sync — pick either:
Listen for the event. Each flip dispatches a promenade:theme event on
document:
document.addEventListener("promenade:theme", function (e) {
myThemeSwitch.checked = e.detail.light; // e.detail.theme is "light" | "dark"
});
Observe the attribute (catches any writer, not just the lamp):
new MutationObserver(function () {
var light = document.documentElement.getAttribute("data-theme") === "light";
myThemeSwitch.checked = light;
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
A note on performance
The widget is around 330 KB and loads lazily with async, so it never blocks
your page render. Terrain is measured once and only recomputed on resize or when
the DOM changes — scrolling costs nothing. The P2P transport lives in a separate
chunk that's only downloaded when you actually use data-p2p.
Controls, chat & video
This chapter is for visitors — the people walking around on a page that has
Promenade embedded. Nothing here needs setup; it's how you actually play and
talk.
Moving around
Key
Does
A / D
Walk left / right
W
Jump
J or Shift
Jetpack — hold to fly (burns limited fuel)
S
Climb down through a bordered edge of a div or pre
T
Open the Travel menu — jump to any heading on the page, or to another page. Start typing to filter; Enter jumps to the first match.
E
Open your camp / settings dialog (name + colour) from anywhere
. (dot)
Open a slim chat bar — Enter sends, Esc cancels
C
Toggle the chat history panel
;
Toggle the fullscreen stage
Some abilities depend on the mode the site runs — on a floor-only "promenade"
site there's no glyph terrain, jetpack, or climbing (see
Modes & universes).
Carrying your figure (desktop)
On desktop you can pick up and carry your own figure with the mouse: press
and drag it anywhere on the page, then release to drop it — it falls and lands on
whatever's beneath. Everyone else sees a little UFO swoop in, beam your figure up
and away, then set it down at its new spot. A quick click (no drag) just opens
your settings instead.
Pickups are limited to one every 5 seconds; while it's cooling down the figure
shows a red "blocked" cursor. (Dragging is mouse-only — on touch devices a
press-drag scrolls the page as usual.)
Idling
If you stop for a while, your figure shows Zz after 10 seconds and settles into
a bed after 30. Everyone sees the right pose — even people who just arrived.
Setting your name and colour
Press E anywhere, or walk up to the camp tent prop (beside the fire and
tree) and press Enter when it lights up. Set your name, a colour, and an
optional short description. Your choices are saved in your browser and shared with
everyone else in the universe in real time. (Esc closes the dialog.)
Chatting
Everyone on the same site shares a chat. Press . (dot) for a slim input bar
pinned low on the screen — Enter sends, Esc cancels. Each message pops a
speech bubble over your figure for a few seconds.
Press C to open the full history panel: a floating, draggable, resizable
window that does not block or blur the page — you can keep walking and reading
while it's open. Drag it by its header to move it, drag a corner to resize, and
use the header button to switch between a glass (translucent) and a solid
background. Its size, position, and look are remembered in your browser.
The panel lists every message with the sender's name, a timestamp, and the text,
and has a composer at the bottom so you can type and send right there. On a
server-mode site the server keeps a rolling history and replays whatever's still
in the window when you arrive, so you land mid-conversation. Chat is scoped to the
site's universe — foo.com and bar.com never see each other's messages.
Beyond the general chat there are also named channels and 1:1 direct
messages in the same panel. Direct messages between two people negotiate a
direct, encrypted browser-to-browser connection where possible — the panel
shows whether a conversation is direct or relayed via the server — and fall
back to the server relay if a direct link can't form.
Voice & video
When the site owner enables it, the chat panel gains a 🎥 Live room: a
WebRTC mesh where participants share audio and optional video.
It is strictly opt-in — nothing touches your mic or camera until you click
Join with mic or Join with camera, so you never unexpectedly broadcast.
Participants appear in a video grid (camera-off shows an avatar tile), and a 🔊
indicator lights over someone's head while they're talking. Media flows directly
between browsers; the server only relays who's in the room.
A few things worth knowing:
The video grid sits beside the chat and stacks on a narrow panel. You can hide
the chat column for a video-only view, and split the layout side-by-side or
stacked.
On a phone in landscape, opening chat during a call switches to a clean
theater (fullscreen) layout built for small screens.
You can opt into camera thumbnails over players' heads (Settings → Live
room) to see participants' video in the scene itself, not just in the grid.
If your mic or camera can't be opened, the room tells you why (for example it
needs a secure HTTPS or localhost page, or permission was blocked) instead of
silently doing nothing.
Connection trouble? Direct media needs the two browsers to reach each
other. On open or LAN networks this just works, but some restrictive networks
need a relay (TURN) that the site owner has to provide. Without one, a
connection that can't get through shows a clear "your networks may need a TURN
server" notice rather than stalling silently.
The props
Along the foot of the page runs the promenade line with a few interactive props,
placed at fractions of the page width:
A bus stop — the Travel menu.
A lamppost — optionally a light switch (if the site enabled
data-lamp-switch).
Benches you can sit on.
A telephone box — toggles the fullscreen stage.
A camp tent — your name/colour dialog.
A campfire that can set you alight — and, in floor mode, doubles as a
connection tell: it burns out when your connection drops and relights when
it comes back.
A tree.
Props scale and shift on narrow screens so they don't pile up. Which props appear
is configurable by the site owner.
Modes & universes
Promenade runs the same client everywhere, but a site can be configured to feel
quite different. This chapter explains the two concepts that shape that:
universes (who shares a world) and modes (what that world lets you do).
Universes: one world per site
Visitors are partitioned into universes, keyed by the browser's origin — the
site the widget is embedded on. Every site that embeds the widget gets its own
private street: visitors on https://foo.com never see visitors on
https://bar.com. A universe is created on demand when its first visitor arrives
and dropped when its last visitor leaves.
Everything is scoped to the universe the same way: movement, chat history,
channels, and the voice room. This is why a busy site and a quiet one embedded
from the same server never bleed into each other.
On the roadmap: "stargates" that let a figure hop from one site's universe into
another. Today, a universe is a firm boundary.
Modes
The mode decides which abilities exist in a universe. In server mode the owner
picks the mode (globally or per-site); the server advertises it to every visitor
so everyone in a universe agrees on the rules, and it enforces the mode — a
modified client can't, say, fly in a mode where flight is off.
Mode
Terrain
What it feels like
explore (default)
Full glyph terrain, jetpack, warp links, figure-dragging
The full experience: the page's text is terrain and figures climb, fly, and warp around it.
promenade (floor)
Floor only — walk and jump on one line
A calm "town square": visitors can only walk and jump along a single promenade line at the foot of the page, never interacting with the page content itself.
fullscreen stage
Floor only
Any visitor can toggle a fullscreen stage with ; (or the telephone box). Occupants share a world with each other and appear as "ghosts" to people still on the page.
boxed
Floor only
Set by the site's data-mount embed — the promenade lives inside a bordered box instead of overlaying the whole page.
explore mode
The default. The host page's text is the terrain: every glyph is a platform,
figures climb and jump between headings and list items, fire the jetpack to reach
the top, warp through links, and can be picked up and carried. The Travel menu
jumps to any heading. This is the mode most sites want.
promenade (floor) mode
A deliberately minimal, TownSquare-style
mode. Visitors can only walk and jump on the promenade line, which sits at the
foot of the page — you scroll down to reach it. The view never camera-follows as
you walk; your figure slides across a fixed-width stage instead of dragging the
page around.
The widget never scans the host page in this mode — no glyph terrain, no
link-walking, no watching the DOM — because there's nothing to collide with. Glyph
terrain, climb-down, the jetpack, link-warp signs, and figure-dragging are all
off. The Travel menu remains but only lists "leave this site" links. Jumps are a
purely cosmetic hop that never affects a figure's networked position, so they can
never jitter on other people's screens. The result is smooth, even motion that
suits a busy or a very simple page.
Ghosts: when layouts don't match
A figure's position is expressed as which glyph it's standing on, not as pixels
(see How it works). That only lines up perfectly when
two visitors' pages wrap text the same way. When they don't — a different window
size or zoom, a fullscreen stage, a boxed embed, or genuinely different content —
the other person is shown as a faded ghost: an approximate figure placed onto
your terrain rather than positioned precisely-but-wrongly. Ghosts are how the
widget stays sensible across a phone and a widescreen monitor looking at the same
page at the same time.
Serverless (P2P) mode
Add one attribute and Promenade runs with no server at all:
In P2P mode, visitors' browsers connect directly to each other over a WebRTC
mesh. Public relays are used only for discovery and signalling — the handshake
that lets two browsers find each other — never for your movement or your messages.
Once connected, everything flows browser-to-browser.
This is the zero-infrastructure option: you don't run anything, you don't need an
account, and there's nothing to administer. The trade-off is that features which
fundamentally require a trusted server aren't available.
What you get, and what you give up
P2P mode
Server mode
Infrastructure
None
A Promenade server
Chat history
Ephemeral (in-session only)
Persisted per site
Moderation
Local hide only
Mute, ban, purge, reports
Identity
Random per page load (today)
Stable, hashed per browser
Voice / video
Yes — open to everyone in the room
Yes — owner can gate it
Anti-abuse
Receiver-side guard + proof-of-work
Server-enforced
The single biggest difference is moderation. With no server there's no
authority to ban anyone, persist history, or resolve identity disputes — each
browser can only locally hide peers it doesn't want to see. Sites that need real
moderation should use server mode.
Rooms
In server mode, the "room" you land in is fixed by the site's origin. In P2P mode
you choose it with a room key — a rendezvous string that decides which
browsers try to find each other:
The default room key is location.origin, so by default P2P behaves like server
mode's per-site partitioning. Note a key difference, though: a room key is a
rendezvous point, not a boundary. Anyone who knows the key can join. It
decides who finds each other, not who's allowed in.
Relays
Discovery uses public Nostr relays by default. You can point
at your own with data-p2p-relays if you'd rather not depend on the public ones:
There's no server to enforce rules, so P2P does its policing on the receiving
side: each browser vets what it receives and quarantines misbehaviour locally.
That needs no cooperation from the sender, which is why it works at all.
Per-peer movement and flood limits catch an individual bad actor.
Aggregate budgets catch a swarm — twenty peers each individually compliant
are still a swarm — and shed the least-trusted first, where "trust" is simply
time-in-room (unforgeable, and paid in wall-clock).
New peers solve a small proof-of-work before they're shown, so a flood of
fresh identities costs real effort.
By default, peers with missing or invalid proof-of-work are flagged but still
shown. Add data-p2p-strict-pow to hide them entirely:
You want presence and chat with zero infrastructure.
You don't need persisted history or the ability to ban people.
Your audience is small-to-medium and mostly on open networks (very restrictive
NATs may struggle to form direct connections without a relay).
Choose server mode when you need moderation, persisted chat, a stable
identity per visitor, or a gated voice room. See
Owner & admin controls for what that gives you.
Owner & admin controls
When you embed Promenade in server mode (pointing at promenade.cruma.io),
you can claim your site and take control of how the widget behaves there — the
mode, whether the voice room is on, whether chat history is kept, and who gets
moderated. This chapter covers that owner experience.
This applies to server mode only. P2P mode has no
server and therefore no owner controls, no persisted history, and no
moderation beyond a local per-viewer hide.
Claiming your site
Everything an owner can do is scoped to origins you've proven you control.
Promenade deliberately never trusts the widget's own Origin header for this —
a browser sets it honestly, but a non-browser client can forge it — so ownership
is verified out-of-band, the same way other services verify a domain.
From your Cruma dashboard, add your site's origin and pick one of two proofs:
DNS — add a TXT record at _promenade-challenge.<your-host> containing
the challenge token you're shown.
File — serve the token at
https://<your-host>/.well-known/promenade-verify.txt (the file's contents
should be exactly the token).
Then click Verify. Each origin has exactly one verified owner — first to
verify wins. Once verified, you get a copy-paste embed snippet and access to the
per-site settings below.
Per-site settings
These are all scoped to your verified origin (your universe). Each one is an
override of the server's global default — where you don't set anything, the
site inherits the default.
Setting
What it controls
Mode
Floor-only promenade mode vs full explore mode for your site (see Modes & universes).
Voice / video room
Turn the live 🎥 room on or off for your site.
Registered-only voice
Require visitors to be signed in (to a linked account) before they can join voice or place 1:1 calls. When off, guests can join.
Chat history
Whether chat is persisted and replayed to new arrivals, or live-only.
Federation
Opt in to listing your site's open, password-free channels in other sites' channel trees so people can discover them. Off by default.
Multiple channels
Allow named channels beyond the general chat.
Fullscreen stage
Allow the ; fullscreen stage.
Chat widget
Whether the draggable chat panel is available.
Travel menu
Whether the T travel menu is available.
Chat theme
The chat panel's colour theme.
You manage these from the site's page in your dashboard, which also shows a live
list of who's currently on your site and lets you drop into it yourself.
Moderation
Moderation exists only in server mode. There are two levels: what a site owner can
do inside the running widget, and the platform-level controls the server operator
holds.
As a site owner (in the widget)
Once you're recognised as the owner of your origin, you get a small set of
moderation actions right inside the running widget — from a player's info panel or
the in-call UI. Every action is re-checked server-side against your ownership of
that origin, so it only ever affects your site:
Mute — silence a visitor's chat (general and channels). They're told
they've been muted; you can unmute them again.
Voice-block — revoke a visitor's access to the voice room. If they're in a
call, they're removed from it.
Kick / ban — remove a visitor and block their network address from your
site. Their recent messages are purged and their connection is dropped.
Reports
Any visitor can report another (for spam or abuse, with an optional note),
rate-limited so it can't itself be spammed. Reports are retained even after the
offender disconnects, and are backed by a message audit trail, so a late report is
still actionable — a moderator can trace the reported visitor's network addresses
and recent messages and act on them after the fact.
Automatic anti-abuse
On top of manual moderation, the server runs the layered anti-abuse defences
described in How it works — connection-rate limits,
chat-flood gates, identity-churn detection, movement-plausibility scoring, and an
optional proof-of-work at connect. These run automatically; you don't have to
configure anything to benefit from them.
Platform-level controls
The server operator (the Cruma team, for the managed service) additionally has a
super-admin dashboard covering every universe on the server: global defaults
that all sites inherit, server-wide bans, the report queue and audit trail across
all sites, an embed/connect allowlist, and the sign-in providers offered to
visitors. As a site owner you don't need this — your per-site settings and
in-widget moderation cover your own universe — but it's what backstops the whole
service.
Running your own server
The managed server at promenade.cruma.io is not currently open source, so
self-hosting isn't covered here. If you want moderation and persisted chat, the
managed server is the path; if you want zero infrastructure and don't need those,
P2P mode needs no server at all.
How it works
You don't need any of this to use Promenade — but if you're curious how a web
page becomes walkable ground and how everyone stays in sync, here's the mental
model, top down.
The page is the level
When the widget loads, it measures the host page's real, laid-out text. It walks
every matched element (headings, paragraphs, links, list items, table cells, and
so on) and, for each non-whitespace character, measures the glyph's actual
on-screen rectangle. Each glyph becomes its own tiny platform, sized to the
visible ink of the letter — so a figure stands right on the letters, walks across
them, slips off their edges, and falls through the gaps between words and lines.
Text-less elements like images and inputs become a single block platform.
Because playability comes straight from typography, the whitespace is the
gameplay: the gaps between words and lines are deliberately holes to fall
through. Change the page's text and you've changed the map, for free — there's no
authored level anywhere.
Terrain is stored in document coordinates (scroll-independent), so scrolling
costs nothing; it's only recomputed on a resize or when the page's DOM changes.
Positions are glyphs, not pixels
Here's the idea the whole project rests on. A position cannot be sent as
pixels, because your window is a different width than mine — my pixel 400 is the
middle of a different word than yours. So a figure's position is expressed as
which glyph it's standing on:
el — the element's index in the match list,
seg — the character's offset within that element's text,
plus a fraction across that glyph.
Both el and seg are derived from the shared DOM that every visitor
received identically. So the same glyph is identifiable on every visitor's
screen, no matter how differently their browser lays the page out. When your
figure walks across a heading, the message that goes out says "I'm on the W of
element 3" — and the receiving browser resolves that against its own layout
and draws the figure there. Which is why it looks right on a phone and a
widescreen monitor at the same time.
When two visitors' pages don't lay out the same way — different window size,
zoom, a fullscreen stage, or genuinely different content — the glyph anchor would
resolve to a weird spot, so that peer is drawn as a faded ghost placed
approximately onto your terrain instead (see
Modes & universes).
What runs where
There are two pieces: one JavaScript bundle in every visitor's page, and —
in server mode — one server.
The client bundle contains the whole simulation, the UI, and both
transports. Local physics actually runs in a small Rust core compiled to
WebAssembly, with a pure-JavaScript fallback so the widget always works even
if the WASM can't load.
The server (server mode only) is a relay and a referee, not a
simulator. It never runs physics. It sanitizes and forwards each visitor's
state, enforces the mode (clearing flags for abilities a mode disables, so a
modified client can't express them), scores movement for plausibility, and
relays chat. Physics runs only in browsers.
In server mode the server forwards each visitor's state the moment it arrives
(rate-capped per player, and always flushing a player the instant it comes to
rest so its settled position is exact). A figure standing still generates no
traffic at all. In P2P mode there's no referee, so each browser instead vets what
it receives and quarantines misbehaviour locally.
Identity
In server mode, each browser mints a random secret on its first visit and keeps
it locally. It presents that secret to the server on connect, and the server
hashes it into the public id other people see. The secret itself never
leaves the browser, so a public id can't be reverse-engineered into someone's
secret or used to impersonate them — and because the hash is deterministic, a
returning visitor keeps the same identity. Multiple tabs from the same browser
share one player.
In P2P mode, identity is random per page load today (a stable-identity scheme is
in progress).
Anti-abuse, briefly
The widget is embeddable by anyone, so the abuse surface is "a bot connects to
every site using the script." The defences are layered, and mostly about
anchoring limits to something that costs real money:
Limits key on IP, not identity, because a browser identity is free to mint
— per-identity penalties would reset for nothing.
Identity churn is the signal that separates a real office behind one
address (24 identities that persist) from a spammer (a fresh identity every few
messages). Message rate alone can't tell them apart; churn can.
Proof-of-work at connect is a speed bump that makes swarms cost effort,
solved off the main thread so the host page never janks.
In P2P, the same ideas are judged locally by each receiver, plus aggregate
budgets that catch a swarm of individually-compliant peers.
If you self-host and every visitor appears to arrive from one IP (a common
misconfiguration behind naive Docker port-forwarding), the per-IP defences detect
the collapse and fail open with a loud warning, rather than throttling your
whole audience into one shared bucket.
Want the deep version?
This is the tour. The widget also has an interactive, six-altitude architecture
walkthrough (arch-and-mental-model.html in the source tree) and a set of design
docs covering anti-abuse, anti-cheat, deterministic replay, the simulation-core
interface, and the in-progress P2P identity spec. Those are the source of truth
for anyone working on Promenade itself.