The Seed web app is what you are reading right now. It sits in front of a daemon and serves a space at a domain as a site. It serves server-rendered pages for every document, the typed Seed API for programs, and the site services that let a desktop app register the site and let browsers sign in. It also has a small editor, so people can comment from the web with a real signature. hyper.media, every *.hyper.media site, and every self-hosted site run it.
Where the code is
frontend/apps/web, package @shm/web: Remix 2.17 on Node with Vite, React Query, the shared editor, Sentry, Tailwind. The server entry is app/entry.server.tsx, which loads .env first, then everything else. The catch-all route app/routes/$.tsx renders documents. app/routes/api.$.tsx is the Seed API. The app/routes/hm.api.* files are the site services. Run it with pnpm web against the desktop dev daemon, or pnpm web:standalone with its own daemon.
How it talks to the daemon
The server holds one gRPC-web client to DAEMON_HTTP_URL, the daemon's HTTP port. An interceptor adds Authorization: Bearer <token> when the current request carries one, so a signed-in browser can read private content it is allowed to see. Server-rendered loaders and the API both go through this client. The API also forwards the browser's own authorization header into every gRPC call it makes.
A bearer token the daemon rejects fails the request with a 401, even for public content. The page loader shows that as a server error. Clear the cookie if a site refuses to load after a key change.
Server-side rendering caches the responses of Query, QueryBlock, InteractionSummary and ListCitations for 30 seconds, 500 entries at most, in server-universal-client.ts. An entry holds the in-flight promise, so concurrent renders of one page share a single gRPC call. That client is never authenticated, so the cache only ever holds the public view. API responses to browsers carry Cache-Control: private, no-store, and there is no HTTP cache in front of the app. Citation lists and the counts in interaction summaries fetch one page of 500 citations and never more (listAllPages with maxPages in @shm/shared), because enumerating every citation of a heavily cited document held the daemon's read pool and took hyper.media down in August 2026. A document with more than 500 citations shows a truncated count until the daemon can report a count without enumerating.
Site identity and registration
A site is a daemon plus this app plus a JSON file. config.json in the data directory holds availableRegistrationSecret, and after registration also registeredAccountUid and sourcePeerId. The owner opens https://site/hm/register?secret=… and pastes the link into the desktop app's publish dialog. The desktop app posts to /hm/api/register. The site then connects to the desktop's peer and records the account. When the site and desktop share a daemon, as in local development, matching peer IDs skip the connection and resource push because the content is already local. A service-config.json variant serves several sites from one deployment with custom domains. Until registration, the site shows a "not registered" page. Sites explains the model, and Self-hosting walks through it.
Set SEED_IS_GATEWAY=true and the site becomes a gateway. A gateway serves canonical /hm/<account>/<path> URLs for any account. A normal site serves one registered space. hyper.media runs as a gateway.
Publishing a space to a domain
The first successful publication of a public space home opens an invitation to give it a web address. The owner can continue directly into Seed Hosting or reopen Web Domain from the home document's options menu later. The dialog stays open across navigation, including publication resumed after signing in through the vault. Private spaces, child documents, and updates to existing homes do not trigger the invitation.
While viewing a space you own, open the account menu and choose Space Settings to update its identity, navigation, or web domain. Settings use the desktop master/detail design: a section sidebar beside the detail pane, or a section list and back navigation on small screens. Each section has its own URL under /:settings (or /hm/<uid>/:settings on a gateway). The account-menu entry is available in both desktop and mobile browsers and is hidden when viewing someone else's space. Changes use the signed-in owner's browser delegation, including when managing the space through another site's origin.
The web and desktop publishing dialogs share their domain and hosting choices, self-hosting guide, setup-URL registration flow, and publication confirmation. To self-host, follow the setup guide and paste the server’s setup URL; no Seed Hosting login is required. The browser registers the server using the current gateway peer and signs the address update with the owner’s delegation. Public servers need HTTPS when publishing from an HTTPS page; localhost HTTP remains available for development. In the browser, Seed Hosting first tries the signed email proof from Seed Vault, then falls back to a four-digit email code when needed. Choose a free Seed address after signing in. An interrupted connection can resume without reserving another address. Custom domains include DNS instructions and status checks; address changes and hosting ownership transfers appear only for sites owned by the signed-in hosting account and registered to the current space. A hosting transfer does not transfer the Seed identity. Logging out of the web app also clears its hosting credentials and attempts to revoke the hosting session. Vault sign-in carries the email proof privately in the callback fragment and stores it with the browser identity. Older browser sessions need one fresh Vault sign-in to receive the proof. Publishing signs in automatically when a proof or hosting session is available; the account footer offers Switch account for choosing another hosting account. Hosting accepts this proof only for new accounts from a trusted vault; existing hosting accounts still require a fresh email code.
Custom-domain setup opens a full-page status view at /:settings/web-domain/<hostname> (or /hm/<space-uid>/:settings/web-domain/<hostname> on a gateway). Refreshing or returning through Space Settings → Web Domain → Open Domain Setup restores the selected domain and resumes checking with the same hosting account. The page distinguishes DNS waiting, HTTPS setup, publication updates, and completion. The browser checks every 20 seconds while open; Seed Hosting checks DNS every minute, and propagation timing depends on the DNS provider. Completed status is verified against the owned hosting domain and the space’s published address. If browser setup state is missing, Resume Domain Setup reconnects to the existing owned domain without creating another one. Signing in again preserves the setup URL; a different hosting account cannot see or finish another account’s pending setup.
Browser registration reads the current gateway's peer information from /hm/api/config and sends the registration secret directly to the hosted site's /hm/api/register. The destination site is selected from its Host or forwarded-host header, never the publishing browser's Origin, including when local development uses different ports. That endpoint supports cross-origin POST and OPTIONS without browser credentials; the secret still authorizes registration. The gateway is the source peer because browsers have no local daemon. Browser setup supports both managed Seed Hosting and self-hosted setup links. Deploy the updated registration endpoint before enabling the browser workflow on a gateway.
Routes
Route | Purpose |
|---|---|
| Server-rendered documents, with |
| The Seed API: GET queries and CBOR POST actions, dispatched over the shared typed router. See The web API. |
| Site identity: registered account, peer id, addresses, protocol id, gateway flag, notification host. |
| The site services. |
| Pages of the app itself. |
URLs explains versions, block fragments and view terms.
How a page is rendered
The loaders in app/loaders.ts resolve the URL to a document or comment, following redirects, then fill a React Query cache that the browser takes over without refetching. Each request gets its own QueryClient (app/queries.server.ts, staleTime: Infinity). The query definitions are shared with the apps in @shm/shared (models/queries.ts): queryResource, queryDirectory, queryAccount, queryQueryBlock, queryDocumentCollaborators and the rest each return a {queryKey, queryFn} that works for both prefetchQuery on the server and useQuery in a component. So the rule is simple: the server prefetches exactly the queries the components will ask for.
Prefetching runs in two waves of parallel Promise.allSettled calls, so one failure never breaks a page: first the document, the site home, both directories, the collaborators of the document and the home, and the breadcrumb documents; then, from the document's content, the payload of every query block, every embedded document and every author account. The cache is dehydrated into the loader data and restored in a HydrationBoundary. Anything missing is fetched by the browser on demand. Interaction summaries (comment and citation counts) are deliberately not prefetched: computing one enumerates every citation of the target, and prefetching thirty per page took hyper.media down on 2026-08-11. The browser fetches them for the cards on screen.
The document body itself is server-rendered as real editor markup by renderDocumentToHTML in @shm/editor/ssr-render. It converts the blocks exactly as the editor does at mount, serializes the ProseMirror document with the schema's own renderHTML through DOMSerializer in a happy-dom window, and renders the React node views (images, embeds, query blocks) with react-dom/server from the same prefetched cache. The result is cached per origin, document version and data fingerprint and injected with dangerouslySetInnerHTML. The page keeps that placeholder until the editor has mounted over it, then DocumentContentHandoff in @shm/ui lifts it two frames later, so the swap does not paint a blank frame. Because the markup is identical, read-only and editable views share one editor instance and switch without a remount.
Signing in the browser
Browser identities are Ed25519 keys generated with WebCrypto as non-extractable key pairs and stored in IndexedDB. A database migration dropped the older P-256 identities. A browser key does not own a space. It acts for an account held in the vault, through a delegated AGENT capability and a profile alias. This is the flow described in Sign in with Seed. Comments and document edits are built and signed in the browser with the SDK and posted through the PublishBlobs action. For private content the app also calls the daemon's authenticate RPC and keeps the resulting bearer token in an HTTP-only cookie.
Configuration
Variable | Meaning |
|---|---|
| The daemon's HTTP port. |
| Where |
| The site's public origin. |
| Hosting API origin, injected into the browser at runtime. Defaults to |
| Gateway mode. |
| Web signing and the vault origin used for sign-in. |
| Where |
| Admin secret for the multi-site service mode. |
| The notify service to use. |
The /hm/notifications page signs its requests in the browser with the session key and talks to the notify host that the vault callback named at sign-in, not to NOTIFY_SERVICE_HOST. Notify describes both the signed inbox and the older unsigned email subscribe form.
Working with it
CLI
The CLI reads and writes through a site's /api. It uses hyper.media unless --server names another site, and an https:// URL argument targets that site directly.
SDK
createSeedClient('https://site') talks to /api/<Key>. See The SDK.
Web API
The web API documents everything under /api and /hm/api.
Agents
Seed Agents use a site's /api as their Hypermedia backend, hyper.media by default. They read the site's /hm/api/config to learn which account it publishes.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime