Websites built from every folder's docs
Change a website, add a page to it, or translate it. Local Link has two websites built from repository pages and a shared navigation outline, so each page says the same on GitHub and on the website.
cd web
npm install
# both, www.local.link at http://localhost:3000 and docs.local.link at 3001
npm run devThe two websites
Each is a Next.js app on Vercel, and both are one package,
web/packages/site: the same reader, layout and look, and each app's routes only re-export it.
www.local.link
web/apps/home: the home page, the mission, sponsors, Hire Us, the waitlist and the legal pages, from that folder's own docs/.
docs.local.link
web/apps/docs: the documentation, from the docs/ of every folder scripts/documentation-index.json lists.
What they run on
They use Next.js 16 with the App Router, React 19, TypeScript, Tailwind CSS 4 and ESLint, and need Node 20.9 or newer. They don't run on the relay droplet, and the relay knows nothing about them.
One set of docs
These rules keep the docs one set, and cargo test in the core checks
them: every folder's links and anchors, that no link names a page by
its address on the website, and that no README.md outside a docs/ folder, nor a page in .github/, holds more than a hub.
Every fact is in a folder's docs/en/
Every file there is a page of one of the websites. Nothing is said only in the repository or only on the website.
A folder's pages are in that folder
They change in the pull request that changes its code.
link-public/integrations/agents/harnesses/openclaw/docs/en/agents/harnesses/openclaw/
is OpenClaw's page,
at /agents/harnesses/openclaw, and the core's link-public/plugins/agents/agent/docs/en/agents/ is
Agents, at /agents.
A page's path is its URL
A page's path in its folder's docs/en/ is its URL on its website,
whichever folder holds it, and the build fails if two folders of one
website hold one path. The shared navigation outline determines where the page appears in the reading tree, independently of its URL.
A README.md outside docs/ is a map of its folder
A paragraph says what the folder holds: its long description. Its ## Docs lists its pages and child folders' README hubs in shared
reading order, by their titles and short descriptions, said once where it lives (a package's Cargo.toml description, else the first
sentence of the README or page), so no parent restates it to drift.
A package's ## Code
It links its entry points, three or so, only code, each in a few words.
Any other section comes after. How anything works is said in docs/en/, and what a file does at its top.
A map where the website has none
GitHub shows a folder's README.md where the website has no page. So
do .github/'s SECURITY.md, SUPPORT.md and CODE_OF_CONDUCT.md, which point at their pages.
A folder's README.md links one level down, no further
It links its own docs, and the README.md of each folder directly in it. The workspace's links its members (harnesses/, connections/,
...), and harnesses/'s each harness. Regeneration updates the hubs and shared outline after a folder is moved or added.
Every link to a page is a relative path to its file
As in ../agent/README.md, never https://docs.local.link/..., so a
reader following links stays in the repository. The website turns each
path into the page's URL, in the reader's language, and a path to the
other website's page into its address there. Only a website itself, https://docs.local.link/, is linked by its address.
Develop
npm run dev serves both, and npm run lint && npm test && npm run build checks them.
The other website
In development a link to the other website goes to its address on this
machine, the index's local, so the two link to each other as they do when deployed.
Show it to someone
To show it to someone else, expose it through Link itself with lnk tunnel open 3000 --name site --public.
Before pushing
Run the commands below. CI runs them in .github/workflows/website.yml
on release pull requests that change any folder's docs/ or link-public/web/.
npm run lint && npm test && npm run buildIts cache
Development checks the docs' file versions once per server render and
reuses unchanged Markdown, page data and navigation indexes. Edits,
additions and deletions refresh the cache, including changes to
navigation.md and the folders the index lists.
What a page renders
Each page supplies its own headings to the sidebar; opening one never renders the other pages to collect theirs. The Geist fonts are bundled locally, so development and production builds download no fonts.
Add a page
Add a Markdown file to the docs/en/ of the folder it's about, and its
path there is its URL. Run npm run dev and refresh to see a change to the docs.
# docs.local.link/pricing
$EDITOR ../core/docs/en/pricing.md
# or as a folder: the same URL
$EDITOR ../core/docs/en/pricing/README.mdWhere it goes
A page of www.local.link's own goes in apps/home/docs/en/, and a new
folder of docs is a line in the documentation index.
Its title and description
Its first # heading is its title ("Local Link — Pricing"), and its
first paragraph is its description in search results and link previews.
Its place in the tree
Each website's docs/en/navigation.md holds its shared reading order
under ## Pages. The website renders that outline, and the README
writer follows it. Run the documentation writer
after adding or moving a page; it inserts missing entries while keeping
curated order. Move links in the outline to change their placement.
- [Agents](../../../../../plugins/agents/agent/docs/en/agents/README.md)
- [Link Harness](../../../../../plugins/agents/link/docs/en/agents/link/README.md)
- [Harnesses](../../../../../plugins/agents/harnesses/docs/en/agents/harnesses/README.md)
- [OpenClaw](../../../../../integrations/agents/harnesses/openclaw/docs/en/agents/harnesses/openclaw/README.md)A section
A page with pages under it is a section. Its line names the section
(OpenClaw), and its own page, its folder's README.md, is the first
page under it, called Get Started whatever its title, which is a pitch
of its own. The writer inserts a folder's other pages under its README
unless the outline already places them elsewhere.
The order and the words of its lines
The outline owns sibling order; use it to put the reader's most useful pages first. Labels follow page titles, and generated README indexes inherit this order. Unranked repository entries remain alphabetical.
The other website's pages
A line linking the other website's page is a tab or a page there, opened in a new tab with an arrow: www.local.link's Documentation is docs.local.link, and docs.local.link links the legal pages, which live once, on www.local.link.
A folder a release holds back
A folder scripts/documentation-index.json marks "released": false
is a plugin's a release holds back, and its pages are no page of either
website. A link to one goes to its file in the repository, and the tree
drops its line with every line under it, so a page placed only there is
held with it.
What a page may link
A link in a page is to a page, a file, or an https://, http:// or
mailto: address; any other scheme fails the build.
Interface text
Shared interface labels live in packages/site/locales/<language>.json,
with English fallback. Navigation files contain structure, and page
content supplies descriptions and share metadata.
Headings
No heading has more than ten headings under it, so group a longer list under headings one level deeper.
How a page is laid out
The tabs are pills across the top, and on a wide screen each drops down its pages. The tabs and the column are centred on any screen, the home page laid out as every other, and there is no footer: every page is in the tabs.
The tree
Beside every page is the whole site's tree, the home page's too, the
same from tab to tab, with the page you're on dark, the sections above
it in bold, its ## headings under it and the other sections folded.
Until the screen is wide, the tree folds into a bar under the tabs that
says where you are and opens in place.
Folding it away
On a wide screen a pill before the tabs folds the tree away, leaving
the page alone in the middle, and back: www.local.link starts folded,
with few pages, docs.local.link open (the index's tree), and a
browser keeps how its reader last left it.
Around a page
Above its title, a page says the sections it's in, and below its cards come the pages before and after it in its tab, each a card on one line, as wide as its words.
What the Markdown means there
The docs are plain Markdown, so they read the same on GitHub. A few things mean something more on the website:
| In the Markdown | On the website |
|---|---|
First # heading | Its title, or its tab's name if a tab |
Each ## heading | A line under the page in the tree |
| First paragraph | Its description |
--- | A break between cards |
| Lone link titled "button" | The big pill button |
| Link titled "button" in a card's words | A pill in the card |
Lone link titled "sponsors", between --- | The sponsors (Sponsors and prices) |
Lone link titled "offers", between --- | The offers, read in the browser |
| Link titled "price" in a line | Its words, then that list's price |
List of links alone between two --- | A row of pills |
sh code block | Commands with a copy button |
| Any other code block | Code |
<!-- ... --> | Hidden in both |
Relative link to a .md file | A link to its page, anchor kept |
| Link to its own website's address | That page, in the reader's language |
| Relative link to the other website's page | A link to its address there |
| Relative link outside the docs | The file on GitHub, marked as leaving |
| Link to another site | A link with an arrow after it |
Languages and builds
English is served without a prefix. /agents is
app/[lang]/[[...path]] with lang = en, and /en/agents redirects
to /agents (packages/site/src/config.ts). Every page is built once,
at npm run build. Moved documentation explains redirects for relocated pages.
Translate the docs
You write the docs in English, in each folder's docs/en/. A model
writes the other languages beside them, in docs/<language>/, and
never by hand. Each page names the English it was made from, and the
website marks a page whose English has changed since.
# from the app of the website to translate, apps/home or apps/docs
cd web/apps/docs
# what is missing or behind in docs/es/ of each folder
node ../../packages/site/scripts/translate.mjs plan
# send it as one batch, see its progress, and once it is done write docs/es/
export OPENAI_API_KEY=...
node ../../packages/site/scripts/translate.mjs submit
node ../../packages/site/scripts/translate.mjs status
node ../../packages/site/scripts/translate.mjs collectA language
A language is one this app's docs/ has a navigation.md for, whose
## URLs lists the folder and file names in that language, such as
/es/agentes. The language pill in the tabs goes to the same page in
another language.
What collect writes
collect validates page headings and code blocks, navigation links and
nesting, and UI catalog keys and English-source hashes before writing.
Invalid or stale results leave existing files intact. UI catalogs are
shared across both apps. LNK_TRANSLATE_MODEL picks the model.
Who runs it
No workflow runs this: whoever changes a folder's docs/en/ runs it,
and commits what collect wrote with that change. The Terms of
Service, the Privacy Policy, the License and Hire Us stay in English,
which binds.
Deploy (Vercel)
The websites go live with a release. A merge into main releases
lnk when it bumps the version (release.yml). The release's last
step, once it is published, calls each website's Vercel deploy hook, so
the sites show what the release does.
Only the release's commit
The hook builds main's head, so it's called only while that is still
the release's commit: a release that main moved past deploys nothing,
and the next one does. A release that fails deploys nothing, and a
change to the docs alone waits for the next release.
Previews
vercel.json turns off Vercel's own deploys of main, and branches
and pull requests still get preview URLs.
Its headers
Every page is served with headers that keep it out of another site's
frame (frame-ancestors 'none', X-Frame-Options), stop a browser
guessing a file's type (nosniff), send other sites only the site's
address (Referrer-Policy), keep it on HTTPS
(Strict-Transport-Security) and ask for no camera, microphone or
location (packages/site/src/config.ts).
Set up a website
Once for each website, in Vercel: Add New → Project, import the repository, then set its Root Directory and its deploy hook, as below.
Its Root Directory
Set Root Directory to its app, link-public/web/apps/home or
link-public/web/apps/docs in the union checkout, web/apps/... after
extracting the public workspace. vercel.json pins the Next.js
framework preset, so the default build settings are right as they are.
Without the Root Directory
A project imported from the repo root is detected as "Other" and fails
with No Output Directory named "public" found. The build reads every
folder's docs/, outside the Root Directory, so keep "Include files
outside the root directory in the Build Step" on, as it is by default.
Its deploy hook
In Settings → Git → Deploy Hooks, make a hook for main, and save
its URL as a secret of the repository's release environment:
VERCEL_DEPLOY_HOOK for www.local.link, VERCEL_DOCS_DEPLOY_HOOK for
docs.local.link. Without one, a release says it deployed nothing there.
Domain
The bare domain local.link points at the relay droplet, which owns
/_link/* (agent connections, login, TLS checks) and /install.sh,
and tunnels live on *.local.link. So the site can't simply take over
local.link DNS. It lives at www.local.link instead, and the
docs at docs.local.link.
Its DNS and redirect
A CNAME for each points at its Vercel project, and the relay redirects
browsers on the bare domain to www.local.link, through LINK_WEBSITE,
which bootstrap's --website sets. The relay's own paths stay on the
relay.
What they keep
The websites have no settings. The waitlist page links to cloud.local.link, where the pre-sale is bought, and to the free waitlist's Tally form, which keeps what people enter. The site stores nothing. The photos and the sponsors are read from a public bucket (The bucket).
The bucket
What changes without a release is in one public Google Cloud Storage
bucket, local-link-web (packages/site/src/bucket.ts), read in the
browser by every website: backgrounds/, sponsors/ and hire-us/.
Anyone may read it, from any site; only its owners write.
Backgrounds
Every page shows the day's photo, the same on every website: the
list's day-th in turn, counting days in UTC. Each photo is a folder,
backgrounds/<name>/, of full.jpg, blur.jpg, a tiny copy shown
blurred until the photo arrives, and share.jpg, which a link's share
card is made from (the list's first, at build time).
Add a photo
backgrounds/backgrounds.json lists them, with where each is centred
on a narrow screen and a wide one. A new photo is a new name; the
script makes the three images and puts them and the list up:
cd web/packages/site
node scripts/backgrounds.mjs list
node scripts/backgrounds.mjs add dunes ~/dunes.jpg --position "60% center" --wide "left center"
node scripts/backgrounds.mjs remove dunesSponsors and prices
The lists a page reads in the browser are linked from it, alone between
two ---, titled which list it is. A browser keeps a list for up to
five minutes.
{"price": "$100 a month",
"sponsors": [
{"name": "Acme", "url": "https://acme.example", "logo": "logos/acme.png",
"line": "GPUs by the hour, near your users", "category": "Cloud",
"since": "2026-10-12", "until": "2026-12-11"}
]}Sponsors
sponsors/sponsors.json, the logos beside it, is read by
Sponsors's link
titled "sponsors". A sponsor shows from its since through its
until, in the order they joined, grouped by category once there are
two. Its price, where a sponsor's price starts, is shown in Become a
Sponsor's text
after the words of its link titled "price", wherever a page puts one.
A sponsor's entry
A sponsor's name is at most 30 characters and its line 80. A logo is
square: an SVG, or a PNG from 512 to 1024 pixels wide, shown at 128 on
a white tile. until is left out until a sponsor stops, then set to 30
days after their last payment, or to the day they asked to come down.
Offers
hire-us/offers.json is read by Hire
Us's link titled
"offers": each a name, what it includes and its price, as a table.
Sponsors' words, and the offers', are shown as given, never translated.
{"offers": [
{"name": "Your own agent, set up", "what": "on your Mac and in your cloud", "price": "$500"}
]}Validate the examples
The website's catalog-schemas.ts owns the runtime shapes of sponsors,
offers and prices. Documentation JSON binds to those same constants, and
website tests read the published examples through the runtime validator.
Change the schema, examples and owning readers together.
Change the lists
Copy the lists down, edit them, check them, and put them back up; the check reads every name, line, link, date, logo and price first.
cd web/apps/home
# a copy of the lists, as they are, without the photos
gcloud storage rsync --recursive --exclude="backgrounds/.*" gs://local-link-web bucket
$EDITOR bucket/sponsors/sponsors.json bucket/hire-us/offers.json
# names, lines, links, dates, logos and prices, before anyone sees them
node scripts/bucket.mjs check bucket
gcloud storage rsync --recursive --exclude="backgrounds/.*" --cache-control="public, max-age=300" bucket gs://local-link-webLayout
Page prose stays in each member's docs/; shared catalogs hold interface
labels. packages/site/src/docs.ts reads those pages through the
index's sites and renders each app's navigation.md outline.
Decisions explains the ownership and rendering choices.
No Rust here
web/ holds no Rust, and the core no TypeScript. npm test checks the
docs cache against edits, additions, deletions, source-folder changes
and translations.
The Turbo cache
The Turbo cache includes the documentation index, component pages, shared website pages and the repository's documentation guides. A page edited in any of those roots invalidates the websites' builds, even when an app's source is unchanged.
Docs
- Decisions: Why www.local.link and docs.local.link are built the way they are
- Moved Documentation: Permanent redirects preserve existing documentation links when a page moves to a new address, including its child pages and language-prefixed addresses