Written agent-side, operator-reviewed in the wiki UI. Facts verified live (build 2.11.1). Feedback below the line is operator-owned.
Story 1 — Obsidian stays; the agent era changed the problem
We used — and still use — a desktop vault for quick notes; that tool remains our first grab. But something new entered the picture: coding agents now run at 100x speed inside TUIs, creating tons of documentation while the operator interacts with them remotely. At that speed you need something different for two moments:
- early design stages — ideas, drafts, checklists the agent consults and updates;
- completion/review — reading docs and artifacts comfortably.
For both, the requirement is the same: private, in a browser, readable, and friendly to pasted material — you can drop a chunk straight from ChatGPT/Gemini into it — and the agent can pull those pages back into a session on demand. So instead of building a tool, we borrowed one: SilverBullet, added a few things here and there for our needs (mirrors, machine API flows, editorial statuses), and made it part of how every lane works.
Story 2 — the disk got expensive, so the vault had to move
Agents also generate volume. Docs outgrew the boot disk; the natural move was pushing data to an external/remote drive — which brought its own problems: two different filesystems (with the quirks we’d have to work around), and restarts whenever mounts lag. Rather than accept “restart-and-pray”, the answer became logic inside the container: a tiny wrapper that picks the right data root at every boot, falls back to a local copy when the big disk is lagging, and merges the leftovers back the moment the big disk returns. Zero babysitting; tested by actually pulling the disk away.
Story 3 — the restart era, and the wheel we didn’t reinvent
The early days: every new space meant editing a registry file and restarting the container — annoying at one space a week, unacceptable at one a day. Before inventing anything, we read the source: the feature already existed (an admin API that manages spaces hot, no restarts). The restart era ended by reading, not by building.
What those stories distilled into (the five rules)
- Two content classes. Repo-owned docs (git-canonical, mirrored read-only) vs wiki-native pages (dashboards, drafts, statuses). Who owns a page is never ambiguous.
- Humans use page URLs; agents use the file API. The server itself enforces the split — unknown paths serve the UI shell, data lives under the file API — agents never scrape HTML, humans never see JSON.
- Publish with conflict-safety. Writes carry
If-Matchwith the ETag last seen; a concurrent edit returns412, the writer re-reads and retries. Nobody’s keystrokes get clobbered. - Every change is auditable by default. Managed revisions keep a git-style commit log per space; machine publishes and human edits land in one trail, diffable in the UI.
- Navigation is a mirror-time obligation. A docs-dump without links and a landing hub is a navigation desert; readability is fixed where copies are made, not in the canonical repo.
The deployment (compose + built-in failover)
services:
silverbullet:
image: ghcr.io/silverbulletmd/silverbullet:latest
container_name: silverbullet
restart: unless-stopped
volumes:
- /mnt/bigdisk/wiki/data:/data-primary # fast big disk
- ./data-fallback:/data-fallback # boot-disk fallback copy
- ./pick-data.sh:/pick-data.sh:ro
ports:
- "3000:3000"
environment:
- PUID=1000
- PGID=1000
entrypoint: ["/bin/bash", "-e", "/pick-data.sh"]
The image’s own entrypoint is preserved (the wrapper execs
/docker-entrypoint.sh after choosing the data root), so PUID/PGID
drop-privileges works as upstream intends.
The pick wrapper — the data-root resolver that re-decides at every start:
#!/bin/bash -e
D=/data-primary
if [ ! -f /data-primary/spaces.json ]; then
D=/data-fallback
echo "WARN: primary disk not ready -> FALLBACK data root"
elif [ -f /data-fallback/.ACTIVE-fallback ]; then
echo "reattach: merging fallback leftovers into primary (cp -ru)"
cp -ru /data-fallback/. /data-primary/
rm -f /data-fallback/.ACTIVE-fallback /data-primary/.ACTIVE-fallback
fi
[ "$D" = /data-fallback ] && touch /data-fallback/.ACTIVE-fallback
export SB_FOLDER="$D"
exec /docker-entrypoint.sh "$@"
Behavior: big disk healthy → serve from it; disk lags (spinning-up NAS mounts do this) → the container falls back to a local copy and flags it; next healthy start merges the leftovers back before SilverBullet reads a page. Fire-tested by removing the disk: the merge showed up in logs, ~10s downtime.
Two bind-mount lessons learned the hard way:
- A mount does not refresh when the host path underneath is
unmounted/remounted; recovery is a container recreate, not
restart. - When a bind source path is missing, dockerd creates it — and chowns it — itself. On filesystems without POSIX ownership (exFAT) that fails outright: pre-create the bind source directories manually.
The machine surface (recipes, not theory)
Auth: a personal access token (a secret token minted per user) for the space data API, but the dashboard admin API wants a session cookie (bearer gets 403):
curl -c jar.txt -X POST "$SB/$SPACE/.auth" --data "username=$U&password=$P"
Read side:
# list a space's files as JSON (name/size/lastModified/perm/etag)
curl -H "Authorization: Bearer $T" -H "X-Sync-Mode: true" "$SB/$SPACE/.fs"
# read one page, raw markdown
curl -H "Authorization: Bearer $T" "$SB/$SPACE/.fs/notes/idea.md"
Write side — publish with conflict-safety:
# current etag
ETAG=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $T" \
-H "X-Get-Meta: true" "$SB/$SPACE/.fs/notes/idea.md" \
| grep -i '^etag:' | tr -d '"' | cut -d' ' -f2)
# conditional write — 412 if a human edited meanwhile
curl -H "Authorization: Bearer $T" -H "If-Match: \"$ETAG\"" \
-H "Content-Type: text/markdown" -X PUT --data-binary @new.md \
"$SB/$SPACE/.fs/notes/idea.md"
The audit trail is built in:
# space-wide commit log; or the exact diff of one file
curl -H "Authorization: Bearer $T" -H "X-Sync-Mode: true" \
"$SB/$SPACE/.revisions/notes/idea.md?format=diff"
Dynamic spaces live under the dashboard admin API and are hot (no restart; the server rewrites its own registry file):
curl -b jar.txt "$SB/.dashboard/api/admin/spaces"
curl -b jar.txt -X POST -H "Content-Type: application/json" \
-d '{"name":"ops","folder":"spaces/ops","binding":{"prefix":"/ops"},
"indexPage":"INDEX","revisions":"managed"}' \
"$SB/.dashboard/api/admin/spaces"
A trap that taught us the shape of the API: every unknown URL returns the UI’s HTML shell with HTTP 200, so probes “look alive” while serving no data. Page URLs are for humans, the file API is for agents — never judge an endpoint by a 200.
The mirror pipeline (repo docs → readable wiki)
Repo docs are written for terminal readers: 80-column hard wraps, names in backticks, placeholder hosts, no hub. Copied verbatim into a wiki they render as a navigation desert. Three small passes run after each sync turn; the canonical repo is never touched:
| Class | What it holds | Copied via |
|---|---|---|
| A | clean mirror of repo docs | rsync + passes |
| B | scrubbed mirror (attack-map repos) | scrub pass + published manifest |
| C | native pages (boards, drafts) | not a copy at all |
- Unfold — joins hard-wrapped paragraphs into flowing lines; protects fences, tables, lists, blockquotes; provably convergent.
- Host-fix — placeholders to real origins inside the mirror only; the repo keeps its publish-safe alias.
- Make-links — filename references become wiki links, only when the target exists in that space; fenced code untouched; one doc set gained 700+ working references (including a 187-cross-reference glossary).
- The landing page — every mirror gets an authored INDEX hub; the one mistake we made first, then fixed everywhere.
- Sync contract — dry-run by default; deletions in mirrors are manual and deliberate; content never requires a restart.
For class-B mirrors (repos mapping live infrastructure), the scrub pass enforces the repo’s own secret list (bucket names, domains, host aliases, LAN IPs, credentials, paths, ports) and publishes a per-file manifest on the landing page — safe by construction, not by review.
What we’d flag upstream
- The multi-space admin API is undocumented; the community still teaches “edit registry + restart”.
- Bearer tokens rejected on admin routes (cookie-only) — scoped tokens would be cleaner.
- exFAT data roots: permission warnings every boot; the dockerd bind-source chown failure above.
- No reader mode: single newlines render as hard breaks, so 80-column sources fragment — we mirror-transform as a workaround.
- The
/.shellRPC plus a broad token is a dangerous pair; ask for scoping before automating more. - v2 moves fast; an informal stable-API promise would help builders.
References
- SilverBullet multi-space release notes: https://no.silverbullet.plus/2-10
- Docs: https://docs.silverbullet.md (Space Style, Spaces)
- Community: hosting multiple spaces — https://community.silverbullet.md/t/hosting-multiple-spaces/4124
- The admin API in source: https://github.com/silverbulletmd/silverbullet (server/src/multi/admin_api.rs)