diff --git a/apps/loopover-ui/.gitignore b/apps/loopover-ui/.gitignore
index d24df8a769..8f804d8993 100644
--- a/apps/loopover-ui/.gitignore
+++ b/apps/loopover-ui/.gitignore
@@ -16,6 +16,9 @@ dist-ssr
.nitro
*.local
+# fumadocs-mdx generated collection output (source.config.ts -> content/docs)
+.source/
+
# Wrangler / Cloudflare
.wrangler/
.dev.vars
diff --git a/apps/loopover-ui/content/docs/ai-summaries.mdx b/apps/loopover-ui/content/docs/ai-summaries.mdx
new file mode 100644
index 0000000000..4098925661
--- /dev/null
+++ b/apps/loopover-ui/content/docs/ai-summaries.mdx
@@ -0,0 +1,48 @@
+---
+title: Optional AI summaries
+description: A short natural-language summary over the deterministic response. Off by default. Never the source of truth.
+---
+
+## The rule
+
+LoopOver is deterministic. When AI summaries are enabled, they sit _on top of_ the structured
+response — they never replace it, never add facts that aren't in the response, and never change
+ranked actions, blockers, or scoreability numbers.
+
+## Where they appear
+
+- In the `/app/playground` tool runs, behind an opt-in "Include AI summary" toggle, above the
+ JSON.
+- As an optional AI-clarified rewrite of the public PR intelligence comment, gated server-side by
+ `AI_PUBLIC_COMMENTS_ENABLED` and always falling back to the deterministic comment body on any
+ error, quota limit, or unsafe output.
+- Never in maintainer packets without explicit maintainer opt-in.
+
+
+ The playground's toggle currently renders a local, deterministic preview of the structured
+ response — it does not call the backend AI summary service described below yet. Treat it as a
+ stand-in for what a wired-up summary would look like.
+
+
+## What is sent to the model
+
+A compacted signal bundle — the run's objective, actor login, surface, status, and data quality,
+plus up to five ranked actions (kind, recommendation, why, blockers) and up to eight freshness
+warnings. For a public rewrite, scoreability/risk fields are stripped before the bundle is built,
+not filtered out of the model's response after the fact.
+
+No source code, no PAT, no GitHub identity beyond the acting login, and no per-user history beyond
+the current run are sent.
+
+## Model choice
+
+There is no per-user or per-session model picker. The operator configures one AI provider for the
+whole instance — see [self-hosting AI providers](/docs/self-hosting-ai-providers) for the
+Codex/Claude Code/Ollama/OpenAI-compatible/Anthropic options. Summaries are off by default
+(`AI_SUMMARIES_ENABLED`); public-comment rewriting is a separate, also-off-by-default switch
+(`AI_PUBLIC_COMMENTS_ENABLED`).
+
+
+ **Never the source of truth.** If the summary disagrees with the structured response, trust the
+ structured response. The summary is a convenience layer, never an authority.
+
diff --git a/apps/loopover-ui/content/docs/beta-onboarding.mdx b/apps/loopover-ui/content/docs/beta-onboarding.mdx
new file mode 100644
index 0000000000..57bdd87469
--- /dev/null
+++ b/apps/loopover-ui/content/docs/beta-onboarding.mdx
@@ -0,0 +1,175 @@
+---
+title: Beta onboarding by role
+description: "Pick the lane that matches you. Each path ends in a concrete first win — install, configure, or read a report — without treating LoopOver as an official Gittensor product surface."
+---
+
+
+ **Product positioning.** LoopOver is a deterministic base-agent and control-plane layer for the
+ Gittensor ecosystem. It is [jsonbored/loopover](https://github.com/jsonbored/loopover),
+ independent of [entrius/gittensor](https://github.com/entrius/gittensor). Use it to plan work,
+ preflight branches, and keep GitHub review surfaces quiet — not as an official Gittensor frontend,
+ wallet UI, or payout dashboard.
+
+
+## Miner journey
+
+Miners and contributors use the local MCP package. Source contents stay on your machine but
+branch metadata (such as branch names, SHAs, changed file paths, commit messages, validation
+details, labels, body text, linked issues, and scenario notes) is sent to authenticated
+LoopOver MCP/API responses for analysis and packet preparation.
+
+**1. Install the MCP.** Global install or `npx` — see [Quickstart](/docs/quickstart).
+
+
+
+**2. Sign in.** GitHub Device Flow — no PAT storage.
+
+
+
+**3. Run diagnostics.** Confirms API reachability, auth, source-upload posture, and optional
+local score-preview wiring.
+
+
+
+**4. Plan next work.** Ranked actions, lane context, and blockers — copilot-only; does not open
+PRs or post comments.
+
+
+
+**5. Preflight the branch.** Branch blockers, queue pressure, and maintainer-fit notes before
+you push.
+
+
+
+**6. Prepare a public-safe packet.** Maintainer-readable PR description with no private scoring
+language.
+
+
+
+Wire the same tools into Codex, Claude Desktop, or Cursor via
+[MCP client setup](/docs/mcp-clients). Signed-in miners can also use the
+[Workbench](/app/workbench) and [Miner dashboard](/app/miner) in the control panel.
+
+## Maintainer journey
+
+Maintainers self-host the review stack and install a GitHub App, configure per-repo policy,
+preview what could appear on a confirmed-miner PR, then pull context on demand.
+
+**1. Self-host, then install your own App.** Choose repositories and approve permissions —
+default posture is silence. Start with
+[self-hosting setup](/docs/maintainer-self-hosting), which covers the
+direct App's install checklist, then [GitHub App configuration](/docs/github-app) for the
+review behavior (PR panel, checks, gate modes).
+
+**2. Configure settings.** Opt in to at most one configured label and one sticky sanitized
+comment per confirmed-miner PR. Tune repo policy in installation settings or via the API.
+
+**3. Preview the public surface.** Dry-run what would be written to GitHub without mutating
+state. Keep **LoopOver Context** advisory; require **LoopOver Orb Review Agent** only after
+blocking rules are explicitly configured.
+
+
+
+The signed-in [Maintainer console](/app/maintainer) and [Repos](/app/repos) tab surface the
+same preview diff when live data is available.
+
+**4. Use maintainer commands.** On-demand context in the PR thread — output stays
+maintainer-scoped when appropriate.
+
+
+
+Deeper workflow: [Maintainer workflow](/docs/maintainer-workflow). Privacy rules:
+[Privacy & security](/docs/privacy-security).
+
+## Repo owner journey
+
+Repo owners care about registration readiness and sensible `.loopover.yml` configuration before
+promoting labels or maintainer-cut policy.
+
+**1. Run a readiness report.** Blockers, warnings, recommended registration mode, and issue
+policy — private API only.
+
+
+
+**2. Review config guidance.** Recommended config diff with reasons and tradeoffs — apply via PR
+when ready.
+
+
+
+**3. Use the control panel.** Open [Repository owner](/app/owner) (or the Owner tab under
+[Repos](/app/repos)) to inspect the same signals with a live repo selector after you sign in
+with GitHub.
+
+Readiness is separate from upstream drift: a repo can look ready while Gittensor rules are stale.
+Check [Upstream drift](/docs/upstream-drift) when you change scoring assumptions.
+
+## Operator journey
+
+Operators watch deployment health, product usage, value rollups, and upstream drift across
+installations. These surfaces are private and authenticated — never mirrored to public GitHub
+comments.
+
+**1. Open usage & value.** Weekly rollups, activation status, and noise-reduction metrics in the
+control panel.
+
+[Operator dashboard](/app/operator) — backed by `GET /v1/app/operator-dashboard`.
+
+**2. Read the weekly value report.** Summary lines plus rollup freshness and warnings when
+backfills lag or fidelity degrades.
+
+**3. Check drift status.** Compare ruleset snapshots and signal fidelity before trusting miner or
+maintainer guidance.
+
+
+
+When drift is not `current`, treat MCP and API responses as tied to the printed ruleset version.
+See [Upstream drift](/docs/upstream-drift) for semantics.
+
+## Public vs private boundaries
+
+Public GitHub output must never include wallets, hotkeys, payout or reward estimates, raw trust
+scores, public score estimates, private reviewability details, or farming language. Private MCP,
+API, and control-panel surfaces may show authenticated scoreability, blockers, projections, and
+evidence — framed as guidance, not guaranteed outcomes.
+
+
+ If you are unsure whether copy belongs on a PR thread, start with a maintainer packet or MCP
+ preflight. Public comments stay advisory and sanitized; richer context stays in private channels.
+
+
+## Next steps
+
+- Miners: [Quickstart](/docs/quickstart) → [Miner workflow](/docs/miner-workflow)
+- Maintainers: [Self-hosting](/docs/maintainer-self-hosting) →
+ [GitHub App](/docs/github-app) → [Maintainer workflow](/docs/maintainer-workflow)
+- Repo owners: [Owner console](/app/owner) + [Privacy & security](/docs/privacy-security)
+- Operators: [Operator dashboard](/app/operator) + [Upstream drift](/docs/upstream-drift)
diff --git a/apps/loopover-ui/content/docs/branch-analysis.mdx b/apps/loopover-ui/content/docs/branch-analysis.mdx
new file mode 100644
index 0000000000..6d4c33d145
--- /dev/null
+++ b/apps/loopover-ui/content/docs/branch-analysis.mdx
@@ -0,0 +1,59 @@
+---
+title: Branch analysis
+description: LoopOver analyzes branches using metadata only. Your source code never leaves your machine.
+---
+
+## Inputs
+
+- Repository identity (owner/repo).
+- Branch, base, and head refs.
+- Changed-file _metadata_ — paths, sizes, line counts.
+- Labels and linked issues.
+- Commit messages.
+- Validation summaries (lint/test outcomes, not logs).
+- Optional local scorer output.
+- User-supplied scenario assumptions.
+
+## Outputs
+
+- Lane context (maintainer / contributor / hybrid).
+- Role context for your account.
+- Scoreability scenarios (see [Scoreability](/docs/scoreability)).
+- Branch blockers and account/queue blockers.
+- Maintainer-fit notes.
+- Public-safe PR packet preview.
+- Ranked next actions.
+
+## Example invocation
+
+
+
+The request body is a strict schema — every field must match a known key exactly (`repoFullName`
+not a nested repo object, `changedFiles` with `additions`/`deletions`, `linkedIssues` not
+`linked_issues`) and unknown keys are rejected rather than ignored.
+
+
+ File **metadata** is allowed (path, line counts). File contents are not requested, accepted, or
+ stored. The MCP enforces this on the client.
+
diff --git a/apps/loopover-ui/content/docs/github-app.mdx b/apps/loopover-ui/content/docs/github-app.mdx
new file mode 100644
index 0000000000..5edead8245
--- /dev/null
+++ b/apps/loopover-ui/content/docs/github-app.mdx
@@ -0,0 +1,245 @@
+---
+title: GitHub App configuration
+description: Install a LoopOver GitHub App on a repo so it reviews your pull requests, then choose whether it should stay advisory or enforce repo-configured PR quality rules.
+---
+
+Once installed, a **LoopOver GitHub App reviews every pull request** on the repos you select.
+Self-hosting your own App (see [self-hosting setup](/docs/maintainer-self-hosting)) is the only
+currently available path. Each review produces two surfaces: the **LoopOver Orb Review Agent**
+check run (and the advisory **LoopOver Context** check), and a single review comment posted by
+`loopover[bot]` that updates in place as the PR evolves. The review behavior below this page's
+Install section (PR panel, checks, gate modes, config-as-code) is the same regardless of which
+connection mode your self-hosted App uses.
+
+## Install
+
+**Self-hosting is the only currently available path.** Run the review stack yourself, then
+install your own GitHub App on exactly the repos you choose using the self-host setup wizard. The
+direct App's required permissions and events are covered in
+[GitHub App and Orb](/docs/self-hosting-github-app) — use that page's checklist for a self-hosted
+install.
+
+
+ LoopOver previously ran a private managed-beta shared App that repo owners could install directly,
+ without self-hosting. That shared install path is currently paused while self-hosted Orb is the
+ primary way to run LoopOver — a new centrally hosted offering is planned for the future. If you
+ previously used the shared managed-beta App, see [self-hosting
+ setup](/docs/maintainer-self-hosting) to move your repos over.
+
+
+## First 10 minutes
+
+**1.** Install the app on one test repository first.
+
+**2.** Confirm the installation appears in the private API, then open its health record.
+
+
+
+**3.** Check repo readiness before enabling public output.
+
+
+
+**4.** Preview the exact public surface without posting to GitHub.
+
+
+
+**5.** Leave **LoopOver Context** advisory while you tune copy and settings. Make
+**LoopOver Orb Review Agent** required only after the repo explicitly enables blocking rules.
+
+## Default posture
+
+LoopOver is advisory-first. Public comments, labels, the Context check, and the review-agent
+check are controlled per repo. Missing issue links, non-Gittensor contributors, busy queues, and
+weak overlap signals do not block merge by default.
+
+## PR panel
+
+The PR panel is the review comment the LoopOver app posts on each pull request. It is one sticky
+comment authored by `loopover[bot]` that updates in place — the app edits the same comment instead
+of adding new ones. It shows a public-safe readiness score, concrete signal evidence, and short
+actions for linked issues, related work, review load, validation evidence, open PR queue,
+contributor context, and Gate result.
+
+By default the comment is posted only to detected contributors (`commentMode` is
+`detected_contributors_only`). Set `commentMode` to `all_prs` to comment on every PR, or `off` to
+suppress the comment entirely. Operators who have rolled the deployment onto the unified review
+comment (see below) get the single in-place comment shape; otherwise the legacy multi-panel
+comment is used unchanged.
+
+## Checks
+
+The LoopOver app publishes its review as check runs. **LoopOver Orb Review Agent** is the gate
+result, controlled by `reviewCheckMode` (`required` / `visible` / `disabled`). **LoopOver
+Context** is the separate advisory companion, controlled by its own `checkRunMode` (`off` /
+`enabled`) — these two switches are independent axes, not one setting for both checks.
+`checkRunDetailLevel` (`minimal` / `standard`) only affects **LoopOver Context**'s output depth;
+it has no effect on the Orb Review Agent check.
+
+**LoopOver Context** is advisory and should not be required in branch protection. **LoopOver Orb
+Review Agent** is opt-in and can be made required after a repo owner chooses blocking rules.
+
+Branch protection should require **LoopOver Orb Review Agent** only after the repo has verified
+installation health, previewed the public panel, and configured at least one `block` rule. Do not
+require **LoopOver Context**; it is there to inform reviewers, not stop merges.
+
+## Gate modes
+
+The deterministic gate is the heart of the LoopOver review. Its master switch is
+`reviewCheckMode` (`required` / `visible` / `disabled`); each dimension then refines an
+already-enabled gate with a tri-state mode — `off` (not evaluated), `advisory` (surfaced, never
+blocks), or `block` (can become a hard **LoopOver Orb Review Agent** blocker). A configured
+blocker fails the gate for any author identically — confirmed-Gittensor-contributor status does
+not change _who_ can be blocked; it's carried through only for on-chain scoring, a separate
+concern from the gate's own merge/close decision.
+
+- `linkedIssueGateMode` — linked-issue check. Default `advisory`.
+- `duplicatePrGateMode` — duplicate / superseding PR detection. Default `block`.
+- `qualityGateMode` + `qualityGateMinScore` — the PR-quality score gate. Default `advisory`; only
+ blocks when set to `block` with a configured min score.
+- `slopGateMode` + `slopGateMinScore` — the deterministic anti-slop signal. Default `off`;
+ `advisory` surfaces the slop score and warnings, `block` also hard-blocks at or above the min
+ score (engine default band `60`).
+- `mergeReadinessGateMode` — composite merge-readiness gate. Default `off`.
+- `manifestPolicyGateMode` — makes the repo manifest's declared policy (blocked paths, required
+ linked issue, test expectations) enforceable. Default `off`.
+- `aiReviewMode` — AI review. Default `off`; `advisory` posts AI review notes only, `block` lets a
+ dual-model high-confidence consensus defect become a blocker.
+- `sizeGateMode` — PR-size hold. Default `off`; a PR at or above the configured file/line
+ thresholds is held for manual review rather than auto-merged, never a hard failure.
+- `lockfileIntegrityGateMode` — lockfile-tamper-risk check (a resolved/integrity change with no
+ matching version bump, or a dependency pointed off the npm registry). Default `off`.
+- `claGateMode` — CLA / license-compatibility gate. Default `off`.
+- `selfAuthoredLinkedIssueGateMode` — flags or blocks a PR whose author also opened the linked
+ issue. Default `advisory`.
+- `linkedIssueSatisfactionGateMode` — an AI assessment of whether the PR's diff actually satisfies
+ its primary linked issue's intent, distinct from `linkedIssueGateMode` (which only checks a link
+ exists). Default `off`; `advisory` renders in the review comment without blocking, `block`
+ additionally lets a confidence-floor-passing "unaddressed" verdict become a blocker.
+- `moderationGateMode` — whether the moderation-rules engine (contributor cap, blacklist,
+ review-nag feeding a shared cross-repo violation tally) runs on this repo. Default `inherit`
+ (defers to the instance-wide default); `off`/`enabled` force it per repo.
+
+The policy pack (`gatePack`) selects which rule set runs: `gittensor` (registry-aware, tracks
+confirmed-Gittensor-contributor status for scoring) or `oss-anti-slop` (the deterministic rules
+against any author on any repo, with no confirmed-contributor tracking at all).
+
+## Configure as code (`.loopover.yml`)
+
+Every setting can be committed to `.loopover.yml` at the repo root instead of, or layered over,
+the dashboard. Precedence is `.loopover.yml` > repository settings > safe defaults; an unset
+field falls back to the next layer. It only chooses _what_ LoopOver does — a configured blocker
+gates every author identically, regardless of config.
+
+
+
+Maintainer-supplied footer and note text is dropped if it contains forbidden public language
+(reward, score, wallet, hotkey, payout, etc.); the Gittensor attribution and register link always
+remain on the footer.
+
+The per-repo settings above choose _what_ LoopOver does on each PR. The next section covers the
+deployment-wide capability switches that turn whole review features on or off.
+
+## Review capability flags (`LOOPOVER_REVIEW_*`)
+
+Beyond per-repo settings, operators turn whole review _capabilities_ on or off with the
+`LOOPOVER_REVIEW_*` worker environment variables. Every flag defaults to **OFF**: when a flag is
+off its code path is inert and the review behaves exactly as if the feature did not exist.
+"Truthy" is one of `1`, `true`, `yes`, or `on`. You roll capabilities forward — and back — one
+flag, and one repo, at a time.
+
+
+ Per-PR features require **two** conditions: the capability flag is on _and_ the repo is listed in
+ `LOOPOVER_REVIEW_REPOS`. With an empty repo allowlist every per-PR feature stays dormant for
+ everyone, no matter the global flags.
+
+
+- `LOOPOVER_REVIEW_REPOS` — per-repo cutover allowlist. Comma-separated `owner/repo` names that
+ may run the per-PR features. Add repos one at a time to roll forward; remove to roll back.
+- `LOOPOVER_REVIEW_SAFETY` — safety scan: defangs untrusted PR title/body/diff (prompt-injection
+ neutralization) before the reviewer sees it, and surfaces a `secret_leak` blocker for leaked
+ secrets in the diff. Per-PR.
+- `LOOPOVER_REVIEW_GROUNDING` — grounds the AI reviewer with the PR's finished CI status and the
+ full post-change content of the changed files, so the model verifies its claims against
+ reality. Per-PR.
+- `LOOPOVER_REVIEW_RAG` — retrieval-augmented context: appends semantically related code/docs from
+ the codebase vector index to the reviewer prompt. Per-PR; inert until a `VECTORIZE` index exists
+ for the repo.
+- `LOOPOVER_REVIEW_REPUTATION` — submitter-reputation spend control: downgrades a new / burst /
+ low-reputation submitter to a deterministic-only review. Internal-only, never surfaced publicly.
+ Per-PR.
+- `LOOPOVER_REVIEW_OPS` — read-only observability: a cron anomaly scan over your own
+ review-outcome data plus a bearer-gated stats aggregate. Global (not scoped by the repo
+ allowlist).
+- `LOOPOVER_REVIEW_SELFTUNE` — self-improvement loop that computes tuning recommendations from
+ review outcomes, shadow-soaks any strictly-tightening recommendation, and can _only ever
+ tighten_ the gate. Global.
+- `LOOPOVER_REVIEW_CONTENT_LANE` — routes content repos (curated lists, registries) through the
+ dedicated content lane instead of the code gate. Global.
+- `LOOPOVER_REVIEW_DRAFT` — public draft-submission flow (contributor draft → GitHub OAuth → fork
+ PR). Global; also needs the draft secrets set.
+
+A safe rollout for a per-PR feature is two flips: set the capability flag truthy, then add the
+repo to `LOOPOVER_REVIEW_REPOS`. Because both must hold, a capability can stay globally enabled
+while remaining dormant everywhere except the repos you have explicitly added.
+
+
+
+## Dogfood mode
+
+For repos like `JSONbored/loopover` and `awesome-claude`, enable PR comments, labels, Context, and
+Gate together to test the full product surface. If another maintainer agent can merge quickly,
+configure that agent to wait for `LoopOver Orb Review Agent` before merge or close.
+
+## Install diagnostics
+
+After installing, verify your install health from the API. The readiness endpoint separates
+service health from data quality.
+
+If the install route changes, check the deployed `GITHUB_APP_SLUG` before publishing setup copy.
+Self-hosted deployments use whatever slug you chose during setup.
+
+New maintainers should start with [self-hosting setup](/docs/maintainer-self-hosting), then
+continue with [Maintainer workflow](/docs/maintainer-workflow) or the
+[beta onboarding checklist](/docs/beta-onboarding) after the health endpoint reports clean
+permissions and events.
+
+
+ LoopOver's GitHub App never requests source push, never stores repository contents, and never
+ publishes wallet, hotkey, payout, trust, reward, or private scoring language.
+
diff --git a/apps/loopover-ui/content/docs/how-reviews-work.mdx b/apps/loopover-ui/content/docs/how-reviews-work.mdx
new file mode 100644
index 0000000000..ae3069c898
--- /dev/null
+++ b/apps/loopover-ui/content/docs/how-reviews-work.mdx
@@ -0,0 +1,119 @@
+---
+title: How reviews work
+description: What LoopOver does when a pull request opens — the gate, the dual-AI review and consensus, and the single comment that surfaces it.
+---
+
+## The shape of a review
+
+When a pull request opens or updates, **LoopOver CI** runs a review in two layers and reports the result in one place:
+
+1. **The gate** — a deterministic pass that never asks an AI. It runs a fixed set of rules (duplicates, linked issues, merge-readiness, anti-slop, manifest policy) and each rule is `off`, `advisory`, or `block`.
+2. **The AI review** — a dual-model read of the diff that writes review notes and, when you opt in, lets a high-confidence _consensus_ become a blocker.
+
+comment on the PR, plus an optional **LoopOver Orb Review Agent** check run. The review algorithm is open-source; what changes between repos is the configuration you tune. See [Tuning your reviews](/docs/tuning) for the review options and defaults.
+
+
+ Defaults are quiet. With no settings and no `.loopover.yml`, the gate is `off`, AI review is
+ `off`, and the comment is posted only to detected contributors. Every capability is an explicit
+ opt-in.
+
+
+## 1. The gate: advisory vs. block
+
+The gate is deterministic — same inputs, same verdict, no model in the loop. Its master switch is `reviewCheckMode` (`required` / `visible` / `disabled`). Once enabled, each _dimension_ is independently set to one of three modes:
+
+- `off` — the dimension is not evaluated at all.
+- `advisory` — the finding is **surfaced** in the comment, but it never blocks the merge.
+- `block` — the finding can become a hard **LoopOver Orb Review Agent** blocker.
+
+A `block` outcome fails the gate for any author identically — confirmed- Gittensor-contributor status doesn't change _who_ can be blocked, only the mode chooses _which_ checks are active. Confirmed status is carried through for on-chain scoring, a separate concern from the gate's own merge/close decision.
+
+### The gate dimensions
+
+These are the deterministic rules the gate runs, with their default modes:
+
+- **Duplicate-PR gate** (`duplicatePrGateMode`, default `block`) — detects duplicate or superseding PRs.
+- **Linked-issue gate** (`linkedIssueGateMode`, default `advisory`) — checks the PR references an issue, as strongly as `linkedIssuePolicy` asks.
+- **Quality / merge-readiness score gate** (`qualityGateMode`, default `advisory`) — the PR-quality score; passes at or above `qualityGateMinScore`.
+- **Slop gate** (`slopGateMode`, default `off`) — the deterministic anti-slop signal. `advisory` surfaces the slop score and warnings; `block` also hard-blocks at or above `slopGateMinScore` (engine default band `60`).
+- **Copycat / plagiarism gate** (`copycatGateMode`, default `off`) — a code containment/similarity check against prior art (earlier open or recently merged PRs on the same repo). Escalating tiers: `warn` surfaces an advisory finding only; `label` also applies a label; `block` also closes the PR and counts toward the repeat-offender strikes ledger. The direction is always by submission timestamp, so the earlier (original) author is never flagged.
+- **Merge-readiness gate** (`mergeReadinessGateMode`, default `off`) — a composite readiness check.
+- **Manifest-policy gate** (`manifestPolicyGateMode`, default `off`) — when `block`, the repo's declared policy (required linked issue and test expectations) becomes enforceable. Manual-review path holds are controlled separately by `settings.hardGuardrailGlobs`.
+- **PR-size hold** (`sizeGateMode`, default `off`) — a PR at or above the configured file/line thresholds is held for manual review, never a hard failure.
+- **Lockfile-integrity gate** (`lockfileIntegrityGateMode`, default `off`) — flags a lockfile-tamper-risk finding (a resolved/integrity change with no matching version bump, or a dependency pointed off the npm registry).
+- **CLA / license gate** (`claGateMode`, default `off`) — CLA / license-compatibility check.
+- **Self-authored-linked-issue gate** (`selfAuthoredLinkedIssueGateMode`, default `advisory`) — flags or blocks a PR whose author also opened the linked issue.
+- **Linked-issue satisfaction gate** (`linkedIssueSatisfactionGateMode`, default `off`) — an AI assessment of whether the PR's diff actually satisfies its primary linked issue's intent (distinct from the linked-issue gate above, which only checks that a link exists). `advisory` renders the assessment in the review comment without ever blocking; `block` additionally lets a confidence-floor-passing "unaddressed" verdict become a hard blocker.
+- **Moderation-rules engine** (`moderationGateMode`, default `inherit`) — whether the contributor-cap / blacklist / review-nag mechanisms feed a shared, cross-repo violation tally on this repo; `inherit` defers to the instance-wide default, `off`/`enabled` force it per repo.
+
+Which deterministic rules even apply is set by the **policy pack** (`gatePack`): `gittensor` (registry-aware, tracks confirmed-Gittensor- contributor status for scoring) or `oss-anti-slop` (runs the rules against any author on any repo, with no confirmed-contributor tracking at all).
+
+
+
+## 2. The dual-AI review and consensus
+
+AI review is its own dimension (`aiReviewMode`, default `off`). It reads the diff and produces review notes — concrete findings tied to the change, not a vague verdict. Two modes:
+
+- `advisory` — the AI write-up is posted as notes only. It never blocks.
+- `block` — a **dual-model high-confidence consensus** defect is allowed to become a blocker.
+
+By default, the blocking decision runs on a **pair** of free models and only blocks when _both_ models independently agree, with high confidence, on a real defect — no single-model block and no tie-breaker third model, so a confident-but-wrong single model can't block a good PR on its own. An operator can override this per repo (`aiReviewCombine`: `single` / `consensus` / `synthesis`); in `single` mode, one reviewer's verdict is the decision.
+
+### Bring your own model (advisory only)
+
+With `aiReviewByok: true` and a configured provider key, the _advisory_ write-up can use a maintainer's own frontier model (`aiReviewProvider` / `aiReviewModel`, e.g. `claude-3-5-sonnet-latest`). The consensus blocker always stays on the free model pair, so BYOK improves the prose without ever changing _who_ can be blocked.
+
+
+ The `LOOPOVER_REVIEW_GROUNDING` flag grounds the reviewer prompt with the PR's finished CI status
+ and the full post-change content of the changed files — so the model verifies its claims instead
+ of predicting CI or flagging a symbol defined just outside the diff hunk. `LOOPOVER_REVIEW_RAG`
+ adds semantically related existing code and docs as extra context. Both are additive and opt-in.
+
+
+## 3. The unified review comment
+
+The result is rendered as **one in-place comment** on the PR — updated in place on each push rather than stacked. It has three parts, top to bottom:
+
+- **The alert** — a one-line headline verdict: whether the gate blocks, what the single most important blocker is, or that the PR is clear. This is the line a reader scans first.
+- **Decision drivers** — a short, always-visible checklist of the signals that actually decided the verdict: the AI code review and the gate result. This is the at-a-glance answer to why the verdict came out the way it did.
+- **Collapsibles** — expandable sections for everything else: a "Context & advisory signals" table (linked issue, related work, validation posture, contributor context — signals that inform but never block the verdict), the AI review notes, slop warnings, duplicate matches, manifest findings. Folded away by default so the comment stays short, opened when a reader wants the evidence.
+
+Who sees the comment, and how much detail it carries, is a repo setting: `commentMode` chooses the audience (`off` / `detected_contributors_only` / `all_prs`), and `publicSignalLevel` (`minimal` / `standard`) controls how much of the signal detail is published. Private review context (`maintainerNotes`) is never published to a public surface.
+
+
+ Public-facing comments are sanitized before they leave the worker. Private scoring, reward, and
+ reputation language never appears in the PR thread — and reputation-based spend control
+ (`LOOPOVER_REVIEW_REPUTATION`) is never surfaced in any comment, label, or check.
+
+
+## 4. The signals behind a verdict
+
+Each row in the advisory signals table comes from a named finding. The common ones you will see:
+
+- `secret_leak` — the safety scan (`LOOPOVER_REVIEW_SAFETY`) found a leaked secret in the diff. The same scan also defangs untrusted PR text before the AI reviewer reads it.
+- `guardrail_hold` — the PR touches a path listed in `settings.hardGuardrailGlobs`. This is a manual-review hold, not an auto-close reason.
+- `manifest_missing_tests` — code changed but the expected test paths (`testExpectations`) did not.
+- **Slop score + warnings** — the deterministic anti-slop signal. With `slopAiAdvisory: true`, a free advisory-only `ai_slop_advisory` finding is added too — it never feeds the score or the gate.
+- **Duplicate match** — the other PR this one duplicates or supersedes.
+- **AI review notes** — the dual-model findings, and (in `block` mode) any consensus defect.
+
+The check run can carry the same signals at adjustable depth — but this is the **LoopOver Context** check, not the Orb Review Agent gate check: `checkRunMode` (`off` / `enabled`) publishes it, and `checkRunDetailLevel` (`minimal` / `standard`) sets how much the check summary spells out. **LoopOver Orb Review Agent** is published separately, controlled by `reviewCheckMode` (see above).
+
+## Putting it together
+
+A pull request flows through the deterministic gate, then the dual-AI review, and the union of both is rendered as one alert + signal table + collapsibles comment. The gate decides _can this merge_ with fixed rules you can read; the AI review adds judgment as advisory notes, escalating to a blocker only on two-model consensus; and the comment is the single, sanitized place a contributor reads the whole verdict. Tune every mode, threshold, and surface in [Tuning your reviews](/docs/tuning).
diff --git a/apps/loopover-ui/content/docs/loopover-commands.mdx b/apps/loopover-ui/content/docs/loopover-commands.mdx
new file mode 100644
index 0000000000..a30ff6d566
--- /dev/null
+++ b/apps/loopover-ui/content/docs/loopover-commands.mdx
@@ -0,0 +1,55 @@
+---
+title: "@loopover command reference"
+description: The full PR and issue-comment control surface -- Q&A verbs, maintainer digests, and PR action commands. Roles reflect the shipped default policy; maintainers can override per command in .loopover.yml.
+---
+
+import {
+ ACTION_COMMAND_ENTRIES,
+ MAINTAINER_COMMAND_ENTRIES,
+ PUBLIC_COMMAND_ENTRIES,
+} from "@/lib/command-reference";
+
+
+ Commands never flip the gate to advisory and never bypass the one-shot disposition. `pause` and
+ `resume` affect only auto-review scheduling — not gate enforcement. See [How reviews
+ work](/docs/how-reviews-work) for the gate/review split.
+
+
+## Syntax
+
+Post a comment on a pull request (or issue thread) mentioning `@loopover` followed by a verb.
+Trailing free text becomes the command argument where noted (for example
+`@loopover ask what should I fix first?`).
+
+
+
+
+
+
+
+
+
+## Per-command authorization overrides
+
+Default allowed roles ship in the worker configuration. A maintainer can tighten or widen a single
+verb via `commandAuthorization` in `.loopover.yml` (resolved in the same order as other per-repo
+settings: manifest → database → defaults).
+
+
+
+Maintainer-only digest verbs ignore a plain `pr_author` role even when widened — only maintainer,
+collaborator, and confirmed_miner survive the clamp for those commands.
+
+## Related docs
+
+- [Maintainer workflow](/docs/maintainer-workflow) — when to invoke commands in a PR thread
+- [How reviews work](/docs/how-reviews-work) — gate, dual-AI review, and unified comment
+- [Tuning your reviews](/docs/tuning) — per-repo review and agent execution modes
diff --git a/apps/loopover-ui/content/docs/maintainer-install-trust.mdx b/apps/loopover-ui/content/docs/maintainer-install-trust.mdx
new file mode 100644
index 0000000000..e9f2138a8a
--- /dev/null
+++ b/apps/loopover-ui/content/docs/maintainer-install-trust.mdx
@@ -0,0 +1,166 @@
+---
+title: Maintainer install and trust guide
+description: A maintainer-first checklist for self-hosting and installing a GitHub App, keeping public output safe, authorizing commands, using the browser extension, and rejecting weak LoopOver-driven PRs.
+---
+
+import { MAINTAINER_COMMAND_LIST, PUBLIC_COMMAND_LIST } from "@/lib/command-reference";
+
+
+ LoopOver is advisory-first. It may help you review contribution readiness, but it does not replace
+ human maintainer judgment, expose private scoreability signals, or make reward, payout, wallet,
+ hotkey, or trust-score claims in public surfaces.
+
+
+## Install the App
+
+**Self-hosting is the only currently available path.** Start from [self-hosting setup](/docs/maintainer-self-hosting) — the direct App's required permissions and events are covered in [GitHub App and Orb](/docs/self-hosting-github-app). Keep the first rollout narrow until the repo owner has verified permissions, webhook delivery, and public copy.
+
+
+ LoopOver previously ran a private managed-beta shared App with its own install checklist below.
+ That shared install path is currently paused while self-hosted Orb is the primary way to run
+ LoopOver — a new centrally hosted offering is planned for the future. The checklist below reflects
+ the shared App's permission set and is kept for when that offering returns; use the self-hosting
+ checklist above for a self-hosted install today.
+
+
+1. Install LoopOver on one test repository or a selected repository set.
+2. Approve `Metadata: read`, `Pull requests: read`, and `Issues: write`. Add `Checks: write` only when Context or review-agent check runs are enabled for the repository.
+3. Keep webhook events enabled for `issues`, `issue_comment`, `pull_request`, and `repository`.
+4. Leave comments, labels, Context checks, and review-agent checks in advisory mode until preview output matches the repo's maintainer policy.
+
+
+ A self-hosted direct App needs `Pull requests: write` (not read) and `Checks: write` is mandatory,
+ not optional — the numbered checklist above is scoped to the shared managed-beta App only.
+
+
+
+
+## Launch verification flow
+
+Treat launch as a controlled trust review. Do not enable public comments or required checks until every step below has a maintainer-visible result.
+
+ verify installation health and webhook delivery
+ -> preview public panel and command output
+ -> confirm private signals stay private
+ -> enable advisory Context, labels, or comments
+ -> capture screenshots/recordings for UI or extension changes
+ -> decide whether the review-agent check should be required in branch protection`}
+/>
+
+## Maintainer controls
+
+
+
+## Command authorization
+
+Commands should be treated like privileged review actions. Use them to fetch context on demand, not to create always-on public scoring. The default authorized roles are **maintainer**, **collaborator**, and **confirmed miner** — a repo can narrow (or further restrict) this per command via `commandAuthorization` in its settings.
+
+
+
+A separate maintainer-only queue-digest command family defaults to maintainers and collaborators only:
+
+
+
+If a command would include private reviewability, private scoreability, duplicate-risk, or contributor-history context, the result must stay in maintainer-visible surfaces. Public replies should only contain sanitized actions a contributor can safely use.
+
+## Public-safe previews
+
+Preview every public output path before enabling it. The same public-safety boundary applies to GitHub comments, issue bodies, PR bodies, extension-visible public panels, and copied snippets.
+
+- No wallet or hotkey identifiers.
+- No reward, payout, or emission estimates.
+- No trust-score, public score prediction, or private scoreability language.
+- No private reviewability blockers or maintainer-only duplicate-risk notes.
+- No farming instructions, bounty gaming language, or rank-chasing advice.
+
+For the full boundary, keep [Privacy & security](/docs/privacy-security) as the source of truth. For AI-written text, use the [AI summaries policy](/docs/ai-summaries) before posting generated copy.
+
+## Browser extension states
+
+The extension is a maintainer review aid. It should make state and scope obvious instead of implying that a contributor or public viewer can see private packets.
+
+ no repo context, no private packet
+Signed in without repo scope
+ -> prompt for authorized GitHub App installation or browser session
+Authorized maintainer on PR page
+ -> public-safe PR panel + private maintainer blockers
+Unauthorized viewer or stale session
+ -> public-safe state only, no private blockers
+API unavailable or stale data
+ -> degraded state with retry guidance, never guessed scores`}
+/>
+
+UI, frontend, browser-extension, or GitHub-overlay pull requests need maintainer-reviewable screenshots or a short recording that shows the relevant states. A checked template box is not enough evidence.
+
+## Audit expectations
+
+A healthy installation should leave an audit trail that maintainers can reason about without exposing repository source or contributor secrets.
+
+- Installation health shows permissions and webhook readiness.
+- Settings preview shows the exact public copy before posting.
+- Command previews identify the maintainer action that produced them.
+- Extension sessions are scoped to authorized review context.
+- Failures are inspectable through diagnostics instead of silent public output.
+
+## CI checks are not reviewer approval
+
+Keep GitHub CI/check state separate from reviewer and mergeability state. A green CI run or advisory Context check can prove automation completed, but it does not prove the PR is acceptable, non-duplicative, or safe to merge. Human maintainers still decide whether the contribution fits the repo, issue, and subnet goals.
+
+If the repo enables **LoopOver Orb Review Agent**, document which blockers are enforced and why. Otherwise, treat LoopOver output as reviewer context only.
+
+## Reject weak LoopOver-driven PRs
+
+Maintainers should request changes or close PRs that misuse LoopOver output. The tool is a contribution operating layer, not a guarantee that work deserves merge.
+
+- Reject PRs with no linked issue, no reproduction, or no validation evidence.
+- Reject UI or extension PRs that omit screenshots or recordings of changed flows.
+- Reject copied snippets that leak private scoring, reward, trust, wallet, or hotkey text.
+- Reject duplicated work when the PR does not explain overlap and maintainer value.
+- Reject generated broad rewrites that are not scoped to the issue acceptance criteria.
+- Reject PRs that confuse passing CI with maintainer approval.
+
+## Next docs
+
+Continue with [Maintainer workflow](/docs/maintainer-workflow) for daily PR review, [Troubleshooting](/docs/troubleshooting) for install diagnostics, and [Browser extension](/extension) for overlay behavior.
diff --git a/apps/loopover-ui/content/docs/maintainer-self-hosting.mdx b/apps/loopover-ui/content/docs/maintainer-self-hosting.mdx
new file mode 100644
index 0000000000..3c4d7c39fa
--- /dev/null
+++ b/apps/loopover-ui/content/docs/maintainer-self-hosting.mdx
@@ -0,0 +1,236 @@
+---
+title: Self-hosted reviews
+description: Run the LoopOver review service on your own infrastructure, with your own data store, GitHub App, AI provider, enrichment service, observability, and private repo policy.
+---
+
+
+ Treat the self-host stack like production infrastructure. Keep secrets out of images and public
+ repos, start in advisory or dry-run mode, and only enable write autonomy after you have watched
+ real reviews, logs, metrics, and failure paths.
+
+
+## What this section covers
+
+Self-hosting is a major product path, not a single install command. The service can run as a quiet advisory reviewer, a private maintainer copilot, or a full review operator. The docs are split by operating concern so you can onboard gradually.
+
+
+
+## Recommended reading order
+
+1. Start with [Quickstart](/docs/self-hosting-quickstart) to get a local instance healthy.
+2. Read [Configuration](/docs/self-hosting-configuration) before enabling repo review features.
+3. Set up [GitHub App and Orb](/docs/self-hosting-github-app) so webhooks and installation tokens are correct.
+4. Add [AI providers](/docs/self-hosting-ai-providers), [REES enrichment](/docs/self-hosting-rees), the [REES analyzer reference](/docs/self-hosting-rees-analyzers), and [RAG indexing](/docs/self-hosting-rag) only after the deterministic path is stable.
+5. Use [Operations](/docs/self-hosting-operations), [Backup and scaling](/docs/self-hosting-backup-scaling), and [Security](/docs/self-hosting-security) before exposing the service to production traffic.
+6. Run the [release checklist](/docs/self-hosting-release-checklist) before tagging or promoting a candidate image.
+
+export const SECTION_LINKS = [
+ {
+ title: "Quickstart",
+ description:
+ "Bring up the container, smoke-test readiness, and confirm the GitHub webhook path.",
+ to: "/docs/self-hosting-quickstart",
+ },
+ {
+ title: "Configuration",
+ description:
+ "Understand env vars, private repo config, feature flags, and safe baseline defaults.",
+ to: "/docs/self-hosting-configuration",
+ },
+ {
+ title: "GitHub App and Orb",
+ description:
+ "Choose a direct GitHub App or brokered Orb enrollment and set the right permissions.",
+ to: "/docs/self-hosting-github-app",
+ },
+ {
+ title: "AI providers",
+ description: "Wire Anthropic, OpenAI-compatible, Ollama, Claude Code, or Codex safely.",
+ to: "/docs/self-hosting-ai-providers",
+ },
+ {
+ title: "REES enrichment",
+ description:
+ "Run external analyzers, configure REES_ANALYZERS, and understand where results show up.",
+ to: "/docs/self-hosting-rees",
+ },
+ {
+ title: "REES analyzer reference",
+ description:
+ "Review every analyzer name, input, finding shape, network call, and token requirement.",
+ to: "/docs/self-hosting-rees-analyzers",
+ },
+ {
+ title: "RAG indexing",
+ description: "Configure embeddings, Qdrant, indexing jobs, and cold-index behavior.",
+ to: "/docs/self-hosting-rag",
+ },
+ {
+ title: "Operations",
+ description:
+ "Health checks, logs, metrics, safe update/rollback checklists, deploy scripts, and daily operator routines.",
+ to: "/docs/self-hosting-operations",
+ },
+ {
+ title: "Backup and scaling",
+ description: "SQLite, Litestream, Postgres, Redis, restores, and multi-instance tradeoffs.",
+ to: "/docs/self-hosting-backup-scaling",
+ },
+ {
+ title: "Releases and images",
+ description: "Official images, tags, source maps, upgrade cadence, and local custom builds.",
+ to: "/docs/self-hosting-releases",
+ },
+ {
+ title: "Release checklist",
+ description:
+ "Versioning, the smoke matrix, an image-contents audit, and release notes for an orb-vX.Y.Z release.",
+ to: "/docs/self-hosting-release-checklist",
+ },
+ {
+ title: "Security",
+ description:
+ "Secret handling, private policy, public output boundaries, network exposure, and auth.",
+ to: "/docs/self-hosting-security",
+ },
+ {
+ title: "Troubleshooting",
+ description:
+ "Review not firing, REES silent, AI unavailable, RAG empty, queue stuck, and webhook failures.",
+ to: "/docs/self-hosting-troubleshooting",
+ },
+ {
+ title: "Docs accuracy audit",
+ description:
+ "Checklist mapping website docs to docker-compose, env, release, observability, and backup sources of truth.",
+ to: "/docs/self-hosting-docs-audit",
+ },
+];
+
+## Pages
+
+
+
+## Onboarding simplification proposal (#1574)
+
+This section records today's accurate setup path and the gaps worth closing next — the issue deliverable for making self-host onboarding as fast as CodeRabbit while keeping robust per-repo policy in container-private config.
+
+### Today's recommended path (verified)
+
+1. `cp .env.selfhost.example .env` — conservative defaults (`dry-run`, small `LOOPOVER_REVIEW_REPOS`).
+2. Pull or build the image (`INSTALL_AI_CLIS=true` by default; `--build-arg INSTALL_AI_CLIS=false` for deterministic-only).
+3. One-click GitHub App via `/setup` + `SELFHOST_SETUP_TOKEN` (`Checks: write` included — re-approve on existing Apps after permission bumps).
+4. Mount `./loopover-config` and copy `config/examples/global.loopover.yml` → `loopover-config/.loopover.yml` for a centralized private default (per-repo files deep-merge on top).
+5. Add each pilot repo to `LOOPOVER_REVIEW_REPOS`, watch a PR in `dry-run`, then enable advisory gate mode from the control panel or `POST /v1/repos/:owner/:repo/activation`.
+6. Go live by unsetting `SELFHOST_DEPLOYMENT_MODE`; tune autonomy in private config when ready.
+
+### Gaps and proposed improvements
+
+
+
+
+ None of the proposals above require code changes to adopt today's path — they describe UX we can
+ add without weakening the private-config model or env-level kill switches.
+
+
+## How self-hosting fits with hosted docs
+
+The hosted maintainer workflow still applies: review modes, gate settings, safety rules, and privacy boundaries are the same concepts. Self-hosting adds infrastructure choices, deployment secrets, private config, and local operating responsibility. Use [Tuning your reviews](/docs/tuning) for gate semantics and this section for running the service yourself.
+
+## Moving a repo between hosted and self-host
+
+
+ "Hosted" here means the private managed-beta shared `loopover` App described in [GitHub App
+ configuration](/docs/github-app) — it previously accepted new installs and is currently paused
+ while self-hosted Orb is the primary way to run LoopOver. A new centrally hosted offering is
+ planned for the future. "Switching from hosted to self-host" below still applies to any repo
+ already installed on the hosted App; "switching back to hosted" isn't possible until hosted
+ installs reopen, but the steps are kept here for when they do.
+
+
+A repo installed on the hosted App is reviewed by LoopOver's own cloud Worker and its own database. "Self-host" means your own container from [Quickstart](/docs/self-hosting-quickstart), with its own GitHub App (or brokered Orb enrollment) and its own data store. There is **no automated migration path between the two** — moving a repo is a manual App swap plus re-creating whatever settings you had, not a toggle.
+
+### Switching a repo from hosted to self-host
+
+1. Stand up your self-host instance first and confirm `/ready` is healthy — see [Quickstart](/docs/self-hosting-quickstart) — before touching the hosted install, so the repo is never briefly reviewed by nothing.
+2. Create **your own** GitHub App via the self-host [setup wizard](/docs/self-hosting-github-app) (or brokered Orb enrollment). You cannot repoint the existing shared hosted App at your self-host container — the shared App's credentials belong to LoopOver's cloud Worker, and `src/selfhost/setup-wizard.ts` always mints a distinct App tied to your instance's own webhook URL.
+3. Install your new self-host App on the repo, choosing only that repo (or the org, if you're migrating several at once).
+4. Uninstall the shared hosted App from that repo (repo Settings → Integrations → GitHub Apps → loopover → Uninstall, or the equivalent org-level App settings page) once you've confirmed the self-host App is reviewing PRs correctly. Leaving both installed means two reviewers post competing checks and comments on the same PRs.
+
+### What does not carry over automatically
+
+Hosted-side settings live in LoopOver's own cloud database, keyed by repo full name — `resolveRepositorySettings` (`src/settings/repository-settings.ts`) reads them from `env.DB`, which is a completely different database instance than your self-host container's. A self-host instance has no access to, and no import path for, whatever thresholds, gate modes, or review-mode settings you configured on the hosted side through the control panel or API. If you want the same behavior, you have to re-configure it on the new instance from scratch — there is no export/import tool for this today.
+
+**One thing genuinely does carry over: a repo's own `.loopover.yml`** (config-as-code), because it lives in the repository's git history, not in either service's database. `resolveRepositorySettings` overlays it on top of whatever DB settings exist, on either hosted or self-host — so gate-mode overrides, thresholds, and other settings expressed in that file apply identically the moment the new App starts reviewing, with nothing to re-enter.
+
+
+ Past review comments, check-run history, and any per-PR state LoopOver recorded while the hosted
+ App was active stay wherever they were created — GitHub comments and check runs are never deleted
+ or copied by an uninstall/install, but nothing in the self-host database is backfilled from the
+ hosted side. A migrated repo starts its self-host review history from zero.
+
+
+What stays identical for contributors either way: the review still posts as a `loopover[bot]`-style comment (under your own App's slug once you migrate, not literally `loopover[bot]`) plus the same check-run shape, and the gate semantics in [Tuning your reviews](/docs/tuning) and [How reviews work](/docs/how-reviews-work) are unchanged — only the infrastructure and the settings storage location differ.
+
+### Switching a repo from self-host back to hosted
+
+Hosted installs are currently paused — this direction isn't possible until the shared App reopens (see the callout above). Once it does, the reverse migration has the same shape and the same gap: uninstall your self-host App from the repo, install the shared hosted App (see [GitHub App configuration](/docs/github-app)), and re-create any DB-backed settings on the hosted side. `.loopover.yml` again carries over for free since it travels with the repo; nothing else does. Your self-host instance's data volumes are untouched by this — see [Uninstalling and decommissioning](/docs/self-hosting-operations) if you also intend to shut the instance down rather than keep it idle or reuse it for other repos.
diff --git a/apps/loopover-ui/content/docs/maintainer-workflow.mdx b/apps/loopover-ui/content/docs/maintainer-workflow.mdx
new file mode 100644
index 0000000000..6bb6a4680d
--- /dev/null
+++ b/apps/loopover-ui/content/docs/maintainer-workflow.mdx
@@ -0,0 +1,127 @@
+---
+title: Maintainer workflow
+description: The default posture is silence. You opt into context. The repo stays calm.
+---
+
+import { MAINTAINER_COMMAND_LIST, PUBLIC_COMMAND_LIST } from "@/lib/command-reference";
+
+export const steps = [
+ {
+ title: "Plan",
+ miner: (
+ <>
+ The contributor pulls a private decision pack via MCP — lane fit, repo targets, ranked next
+ actions. Nothing is posted to your repo.
+ >
+ ),
+ maintainer: (
+ <>
+ Default posture is silence. No always-on public check runs, no score numbers, no labels on
+ non-confirmed-miner PRs.
+ >
+ ),
+ nextStep: {
+ miner: { label: "What miners see", to: "/docs/miner-workflow" },
+ maintainer: { label: "Privacy boundary", to: "/docs/privacy-security" },
+ },
+ },
+ {
+ title: "Analyze",
+ miner: (
+ <>
+ Metadata-only branch analysis runs locally. Source code stays on the contributor's machine.
+ >
+ ),
+ maintainer: (
+ <>
+ On confirmed-miner PRs, request the maintainer packet on demand — no background scanning of
+ your repo.
+
+ >
+ ),
+ nextStep: {
+ miner: { label: "Branch analysis reference", to: "/docs/branch-analysis" },
+ maintainer: { label: "Maintainer packet API", to: "/api" },
+ },
+ },
+ {
+ title: "Preflight",
+ miner: (
+ <>
+ The contributor sees branch blockers, account/queue blockers, and maintainer-fit notes
+ before opening the PR.
+ >
+ ),
+ maintainer: (
+ <>
+ You can ask for the same reviewability view in the PR thread.
+
+ >
+ ),
+ nextStep: {
+ miner: { label: "Common blockers", to: "/docs/troubleshooting" },
+ maintainer: { label: "Upstream drift", to: "/docs/upstream-drift" },
+ },
+ },
+ {
+ title: "Packet",
+ miner: (
+ <>
+ The contributor opens the PR with a public-safe packet — clean description, no private
+ scoring language.
+ >
+ ),
+ maintainer: (
+ <>
+ At most one sticky sanitized comment and one configured label per confirmed-miner PR. Pull
+ richer context on demand via the API.
+
+ >
+ ),
+ nextStep: {
+ miner: { label: "PR packet format", to: "/docs/miner-workflow" },
+ maintainer: { label: "Self-host reviews", to: "/docs/maintainer-self-hosting" },
+ },
+ },
+];
+
+## The mirrored loop
+
+Each step on the right is what you see in the repo; the matching step on the left is what the contributor is doing privately via MCP at the same point.
+
+New installations should start with [self-hosting setup](/docs/maintainer-self-hosting), then [GitHub App configuration](/docs/github-app): install on one repo, verify installation health, preview the public panel, then decide whether **LoopOver Orb Review Agent** should become a required check.
+
+
+
+## On-demand commands
+
+By default, the public commands below are authorized for maintainers, collaborators, and confirmed miners (not the general public) — output stays scoped to maintainer-visible packets when appropriate. A repo can narrow this per command via `commandAuthorization` in its settings.
+
+
+
+A separate maintainer-only queue-digest family defaults to maintainers and collaborators only:
+
+
+
+For syntax, default roles, PR action verbs, and the gate vs auto-review boundary, see the [@loopover command reference](/docs/loopover-commands).
+
+
+ Public-facing comments are sanitized before they leave the Worker. Private scoring, reward, and
+ risk language never appears in the PR thread.
+
diff --git a/apps/loopover-ui/content/docs/mcp-clients.mdx b/apps/loopover-ui/content/docs/mcp-clients.mdx
new file mode 100644
index 0000000000..8a4b08b28f
--- /dev/null
+++ b/apps/loopover-ui/content/docs/mcp-clients.mdx
@@ -0,0 +1,93 @@
+---
+title: MCP client setup
+description: Configure your coding agent to talk to the LoopOver MCP. Pick stdio for local agents, remote for cloud agents.
+---
+
+## Generate config
+
+These commands print config only. They do not mutate your local client files.
+
+
+
+`--print mcp` uses the same JSON snippet as Claude Desktop and Cursor for other stdio MCP hosts
+that expect the `mcpServers` shape. Every generated snippet assumes `loopover-mcp` is on your
+`PATH` (install it globally first, per [Quickstart](/docs/quickstart)) — pass
+`--command /absolute/path/to/loopover-mcp` if your client doesn't inherit your shell PATH.
+
+## Codex (OpenAI)
+
+
+
+## Claude Desktop
+
+
+
+## Cursor
+
+
+
+## VS Code
+
+VS Code's native MCP support uses a `servers` map with an explicit transport type instead of the
+`mcpServers` shape the other JSON hosts use:
+
+
+
+## Remote MCP
+
+The Worker also exposes a remote MCP endpoint. Use this when your agent runs in the cloud or you
+don't want a local Node process.
+
+
+
+
+ Local `--stdio` is the default recommendation. It keeps auth + analysis on your machine and is the
+ easiest path to log into with GitHub Device Flow.
+
diff --git a/apps/loopover-ui/content/docs/miner-coding-agent.mdx b/apps/loopover-ui/content/docs/miner-coding-agent.mdx
new file mode 100644
index 0000000000..1f14e9b9b4
--- /dev/null
+++ b/apps/loopover-ui/content/docs/miner-coding-agent.mdx
@@ -0,0 +1,163 @@
+---
+title: Miner coding-agent driver
+description: Choose a production provider, override the right model and timeout knobs, and recognize credential failures before you chase the wrong layer.
+---
+
+The miner resolves `MINER_CODING_AGENT_PROVIDER` as a comma-separated preference list. The first
+configured name wins, unknown names are skipped, and an empty or unset list leaves production
+construction fail-closed instead of guessing a default backend.
+
+
+ This seam is explicit on purpose: if you do not configure a provider, the miner does not silently
+ pick one for you.
+
+
+## Provider selection
+
+
+
+
+MINER_CODING_AGENT_TIMEOUT_MS=120000
+
+# Prefer Codex, fall back to Claude.
+MINER_CODING_AGENT_PROVIDER=codex-cli,claude-cli
+MINER_CODING_AGENT_CODEX_MODEL=`}
+/>
+
+
+ `noop` and `agent-sdk` ignore the model and timeout knobs. Only the CLI subprocess providers
+ consume them.
+
+
+## Model and timeout overrides
+
+The only driver-specific knobs today are the provider-specific model overrides and the shared
+wall-clock timeout. Anything else is task-level orchestration, not provider config.
+
+
+
+
+
+
Env var
+
Applies to
+
Default
+
Notes
+
+
+
+
+
+ MINER_CODING_AGENT_PROVIDER
+
+
All production provider selection
+
unset / empty
+
+ Comma-separated preference list. The first configured name wins; unknown names are
+ skipped.
+
+
+
+
+ MINER_CODING_AGENT_CLAUDE_MODEL
+
+
claude-cli
+
CLI default
+
+ Optional override for the Claude Code subprocess. Ignored by noop, codex-cli, and
+ agent-sdk.
+
+
+
+
+ MINER_CODING_AGENT_CODEX_MODEL
+
+
codex-cli
+
CLI default
+
+ Optional override for the Codex subprocess. Ignored by noop, claude-cli, and agent-sdk.
+
+
+
+
+ MINER_CODING_AGENT_TIMEOUT_MS
+
+
claude-cli / codex-cli
+
120000 ms
+
+ Positive integer wall-clock ceiling. Unset or invalid falls back to the CLI driver's
+ default timeout.
+
+
+
+
+
+
+## Recognizing a stale or missing credential
+
+The shared troubleshooting table for Claude Code and Codex lives on
+[Self-host AI providers](/docs/self-hosting-ai-providers#recognizing-a-stale-or-missing-credential).
+This page keeps the miner-specific reminder: the credential lives on the operator's machine or
+mounted volume, not in repo config.
+
+
+
+
+ If the CLI cannot see its credential, the miner cannot spawn a healthy provider. Fix the
+ operator-owned credential path first, then come back to the miner env vars.
+
+
+## Related docs
+
+- [Miner quickstart by lane](/docs/miner-quickstart) — install and verify the miner before you wire a coding agent.
+- [Miner workflow](/docs/miner-workflow) — the rest of the contributor loop after the driver is configured.
+- [Self-host AI providers](/docs/self-hosting-ai-providers) — the broader credential and provider reference that shares the troubleshooting table above.
diff --git a/apps/loopover-ui/content/docs/miner-quickstart.mdx b/apps/loopover-ui/content/docs/miner-quickstart.mdx
new file mode 100644
index 0000000000..6cb36e4ef2
--- /dev/null
+++ b/apps/loopover-ui/content/docs/miner-quickstart.mdx
@@ -0,0 +1,140 @@
+---
+title: Miner quickstart by contribution lane
+description: Choose how you want to contribute, then follow the same loop — install, sign in, doctor, plan, preflight, packet — with the flags that fit your lane. About five minutes per lane.
+---
+
+LoopOver is copilot-only. It ranks and explains your options and drafts public-safe PR packets. It
+does not edit code, open PRs, or post comments for you, it makes no earnings promises, and it
+never predicts a public number. Every command below also accepts `--json` for machine-readable
+output, and your source never leaves your machine — only branch metadata (changed file paths,
+commit messages) is sent to authenticated LoopOver MCP/API responses.
+
+If you are setting up Claude Code or Codex as the miner's coding-agent driver, read [Miner
+coding-agent driver](/docs/miner-coding-agent) first so the env vars match the provider you
+actually plan to run.
+
+## 0. Install and sign in (every lane)
+
+The MCP is published as `@loopover/mcp`. Run it with `npx` or install it globally, then
+authenticate with GitHub Device Flow — LoopOver never asks for a Personal Access Token.
+
+
+
+
+ Session tokens are **LoopOver tokens backed by GitHub identity**, not your GitHub PATs. Source
+ upload stays disabled (`LOOPOVER_UPLOAD_SOURCE=false`) and local absolute paths are redacted from
+ anything that leaves your machine. Log out anytime with `loopover-mcp logout`.
+
+
+## 1. Choose your lane
+
+Lanes describe _how_ you contribute. Pick the one that matches the work in front of you, then read
+what the target repo actually supports: `agent plan` and `repo-decision` report the repo's
+configured lane so you can align before you start.
+
+
+
+The repo's configured lane comes back as one of these (it is set by the repo's registry config,
+not by you):
+
+- `direct_pr` — implementation PRs only. Prefer focused PRs with clear evidence, linked context, and low review churn.
+- `issue_discovery` — discovery work only. Focus on high-proof issue reports and avoid self-resolved issue loops.
+- `split` — both paths are active. Pick one intentionally: issue discovery for reports, direct PR for implementation.
+- `inactive` — registered but with no current allocation. Treat it as normal upstream contribution work unless the registry changes.
+- `unknown` — not registered (or no config yet). Do not assume the repo is ready for Gittensor-specific contribution guidance.
+
+## 2. Direct PR lane
+
+You are implementing a change and opening a PR directly. Use this in a `direct_pr` or `split`
+repo. Plan, preflight your branch metadata, then generate the public-safe packet to paste into the
+PR body.
+
+
+
+## 3. Issue-solving PR lane
+
+You are fixing a specific open issue. Same loop as a direct PR, but link the issue in your branch
+so preflight can credit the linked context. Confirm the linked-issue signal in the preflight
+output before opening the PR.
+
+
+
+## 4. Issue discovery lane
+
+You are reporting a high-proof issue rather than opening a PR. Use this in an `issue_discovery` or
+`split` repo. Start from the plan to see which discovery work is worth it, and keep reports
+specific and reproducible — avoid self-resolved loops.
+
+
+
+## 5. Docs and context work
+
+Documentation and context contributions still ship as PRs, so they follow the direct PR loop. Run
+preflight on the branch metadata and generate a packet — the packet is the same public-safe
+artifact regardless of whether the change is code or docs.
+
+
+
+## 6. Repo-specific lanes
+
+Some repos run their own lane policy. Always let the repo tell you: `repo-decision` returns the
+configured lane plus contributor guidance, and `analyze-branch` lets you model a scenario (pending
+merges, expected open PRs) before you commit to a path.
+
+
+
+## Validation expectations (every lane)
+
+Before you open anything, the loop should be clean: `doctor` green, your branch metadata
+preflighted, and a validation note attached. Pass what you actually ran via
+`--validation "status|command|summary"` (for example `"passed|npm test|all green"`) so the
+preflight verdict reflects real validation, not a guess.
+
+
+
+
+ The PR packet from `agent packet` is **public-safe**: it is scrubbed of economic and identity
+ signals (wallet/hotkey, payout, trust-score, ranking, and public-prediction language) before it
+ can be pasted into a public GitHub surface. Pair this page with the [miner
+ workflow](/docs/miner-workflow) for the full loop and [privacy & security](/docs/privacy-security)
+ for the boundary details.
+
+
diff --git a/apps/loopover-ui/content/docs/miner-workflow.mdx b/apps/loopover-ui/content/docs/miner-workflow.mdx
new file mode 100644
index 0000000000..48ce4e4fca
--- /dev/null
+++ b/apps/loopover-ui/content/docs/miner-workflow.mdx
@@ -0,0 +1,106 @@
+---
+title: Miner workflow
+description: A deterministic four-step loop. Each step is pure metadata; each output is structured JSON your agent can consume.
+---
+
+If the workflow will spawn Claude Code or Codex, configure that driver first in [Miner
+coding-agent driver](/docs/miner-coding-agent).
+
+## The mirrored loop
+
+Each step on the left is what the contributor runs; the matching step on the right is what the
+maintainer sees in the repo at the same point.
+
+
+ Pull a decision pack — lane context, repo targets to pursue or avoid, freshness, and
+ ranked next actions.
+
+ >
+ ),
+ maintainer: (
+ <>
+ Nothing visible in the repo. The plan step is private MCP context for the contributor; no
+ public comments or labels are emitted.
+ >
+ ),
+ nextStep: {
+ miner: { label: "How scoreability works", to: "/docs/scoreability" },
+ maintainer: { label: "Privacy boundary", to: "/docs/privacy-security" },
+ },
+ },
+ {
+ title: "Analyze",
+ miner: (
+ <>
+ Metadata-only branch analysis on the current branch — refs, changed-file metadata, labels,
+ linked issues, commit messages, validation summaries.
+
+ >
+ ),
+ maintainer: (
+ <>
+ Still silent in the repo. Branch analysis runs locally against the API; no source is
+ uploaded and no check runs are created.
+ >
+ ),
+ nextStep: {
+ miner: { label: "Branch analysis reference", to: "/docs/branch-analysis" },
+ maintainer: { label: "What we don't upload", to: "/docs/privacy-security" },
+ },
+ },
+ {
+ title: "Preflight",
+ miner: (
+ <>
+ Combine branch analysis with account/queue context to surface branch blockers, account
+ blockers, and maintainer-fit notes.
+
+ >
+ ),
+ maintainer: (
+ <>
+ On confirmed-miner PRs you can later request the same view with
+ @loopover preflight — the response is sanitized for the PR thread.
+ >
+ ),
+ nextStep: {
+ miner: { label: "Common preflight blockers", to: "/docs/troubleshooting" },
+ maintainer: { label: "All @loopover commands", to: "/docs/maintainer-workflow" },
+ },
+ },
+ {
+ title: "Packet",
+ miner: (
+ <>
+ Produce a public-safe PR packet — a description that reads cleanly to maintainers, with no
+ private scoring or risk language leaking out.
+
+ >
+ ),
+ maintainer: (
+ <>
+ At most one sticky sanitized comment and one configured label per confirmed-miner PR.
+ Private scoring, reward, and risk language never appear in the thread.
+ >
+ ),
+ nextStep: {
+ miner: { label: "Set up your MCP client", to: "/docs/mcp-clients" },
+ maintainer: { label: "Self-host reviews", to: "/docs/maintainer-self-hosting" },
+ },
+ },
+ ]}
+ minerCta={{ label: "Set up the MCP client", to: "/docs/mcp-clients" }}
+ maintainerCta={{ label: "See the maintainer side", to: "/docs/maintainer-workflow" }}
+/>
+
+
+ **Cleanup first.** When the preflight reports queue pressure or unsquashed commits, prefer
+ cleaning open work over opening more — risk-adjusted priority is part of the score model.
+
+
diff --git a/apps/loopover-ui/content/docs/owner-checklist.mdx b/apps/loopover-ui/content/docs/owner-checklist.mdx
new file mode 100644
index 0000000000..9cb6d157fe
--- /dev/null
+++ b/apps/loopover-ui/content/docs/owner-checklist.mdx
@@ -0,0 +1,105 @@
+---
+title: Repo-owner onboarding checklist
+description: Work through this before you invite LoopOver contribution traffic. It mirrors the readiness report exactly, so each item is something the platform actually checks -- and each comes with the honest tradeoff you are opting into.
+---
+
+LoopOver is **quiet by default**: it installs without posting comments or
+adding labels until you turn those surfaces on. This checklist is what to confirm first.
+Everything owner-only runs through the private API or the [owner console](/app/owner); readiness is reported as bands and statuses,
+never as a raw private number.
+
+Start by pulling your readiness report — it returns `ready` plus a list of
+`blockers`, and drives every item below. You can also wire a review-only agent
+with the `repo-owner-intake` profile (it explains and drafts setup plans; it
+never pushes config, labels issues, or posts comments).
+
+
+
+
+## 1. Repository registration
+
+Confirm the repo is in the current LoopOver registry. If it is not, that is the first
+`blocker` in the readiness report and nothing else applies yet. Register and
+review from the [owner console](/app/owner).
+
+## 2. Repo policy & config quality
+
+Your policy lives in `.loopover.yml` (sections: `settings`,
+`gate`, `review`). The readiness report grades it as
+`configQuality` (excellent / good / needs_attention / fragile); a
+**fragile** config is a hard blocker. Preview exactly what a given config would
+do on a real PR before you commit it:
+
+
+
+## 3. Labels & trusted pipeline
+
+The report checks `labelPolicy.trustedPipelineReady` and lists missing or unused
+registry labels. Configure the labels you actually use before turning on
+`labelMultipliers`.
+
+
+ Honest tradeoff: enabling trusted-label multipliers before your labels are real and applied
+ consistently incentivizes the wrong work. Start without them and add them once the label pipeline
+ is verified.
+
+
+## 4. Issue quality
+
+Clear, reproducible, well-scoped issues are the cheapest way to reduce low-quality PR
+pressure: contributors aim at real targets instead of guessing. The issue-quality signal
+feeds the contribution lanes below, so tidy your open issues before broadening intake.
+
+## 5. Contribution lanes
+
+A repo's lane is configured (not chosen by contributors) and the config recommendation
+endpoint suggests one with its tradeoffs. The lane is one of:
+
+- `direct_pr` — implementation PRs only (`issueDiscoveryShare` = 0).
+- `issue_discovery` — discovery/reporting only (`issueDiscoveryShare` = 1).
+- `split` — both lanes active (0 < `issueDiscoveryShare` < 1).
+- `inactive` — registered but with no current allocation.
+- `unknown` — not registered or no config yet.
+
+
+ Honest tradeoff: the `split` lane is recommended only when contributor intake is healthy and
+ config quality is excellent. Adding an issue-discovery slice surfaces more outside work but adds
+ triage load and duplicate risk — default to `direct_pr` until you have capacity.
+
+
+## 6. Validation expectations & gate readiness
+
+Declare your validation commands in `.loopover.yml` so contributors know what
+"done" means, and so the gate can run. The report reports
+`testCoverageHealth` as `gate_ready` or `gate_unknown`;
+gate checks only run when you have explicitly configured them. Until then the gate stays
+advisory.
+
+## 7. Maintainer capacity & queue health
+
+The report grades `queueHealth` (low / medium / high / critical) from your open
+PR/issue burden, and `maintainerCutReadiness` tells you whether the repo is calm
+enough to reserve a maintainer lane.
+
+
+ Honest tradeoffs: opening more lanes means more triage. A maintainer cut credits upkeep but
+ reduces the miner share. Requiring a linked issue improves traceability but deters quick drive-by
+ PRs. Pick deliberately for the capacity you actually have.
+
+
+## 8. Public/private boundaries
+
+Decide what becomes visible: `publicSurface` (comments + labels),
+`commentMode`, and `publicAudienceMode`. Everything that can reach a
+public GitHub surface is run through the sanitizer first, so economic and identity signals
+are stripped — along with local file paths — and nothing is framed as a guaranteed outcome.
+
+
+ Turning on public comments and labels increases visibility — and the volume of drive-by PRs. Keep
+ surfaces quiet until items 1–7 are green. See [privacy & security](/docs/privacy-security) for the
+ full boundary and the [owner workflow](/docs/beta-onboarding) for the end-to-end setup path.
+
diff --git a/apps/loopover-ui/content/docs/privacy-security.mdx b/apps/loopover-ui/content/docs/privacy-security.mdx
new file mode 100644
index 0000000000..feb83d2839
--- /dev/null
+++ b/apps/loopover-ui/content/docs/privacy-security.mdx
@@ -0,0 +1,99 @@
+---
+title: Privacy & security
+description: Privacy is the product. These are hard rules, not best-effort goals.
+---
+
+## Hard rules
+
+- No source upload by default. MCP sends metadata only.
+- No PAT storage. Auth uses GitHub Device Flow.
+- No wallet or hotkey display.
+- No raw trust-score display.
+- No payout/reward guarantees, anywhere.
+- No farming language.
+- No public score estimates.
+- No private reviewability details in public GitHub output.
+
+## Open algorithm, private tuning
+
+LoopOver's review engine is built so the **logic is public but the dial settings are not**. The deterministic gate, the
+scoring signals, the slop detector, the grounding/RAG context builders, and the comment
+renderer all live in the open source tree — anyone can read exactly how a verdict is
+reached. What stays private is the **production tuning**: the thresholds,
+guardrail paths, and gate modes an operator runs in production. That separation is what
+keeps a review from being gameable off the public code.
+
+Tuning lives in two private, repo-scoped places that sit on top of the open algorithm, and
+neither reveals review _direction_:
+
+- **Per-repo settings** — gate modes, score thresholds, and guardrails, stored
+ in the operator's database (set through the dashboard/API) or declared as config-as-code
+ in a repo's `.loopover.yml`. Choosing `gate.slop.minScore` or
+ setting `settings.hardGuardrailGlobs` tightens the gate without telling a
+ contributor how to bypass it.
+- **Operator feature flags** — the `LOOPOVER_REVIEW_*` family of
+ worker environment variables. These switch whole capabilities (safety scanning, CI and
+ full-file grounding, RAG context, reputation-based spend control, the unified comment) on
+ or off for a deployment.
+
+Every feature flag ships **OFF**, and a per-PR capability runs only when its
+own flag is on _and_ the repo is in the `LOOPOVER_REVIEW_REPOS` allowlist
+— so capabilities stay dormant until an operator explicitly converges a repo, one flag and
+one repo at a time.
+
+
+
+The internal-only controls never surface publicly. Submitter reputation, for example, can
+downgrade a burst or low-reputation submitter to a deterministic-only review — but no
+comment, label, or check ever shows a reputation value. Reputation thresholds are generic
+anti-abuse defaults that reveal no review direction and are not per-repo tunable.
+
+
+ Reading the open source tells you **how** a verdict is computed, never **what** an operator's
+ production gate will decide. The deciding inputs — thresholds, guardrail globs, and which
+ `LOOPOVER_REVIEW_*` capabilities are live — are private runtime settings, so reviews cannot be
+ reverse-engineered or gamed from the public code.
+
+
+## Public output rules
+
+- At most one sticky sanitized comment per confirmed-miner PR.
+- At most one configured label per confirmed-miner PR.
+- Public comments are maintainer-friendly and non-shaming.
+
+## Auth
+
+- Public endpoint: `GET /health`.
+- Private API uses Bearer / session tokens.
+- MCP CLI uses GitHub OAuth Device Flow.
+- Static bearer tokens remain internal / bootstrap only.
+
+
+ Website copy may discuss private scoreability and risk reasoning, but it's always framed as
+ **private MCP/API context**. The public web never carries score numbers.
+
diff --git a/apps/loopover-ui/content/docs/quickstart.mdx b/apps/loopover-ui/content/docs/quickstart.mdx
new file mode 100644
index 0000000000..5fe46f9c87
--- /dev/null
+++ b/apps/loopover-ui/content/docs/quickstart.mdx
@@ -0,0 +1,56 @@
+---
+title: Quickstart
+description: Install the MCP, sign in, and run your first analysis. About two minutes.
+---
+
+## 1. Install
+
+The MCP is published as `@loopover/mcp`. You can run it with `npx`, or
+install it globally.
+
+
+
+## 2. Sign in (GitHub Device Flow)
+
+LoopOver never asks for a Personal Access Token. The CLI walks you through GitHub's Device
+Flow and exchanges the result for a LoopOver session token.
+
+
+
+
+ Session tokens are **LoopOver tokens backed by GitHub identity**, not your GitHub PATs. You can
+ log out anytime with `loopover-mcp logout`.
+
+
+## 3. Run your first analysis
+
+Analyze the current branch with metadata only. No source ever leaves your machine.
+
+
+
+## 4. Wire it into your coding agent
+
+Print a config snippet for your editor of choice and paste it in. See
+[MCP client setup](/docs/mcp-clients) for the details. For the full miner path
+(plan → preflight → packet) and other roles, see
+[Beta onboarding](/docs/beta-onboarding).
+
+
diff --git a/apps/loopover-ui/content/docs/scoreability.mdx b/apps/loopover-ui/content/docs/scoreability.mdx
new file mode 100644
index 0000000000..fa0df76df3
--- /dev/null
+++ b/apps/loopover-ui/content/docs/scoreability.mdx
@@ -0,0 +1,74 @@
+---
+title: Scoreability
+description: LoopOver projects how scoreable your branch is under several scenarios. These are estimates, never guarantees.
+---
+
+## The seven scenarios
+
+Every preview computes `scenarioPreviews`, an array of exactly seven named
+scenarios, alongside a top-level `effectiveEstimatedScore` and
+`underlyingPotentialScore` for the current state:
+
+- **current** — what's scoreable right now, given all current gates and
+ observed data.
+- **cleanGates** — projection assuming every currently-failing gate (open-PR
+ threshold, credibility floor, review penalty, etc.) clears.
+- **afterPendingMerges** — projection assuming your other open PRs on this repo
+ merge, relieving open-PR collateral.
+- **afterApprovedPrsMerge** — projection assuming only your already-approved
+ open PRs merge.
+- **afterStalePrsClose** — projection assuming your stale open PRs close
+ instead of merging.
+- **linkedIssueFixed** — projection assuming the linked issue is validated and
+ the standard issue multiplier applies.
+- **bestReasonableCase** — the best of the above scenarios; the realistic upper
+ bound across known cleanups.
+
+Each scenario carries its own `scoreEstimate`, `gates`,
+`effectiveEstimatedScore`, `underlyingPotentialScore`,
+`blockedBy`, and a human-readable `deltaExplanation`.
+
+## Language rules
+
+Use `scoreability`, `estimated score`,
+`underlying potential`, and `scoreability status`. Never say
+_guaranteed payout_, _guaranteed reward_, or anything implying outcome
+guarantees.
+
+## Example shape
+
+Trimmed for readability — the real response also includes `laneMath`,
+`gates`, `gateDeltas`, and per-scenario detail:
+
+
+
+`scoreabilityStatus` is one of `blocked`,
+`conditionally_scoreable`, `scoreable`, or `hold` (the repo
+itself isn't actively scoreable — unregistered or inactive allocation).
+
+
+ Scoreability numbers and risk language are **private**. They appear only in MCP/API responses.
+ They are never written to public GitHub surfaces.
+
diff --git a/apps/loopover-ui/content/docs/self-hosting-ai-providers.mdx b/apps/loopover-ui/content/docs/self-hosting-ai-providers.mdx
new file mode 100644
index 0000000000..f1cecbb722
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-ai-providers.mdx
@@ -0,0 +1,197 @@
+---
+title: AI providers
+description: AI review is optional. The deterministic gate still runs when no provider is configured.
+---
+
+## Provider options
+
+
+
+## Single provider
+
+
+ANTHROPIC_AI_MODEL=claude-sonnet-4-6`}
+/>
+
+`ANTHROPIC_AI_BASE_URL` defaults to `https://api.anthropic.com` — set it only to route through a gateway or proxy in front of the real Anthropic API.
+
+## OpenAI API
+
+Distinct from OpenAI-compatible below: this is the native OpenAI API path (`AI_PROVIDER=openai`), for when you have an OpenAI account key rather than a gateway or local endpoint.
+
+
+OPENAI_AI_BASE_URL=https://api.openai.com/v1
+OPENAI_AI_MODEL=gpt-5.5`}
+/>
+
+`OPENAI_AI_BASE_URL` and `OPENAI_AI_MODEL` already default to the values shown — set them only to override the endpoint or model.
+
+## OpenAI-compatible endpoint
+
+
+
+## Ollama (dedicated provider)
+
+`AI_PROVIDER=ollama` is a separate provider id from routing Ollama through `openai-compatible` above — use whichever matches how you want fallback/dual-review chains to identify it. Defaults to a local Ollama at `http://localhost:11434/v1` with no API key.
+
+
+
+Set `OLLAMA_AI_BASE_URL` to `http://ollama:11434/v1` when using the compose `ollama` profile; `OLLAMA_AI_API_KEY` is normally left blank for a local, unauthenticated Ollama instance.
+
+## Fallback and dual review
+
+A comma-list is a fallback chain by default. Use this for subscription CLIs when you want Codex first and Claude Code only when Codex is unavailable or out of tokens.
+
+
+
+Set `AI_DUAL_REVIEW=1` only when you want the first two providers to run as independent reviewers on every PR. In dual-review mode, `AI_COMBINE` controls how decisions are combined.
+
+
+
+
+
+## Claude Code (subscription)
+
+No API key — `claude-code` runs your existing Claude subscription through the `claude` CLI. Generate a long-lived token once with `claude setup-token` and store it as `CLAUDE_CODE_OAUTH_TOKEN`.
+
+
+CLAUDE_AI_EFFORT=medium`}
+/>
+
+**Rotating it:** when the subscription's usage limit resets or the token needs replacing, run `claude setup-token` again and paste the new value into `.env`. `CLAUDE_CODE_OAUTH_TOKEN` is baked into the container at creation time, so a plain restart keeps serving the old value — recreate the service instead:
+
+
+
+Prefer not pasting the raw token into `.env`? Write it into `secrets/claude_code_oauth_token.txt` instead (see [Security](/docs/self-hosting-security)'s secret-file section) and leave `CLAUDE_CODE_OAUTH_TOKEN` unset in `.env` — the same recreate step above picks it up either way.
+
+## Codex (subscription)
+
+Codex has no equivalent of `claude setup-token`. Instead of an environment variable, it stores an OAuth credential file, `auth.json`. Authenticate against the running container so the file lands on the volume the image expects (`/data/codex`, mounted at `~/.codex`):
+
+
+
+This also needs the explicit opt-in shown in the fallback example above (`LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER=1`) — see the Subscription CLI safety note below for why it defaults to off.
+
+
+ Codex reads `auth.json` fresh on every review — each review spawns a new `codex` subprocess. Once
+ `codex auth` succeeds, the very next review authenticates correctly; there is no service to
+ recreate and no env var to change.
+
+
+## Recognizing a stale or missing credential
+
+Both subscription CLIs fail loudly in the self-host logs instead of silently degrading the review:
+
+
+
+## Subscription CLI safety
+
+
+ Subscription CLIs store credentials on disk. Do not mount a writable or prompt-readable CLI home
+ into review execution unless you have isolated it from PR-controlled content. Use an API provider
+ or local OpenAI-compatible endpoint when isolation is not clear.
+
+
+## Related context
+
+AI providers produce the review. [REES](/docs/self-hosting-rees) and [RAG](/docs/self-hosting-rag) add context that the reviewer can use.
diff --git a/apps/loopover-ui/content/docs/self-hosting-backup-scaling.mdx b/apps/loopover-ui/content/docs/self-hosting-backup-scaling.mdx
new file mode 100644
index 0000000000..c943ebaaca
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-backup-scaling.mdx
@@ -0,0 +1,135 @@
+---
+title: Backup and scaling
+description: Choose the right data layout for one node or many, and make sure the review state can be restored.
+---
+
+## Default: SQLite single node
+
+SQLite is the default because it is operationally simple and good enough for a single maintainer instance. The tradeoff is obvious: if the volume is lost, review state is lost.
+
+
+ Do not treat the default data volume as a backup. Snapshot it or enable continuous backup.
+
+
+## Continuous backup with Litestream
+
+
+LITESTREAM_SECRET_ACCESS_KEY=
+LITESTREAM_ENDPOINT=s3.example.com
+LITESTREAM_REGION=us-east-1`}
+/>
+
+
+
+## Scheduled backups
+
+The bundled `backup` profile writes the active app database to the `loopover-backups` volume. SQLite installs use an online backup; Postgres installs use `pg_dump`. The same run also snapshots Qdrant when it is enabled.
+
+
+
+### Retention: how many backups are kept
+
+Each run keeps the newest `BACKUP_RETAIN` backups (default **7**) — applied _independently per target_: `postgres/`, `sqlite/`, and `qdrant/` in the `loopover-backups` volume each retain their own newest 7, not 7 combined across all three. Set it in `.env` to change the window:
+
+
+
+`scripts/backup.sh`'s `normalize_backup_retain` guards against misconfiguration rather than failing the run: a non-numeric or empty value falls back to 7 with a logged warning, and `BACKUP_RETAIN=0` is coerced up to 1 (a retention window of zero would delete the backup the script just took, so the script refuses that rather than leaving you with nothing).
+
+
+ If the SQLite online backup fails verification — the `.backup` command itself fails, the output
+ file is empty, or its `PRAGMA integrity_check` doesn't come back `ok` — the script deletes the bad
+ output, logs the failure, and — critically — **skips the retention prune for the sqlite target on
+ that run**, so a broken backup can never push a known-good one out of the retained window.
+ Postgres and Qdrant retention still run normally on that same pass, since only the SQLite leg
+ failed. The run still exits non-zero so the failure is loud.
+
+
+## Multi-instance: Postgres and Redis
+
+
+
+
+DATABASE_URL=postgres://loopover:@pgbouncer:5432/loopover
+REDIS_URL=redis://redis:6379
+QDRANT_URL=http://qdrant:6333`}
+/>
+
+
+
+PgBouncer pools connections _between instances and Postgres_. Each app instance still opens its own connection pool to whatever it's pointed at (PgBouncer or Postgres directly), shared by every HTTP handler and queue worker in that instance — set `PGPOOL_MAX` (default 10) if a single instance needs more headroom than that under real concurrency (many registered repos, higher `QUEUE_CONCURRENCY`). Raise it gradually and watch for `LoopOverPostgresConnectionPressure`: that alert means you're approaching Postgres's own `max_connections`, a different ceiling than this per-instance pool size.
+
+## One-time SQLite to Postgres copy
+
+Existing SQLite installs can copy state into a fresh Postgres database with the bundled migrator. It dry-runs by default and only commits when `--execute` is present.
+
+@pgbouncer:5432/loopover
+npm run selfhost:postgres:migrate -- --sqlite /data/loopover.sqlite
+npm run selfhost:postgres:migrate -- --sqlite /data/loopover.sqlite --execute`}
+/>
+
+## Restore checks
+
+- Restore to a separate host or volume, never over the live instance first.
+- Boot the app and confirm `/ready` returns 200.
+- Confirm migrations do not fail or reapply incorrectly.
+- Confirm recent review rows and job state are present.
+
+## Verify a backup is restorable
+
+The `backup` profile ships `verify-backup.sh`, which checks the newest backup without touching the live database: Postgres `.dump` archives with `pg_restore --list`, and SQLite `.sqlite.gz` backups with a gzip and `integrity_check` pass. Run it against the newest backup, or a specific file:
+
+.dump`}
+/>
+
+A healthy run ends with `[verify] postgres archive OK: … (N TOC entries)` (or `[verify] sqlite backup OK`), then `[verify] complete`, and exits 0. Corruption, a missing backup, or an empty archive exits non-zero with a `[verify]` reason.
+
+To prove a dump actually restores, opt into a scratch restore into a _throwaway_ database — never the live one:
+
+
+
+
+ The scratch restore runs `pg_restore --clean` against `LOOPOVER_VERIFY_SCRATCH_DATABASE_URL`, so
+ point it at a dedicated database you can afford to drop. The script refuses to run when that URL
+ equals the live backup source.
+
+
+## Restore drill: what "restore-tested" actually verifies
+
+This exact flow has been run against a real production backup on a live instance: the dump was restored into a throwaway, network-isolated scratch database (a separate container, never the live one), which the script's own identity check confirmed was distinct from the backup source before touching anything. The restore completed cleanly and repopulated the application tables with representative production-scale data — not just an empty schema. Table and row counts will grow over time; treat restore-drill observations as point-in-time results, not invariants.
+
+This proves the backup content and the restore path both work end-to-end against real data. It deliberately stops short of booting a full app instance against the scratch database and polling `/ready`: that endpoint also gates on live Redis, Qdrant, the configured AI provider, Codex auth, and a real GitHub App key (see [Operations](/docs/self-hosting-operations)'s health endpoints section) — reproducing all of those for a disposable scratch instance would mean copying real credentials into new, throwaway infrastructure, which is a bigger risk than the drill is worth. This drill proves the dump can be restored and its contents inspected at the database layer — it does not exercise the app's own `db` readiness probe, migration boot path, or `/ready` response. A full disaster-recovery rehearsal still needs to verify app readiness on the target infrastructure, using the operator's own real credentials.
+
+After scaling, revisit [Operations](/docs/self-hosting-operations) and [Security](/docs/self-hosting-security) because network and credential boundaries change.
diff --git a/apps/loopover-ui/content/docs/self-hosting-configuration.mdx b/apps/loopover-ui/content/docs/self-hosting-configuration.mdx
new file mode 100644
index 0000000000..17e93dd61e
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-configuration.mdx
@@ -0,0 +1,426 @@
+---
+title: Configuration
+description: "The self-host configuration model: deployment env, private per-repo policy, feature flags, and review modes."
+---
+
+import { SELFHOST_ENV_REFERENCE_MARKDOWN } from "@/lib/selfhost-env-reference";
+
+This page is the exhaustive reference. For the short path — the required secrets plus a conservative first-boot config — start with `.env.selfhost.example` in [Quickstart](/docs/self-hosting-quickstart) instead.
+
+## Config layers
+
+
+
+## Precedence
+
+Where policy for a given repo can live is one question; which layer wins when more than one is set is another. Most specific wins, in this order:
+
+- the repo's `.loopover.yml` (public repo config, or the mounted private per-repo config file below if `LOOPOVER_REPO_CONFIG_DIR` is set), then
+- the per-repo database settings (the dashboard), then
+- built-in safe defaults.
+
+Within `.loopover.yml` itself, the typed `gate:` block is an alias for the gate-related fields and wins over the generic `settings:` block for those same fields — so a value written under both `gate.duplicates` and `settings.duplicates` resolves to whatever `gate.duplicates` says. One exception to the whole precedence chain: hard path guardrails (`settings.hardGuardrailGlobs`) are config-as-code only — a configured list ADDS repo-specific globs on top of a fixed set of built-in invariant guardrails (config-as-code files, CI workflows/scripts, and core engine-decision paths) that always apply and can never be disabled. Omitted or empty means only those built-in invariants hold, regardless of what the database row or defaults would otherwise imply.
+
+This page covers the environment layer and the shape of the config file. For the full field list — every `gate:` and `settings:` key, its default, and what it does — see [Tuning your reviews](/docs/tuning), and for copy-paste templates see the table below (also shipped inside the self-host image at `config/examples/`).
+
+## Config templates
+
+Start from a template instead of reverse-engineering env flags, private-config precedence, and the parser. Every template uses the same schema for a public repo-root `.loopover.yml` or a container-private `LOOPOVER_REPO_CONFIG_DIR` mount — only what you put in each file differs.
+
+
+
+
+
+
+ Keep anti-abuse thresholds, maintainer allowlists, and autonomy dials in the **private** mount —
+ not in a public `.loopover.yml` contributors can read. `config/examples/TEMPLATES.md` documents
+ the public-vs-private split and how to apply the templates to `loopover`, `awesome-claude`, and
+ `metagraphed` without committing private policy. Lint before deploy: `npx tsx
+ scripts/loopover-config-lint.ts path/to/.loopover.yml`.
+
+
+Authoritative copies in git:
+
+- [`config/examples/loopover.minimal.yml`](https://github.com/JSONbored/loopover/blob/main/config/examples/loopover.minimal.yml)
+- [`config/examples/loopover.full.yml`](https://github.com/JSONbored/loopover/blob/main/config/examples/loopover.full.yml) (same body as [`.loopover.yml.example`](https://github.com/JSONbored/loopover/blob/main/.loopover.yml.example))
+- [`config/examples/TEMPLATES.md`](https://github.com/JSONbored/loopover/blob/main/config/examples/TEMPLATES.md) — catalog + fleet usage notes
+- [`config/examples/README.md`](https://github.com/JSONbored/loopover/blob/main/config/examples/README.md) — the full private-config layout, precedence chain, and deep-merge semantics, including the shared base layer
+
+Several gate-only fields are documented only in the full template comments — see below for the config-as-code blocks with no dashboard equivalent.
+
+## Required baseline env
+
+
+
+GITTENSOR_REGISTRY_URL=https://example.invalid/registry.json
+LOOPOVER_API_TOKEN=
+LOOPOVER_MCP_TOKEN=
+INTERNAL_JOB_TOKEN=`}
+/>
+
+Any `FOO_FILE` is loaded into `FOO` at startup. Explicit `FOO` wins over the file variant.
+
+Every command example on these docs pages hardcodes `:8787` — that's the default, not a fixed port. Set `PORT` to listen on something else; update your compose port mapping and any `curl`/health-check commands to match.
+
+
+ `LOOPOVER_MCP_TOKEN` is a shared, end-user-obtainable CLI credential (the normal alternative to
+ `loopover-mcp login`), so it must not implicitly stage actions (merges, closes, approvals) on
+ every repo the App happens to be installed on. `MCP_ACTUATION_REPO_ALLOWLIST` scopes it to an
+ explicit, comma/whitespace-separated `owner/repo` list — **unset denies all actuation** for this
+ token. Set it to `*` or `all` to opt back into the pre-scoping, any-repo behavior. If you already
+ rely on `LOOPOVER_MCP_TOKEN` for approval-queue actuation, set this variable after upgrading or
+ MCP actuation stops working.
+
+
+
+
+`MCP_READ_REPO_ALLOWLIST` is the same fail-closed/wildcard model, kept as a **separate** allowlist so read-only MCP tools (repo context, issue quality, watch subscriptions) can be granted independently of actuation trust. The full `*`/`all` wildcard additionally unlocks the non-repo-scoped contributor/operator tools.
+
+## Data paths
+
+- `MIGRATIONS_DIR` (default `migrations`) — where the self-host runtime looks for SQL migration files to auto-apply at boot. Only relevant for a custom build that ships migrations somewhere other than the default in-image location.
+- `REVIEW_AUDIT_DIR` — when set, persists visual-review screenshot PNGs to this filesystem path so they're served from cache instead of re-rendered on every request. Unset means each screenshot is re-rendered on demand. Only relevant when `BROWSER_WS_ENDPOINT` (see [REES enrichment](/docs/self-hosting-rees)) is also set — visual review is fully inert without it.
+- `REVIEW_AUDIT_S3_BUCKET` / `_ENDPOINT` / `_ACCESS_KEY_ID` / `_SECRET_ACCESS_KEY` — an alternative to `REVIEW_AUDIT_DIR`: persist screenshots in an S3-compatible bucket (your own Cloudflare R2 bucket, or any other S3-compatible provider) instead of the local filesystem, and set `REVIEW_AUDIT_S3_PUBLIC_URL` to that bucket's own public base URL so screenshots link directly at the bucket instead of proxying through this instance. This matters if your instance sits behind a private network (a VPN, a firewall, no public DNS) — without a public bucket, screenshots embedded in a public PR comment are unreachable by GitHub and by anyone viewing the PR who isn't on that same private network. Takes priority over `REVIEW_AUDIT_DIR` when both are set.
+- `CODEX_HOME` — do not set this for the app container. The Codex provider rejects a container-set `CODEX_HOME` outright (fails closed with `codex_credential_isolation_required`) because `codex exec` reads attacker-controlled PR title/body/diff text, and a mounted OAuth home on the same filesystem could otherwise leak into review output via prompt injection. This is why the Codex subscription path additionally requires the explicit `LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER=1` opt-in — see [AI providers](/docs/self-hosting-ai-providers).
+
+## GitHub API cache
+
+Redis backs shared caching for stable GitHub GET responses, including repeated installation, repo/user metadata, and branch-protection required-status reads. Keys include the caller identity and response-shaping headers, and cold misses are single-flighted so concurrent jobs do not stampede GitHub.
+
+
+
+
+ `GITHUB_CACHE_TTL_SECONDS` is the short default for repeated safe GitHub GETs. Stable repo/user
+ metadata and branch-protection required-status reads use the per-class TTLs above so operators can
+ keep repeated policy reads hot without broadening stale cache risk. Live CI status, check-run,
+ check-suite, pull/issue subresources, pull mergeability, token minting, rate-limit, and
+ collaborator-permission endpoints are never served from this cache. Prometheus exports
+ `loopover_github_response_cache_total`, and the bundled self-host Grafana dashboard includes the
+ hit/miss/coalesced/error breakdown.
+
+
+## Queue cadence and startup
+
+`CRON_INTERVAL_MS` (default `120000`, ~2 minutes) is the tick that drives the maintain/sweep and sync cadence — contributor evidence, burden forecasts, RAG re-indexing, drift scans, and notifications all fan out from it. `QUEUE_BACKGROUND_CONCURRENCY` (default `1`) caps how many low-priority background jobs may occupy a `QUEUE_CONCURRENCY` slot at once, independent of live webhook/review work.
+
+`QUEUE_STARTUP_JITTER_MIN_JOBS` (default `8`) sets the pending-job count below which the queue skips its startup jitter delay — useful on a small instance where you'd rather a handful of jobs start processing immediately after boot than wait out a jitter window meant to stagger many instances restarting at once.
+
+## Maintenance and installation backpressure
+
+Two independent, opt-out admission checks run at queue-claim time, on top of GitHub rate-limit deferral, so background work never starves live PR review or overloads the host. Both grew out of real production incidents — an un-jittered cron enqueue and an unbounded per-installation background fan-out — and every value below is optional with a sane default.
+
+
+
+Tune `MAINTENANCE_ADMISSION_MAX_LIVE_PENDING` (default `5`), `MAINTENANCE_ADMISSION_MAX_LIVE_AGE_MS` (default `120000`), `MAINTENANCE_ADMISSION_MAX_PENDING` (default `15`), `MAINTENANCE_ADMISSION_MAX_HOST_LOAD` (default `1.5`, a 1-minute load-average-per-core ceiling), and `MAINTENANCE_ADMISSION_MAX_BACKLOG_CONVERGENCE_PENDING` (default `10`) if you register many repos or run a busy instance and see maintenance sweeps lagging behind where you'd like. A denial backs off by `MAINTENANCE_ADMISSION_DEFER_MS` (default `180000`, 3 minutes) before jitter, but two escape hatches stop a deferral from becoming a starve: `MAINTENANCE_ADMISSION_MAX_DEFER_AGE_MS` (default `14400000`, 4 hours) force-admits any maintenance job that has waited this long regardless of pressure, and the shorter `MAINTENANCE_ADMISSION_DRAIN_AGE_MS` (default `600000`, 10 minutes, clamped to the 4-hour ceiling) specifically drains the oldest jobs in a backed-up `maintenance_pending_high` lane so it can actually shrink instead of denying every claim for hours. Set `MAINTENANCE_ADMISSION_ENABLED=false` to fully disable the policy and return to the old always-run behavior.
+
+`GITHUB_INSTALLATION_CONCURRENCY_LIMIT` (default `2`) is the per-installation ceiling; `GITHUB_INSTALLATION_CONCURRENCY_DEFER_MS` (default `15000`, 15 seconds) is its base backoff before jitter. Raise the limit if a single large installation's background work is being throttled and you have GitHub rate-limit and host headroom to spare; set `GITHUB_INSTALLATION_CONCURRENCY_ENABLED=false` to disable the check entirely. This check only applies to background jobs that call GitHub — live PR review (`github-webhook`/`agent-regate-pr`) is never subject to it.
+
+
+ Where the two backpressure checks above defer background work, foreground liveness protects live
+ PR-review work FROM unbounded rate-limit deferral (its own worst case is up to ~65 minutes per
+ defer under sustained pressure, e.g. right after a deploy floods a shared REST budget). A periodic
+ sweep force-releases any foreground-priority job that has genuinely waited past
+ `FOREGROUND_LIVENESS_MAX_DEFER_MS` (default `600000`, 10 minutes), checked every
+ `FOREGROUND_LIVENESS_CHECK_INTERVAL_MS` (default `60000`, 1 minute — deliberately not the 1-second
+ poll tick, so a job that is still genuinely rate-limited waits for the next sweep instead of
+ busy-looping), releasing at most `FOREGROUND_LIVENESS_MAX_RELEASE_PER_SWEEP` jobs per tick
+ (default `25`, oldest first, so a large inherited backlog ramps up gradually instead of every
+ released job re-tripping the same rate-limit bucket at once). It also runs once at boot, so a
+ restart self-heals inherited over-deferral. Set `FOREGROUND_LIVENESS_ENABLED=false` to disable the
+ sweep.
+
+
+## Tracing and telemetry env
+
+`OTEL_EXPORTER_OTLP_ENDPOINT` overrides the OpenTelemetry collector target only if you're routing to an external collector instead of the bundled one (default `http://otel-collector:4318` under the `observability` profile). `OTEL_SERVICE_NAME` (default `gittensory-selfhost`) is the service name traces and metrics are tagged with — set a distinct value per instance if you run more than one and want to tell them apart in Grafana/Tempo. `OTEL_TRACES_SAMPLER` (default `parentbased_traceidratio`) picks the sampling strategy for app job/provider traces; pair it with `OTEL_TRACES_SAMPLER_ARG` (for example `0.05` to sample 5% of root traces).
+
+## Generated env reference
+
+This table is generated from `process.env.NAME` reads in `src/selfhost/**` and `src/server.ts`. It intentionally includes names and first source references only, never example values.
+
+
+
+## Repo activation — three layers
+
+Self-host docs and logs use "activation" for more than one mechanism. They stack independently:
+
+
+
+Preview before flipping: `GET /v1/repos/:owner/:repo/activation-preview` runs the deterministic advisory engine over recent cached PRs (no AI cost) and returns a `recommendedAction` of `enable_advisory` when the gate is still off.
+
+## Per-PR feature flags
+
+Most review capabilities need both their own flag and the repo in `LOOPOVER_REVIEW_REPOS` (unless a per-repo `features:` override says otherwise). This gives you a global kill switch and a per-repo rollout switch.
+
+
+
+
+ Empty `LOOPOVER_REVIEW_REPOS` means no repos run the per-PR feature path, regardless of the
+ individual flags.
+
+
+## Private per-repo config
+
+Mount a gitignored directory and point `LOOPOVER_REPO_CONFIG_DIR` at it. If either a per-repo file or the dir-root global default (`.loopover.yml` at the mount root) exists, the public repo `.loopover.yml` is never fetched for that review. With only one of the two present, its contents are used as-is; with both present, they are deep-merged — the per-repo file overlaid onto the global default, nested mappings merging key by key and arrays replacing wholesale.
+
+
+
+
+
+The `features:` block above overrides a deployment-wide `LOOPOVER_REVIEW_*` flag (rag, reputation, safety) for this one repo, with three states per key: `true` forces the capability on for this repo (still subject to the env flag itself being enabled — it can never turn on a capability the operator has fully disabled at the deployment level); `false` forces it off for this repo regardless of the env flag; and omitting the key entirely falls back to the `LOOPOVER_REVIEW_REPOS` allowlist default, i.e. today's behavior for an operator who hasn't set anything here. See [Tuning your reviews](/docs/tuning) for the full `LOOPOVER_REVIEW_*` flag list this overrides.
+
+## Config-as-code blocks with no dashboard equivalent
+
+Everything above has a dashboard row it mirrors. The fields below exist **only** in `.loopover.yml` — there is no DB column or dashboard toggle for them, so a self-host operator who never reads the example file may not know they exist.
+
+### gate.checkMode
+
+Controls only whether/how the required `LoopOver Orb Review Agent` check-run is published — it never affects gate evaluation, comments, labels, audit records, or autonomous merge/close, all of which run identically in every mode. Takes precedence over the legacy `gate.enabled` boolean when both are set.
+
+
+
+
+ Before switching to `disabled`, remove `LoopOver Orb Review Agent` from this repo's
+ branch-protection or ruleset required-status-checks list — LoopOver cannot do this on your behalf,
+ and leaving it required with nothing to satisfy it means GitHub shows a pending status forever.
+ Keep your real CI/Codecov/security checks required; this setting only ever affects LoopOver's own
+ check-run.
+
+
+For a repo that has never been configured, the default is `disabled`; an already-configured repo keeps its current effective behavior. Self-hosters running high-volume autonomous review should prefer `visible` or `disabled` over `required` — LoopOver's own merge/close decisions never depend on this check either way.
+
+### Other gate-only fields
+
+- `gate.cla` — sub-object for the CLA gate (`gate.claMode`, documented on [Tuning your reviews](/docs/tuning)): `consentPhrase` (a case-insensitive substring LoopOver looks for in the PR description), `checkRunName` (an existing CLA-bot check-run name that also satisfies consent), and `checkRunAppSlug` (the trusted App slug required to have produced that check-run, so a contributor-controlled same-name check can't satisfy a blocking legal gate). Either detection method is enough; both may be set. All default to `null` (not configured).
+- `gate.expectedCiContexts` — CI check/status context names to treat as required when GitHub branch protection returns no readable required-status-checks (unconfigured, or a 403 from a token lacking `administration:read` — common for GitHub App installations, especially self-host). Merged with branch-protection contexts when both are readable; used alone when branch protection is null/empty. Default: not configured, which keeps the fold-all fail-closed behavior when branch protection is also unreadable.
+- `gate.premergeContentRecheck` — when `true`, a PR touching `migrations/**` gets a fresh GitHub read of the base branch's current migration filenames immediately before an agent-driven merge, catching a different PR that merged a same-numbered migration in the meantime. A live collision holds the PR instead of merging blind. Default `false` — costs one extra GitHub API call per migrations-touching PR.
+- `gate.requireFreshRebaseWindow` — when the base branch has advanced within this many minutes of the actual merge decision, forces an `update_branch` + fresh CI recheck before merging, instead of trusting a possibly-stale `mergeable_state: clean` read. A bounded retry cap prevents a fast-moving base from live-locking the PR. Default `null` (never force).
+- `gate.dryRun` — when `true`, the posted check conclusion remains the real non-enforcing verdict while comments/check text may also show the would-be stricter verdict for AI-review blocker mode. It does not disable downstream merge/close planning for failures from already-enforced gates. Default `false`.
+- `gate.firstTimeContributorGrace` — reserved and currently inert: parsed and stored, but the gate does not read it. A first-time contributor with a real blocker is one-shot closed the same as a repeat contributor. Kept for potential future use.
+
+### settings.closeOwnerAuthors and blockedPaths
+
+`settings.closeOwnerAuthors` — by default, the repo owner's own PRs (and `ADMIN_GITHUB_LOGINS` fleet-operator PRs) are never auto-closed; they may still auto-merge when clean and passing, or fall to a manual hold. Set `true` to make owner/admin-authored PRs eligible for auto-close like a contributor's, still gated by the close autonomy class and adverse-signal conditions. Automation-bot PRs stay exempt regardless of this setting. Default `false`.
+
+`blockedPaths` (top-level, alongside `wantedPaths`) is **fully retired** (#2974) — the FocusManifest parser no longer reads this key at all, it produces zero findings, and it is not enforceable under any `gate.manifestPolicy` mode. Setting it in a config produces only a migration warning from `npm run selfhost:config-lint`, nothing else. **The only mechanism that actually holds a PR for a touched path** is `settings.hardGuardrailGlobs` (config-as-code only, described above) — a would-merge PR that touches a configured guardrail glob is held for manual review.
+
+### settings anti-abuse block
+
+A cluster of contributor-abuse guardrails, all config-as-code only, all off/unset by default:
+
+- **Open-item caps** — `contributorOpenPrCap` and `contributorOpenIssueCap` bound how many PRs/issues a single non-owner/non-admin/non-bot contributor may have open at once; a contributor's newest item above the cap is closed with a clear reason, their oldest items up to the cap stay open. Both are unset (no cap) by default. `contributorCapLabel` (default `over-contributor-limit`) is the label applied on close — set it to explicit `null` to close silently. `contributorCapCancelCi` cancels in-flight CI runs on a cap-triggered close (requires the `actions: write` App permission; degrades gracefully without it) and falls back to the `CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT` env var when unset.
+- **Review-nag cooldown** — `reviewNagPolicy` (`off`/`hold`/`close`, default `off`) throttles a contributor who repeatedly pings `@loopover` for review on the same PR/issue, once they exceed `reviewNagMaxPings` (default `3`) within `reviewNagCooldownDays` (default `5`). `reviewNagLabel` (default `review-nag-cooldown`) is applied alongside the hold/close action. `reviewNagMonitoredMentions` extends the same cooldown to specific maintainer logins a contributor keeps tagging directly instead of (or in addition to) `@loopover`.
+- **Exemptions and account age** — `autoCloseExemptLogins` is a shared, repo-scoped list of logins never throttled or closed by these deterministic mechanisms, on top of the standing owner/admin/bot exemption. `accountAgeThresholdDays` (default `null`, off) applies `newAccountLabel` (default `new-account`) to a PR from a below-threshold-age account — friction/visibility only, never an automatic close on account age alone, and never for the owner, admins, or bots.
+- **Command rate limit** — `commandRateLimitPolicy` (`off`/`hold`, default `off`) generalizes the review-nag pattern to every `@loopover` command, not just review-request pings. `commandRateLimitMaxPerWindow` (default `20`) bounds cheap, cache-only commands; `commandRateLimitAiMaxPerWindow` (default `5`) is the tighter limit for AI-cost-bearing commands (ask/blockers/preflight/etc.); `commandRateLimitWindowHours` (default `24`) is the rolling window both limits count against.
+
+### contentLane
+
+Lets a self-hosted maintainer point LoopOver at their own structured registry (a subnet/plugin/package catalog, for example) without a LoopOver code change — reviewing additions to a data file the same way it reviews code. Unconfigured by default; uncomment and set at least `entryFileGlob` and `collectionField` (both required — the whole block is ignored with a warning if either is missing).
+
+
+
+### repoDocGeneration
+
+Lets LoopOver open a pull request that refreshes this repo's own `AGENTS.md`/`CLAUDE.md` (and, additively, a skill file) on a schedule — never a direct commit. Disabled by default: an unconfigured repo, or an explicit `enabled: false`, means no repo-doc refresh ever runs for it.
+
+
+
+## Instance-wide write switches (SELFHOST_DEPLOYMENT_MODE)
+
+`SELFHOST_DEPLOYMENT_MODE` forces write suppression for the whole instance, regardless of per-repo autonomy — useful for running a self-host in parallel with the live cloud App on the same webhooks, provably posting nothing until an explicit cutover.
+
+
+
+## Next steps
+
+Configure the GitHub integration in [GitHub App and Orb](/docs/self-hosting-github-app), then add optional context through [AI providers](/docs/self-hosting-ai-providers), [REES](/docs/self-hosting-rees), or [RAG](/docs/self-hosting-rag). For the full gate-mode and per-repo settings reference — including the AI-review combine modes and a complete worked manifest — see [Tuning your reviews](/docs/tuning).
diff --git a/apps/loopover-ui/content/docs/self-hosting-docs-audit.mdx b/apps/loopover-ui/content/docs/self-hosting-docs-audit.mdx
new file mode 100644
index 0000000000..bab92eef75
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-docs-audit.mdx
@@ -0,0 +1,114 @@
+---
+title: Docs accuracy audit
+description: Before a self-host release, use this checklist to confirm website docs still match runtime defaults. Each row links a docs page to the files and CI drift guards that must stay aligned.
+---
+
+import { Link } from "@tanstack/react-router";
+import {
+ LOOSE_DOCS_ROWS,
+ SELFHOST_DOCS_PAGES,
+ SELFHOST_DOCS_VALIDATION_COMMANDS,
+ SELFHOST_SOURCE_OF_TRUTH_ROWS,
+} from "@/lib/selfhost-docs-audit";
+
+
+ This page is the in-repo paper trail for the self-host docs audit under roadmap
+ [#1819](https://github.com/JSONbored/loopover/issues/1819). REES analyzer metadata generation is
+ tracked separately on the REES roadmap — the analyzer reference page covers names and shapes;
+ auto-generated metadata tables are out of scope here.
+
+
+## Website docs navigation
+
+Self-hosting docs live on the website under [Self-hosted reviews](/docs/maintainer-self-hosting). Every child page below is linked from that index and from the maintainer docs hub.
+
+
+
+## Source-of-truth checklist
+
+When you change runtime behavior, update the docs page **and** extend the drift guard test when one exists. Env vars must stay aligned with `npm run selfhost:env-reference`; observability configs with `npm run selfhost:validate-observability`.
+
+
+
+## Defaults, optional services, and experimental surfaces
+
+- **Core stack (default):** loopover + Redis + SQLite on the mounted data volume; `SELFHOST_DEPLOYMENT_MODE=dry-run` in `.env.selfhost.example`.
+- **Optional profiles:** Postgres, REES sidecar, observability (Prometheus, Grafana, OTEL, Tempo, Loki), and backup — each documented on its concern page; none are required for a first healthy instance.
+- **Sentry:** opt-in and off by default — set an operator-owned `SENTRY_DSN` or mount `SENTRY_DSN_FILE`; see [Operations](/docs/self-hosting-operations).
+- **AI / RAG / REES:** off in the starter env until webhook delivery and deterministic review are verified; each has explicit enablement switches.
+- **Operator-owned paths:** `loopover-config/`, `loopover-data`, and secrets via `.env` or `*_FILE` mounts — never baked into images.
+
+## Loose docs consolidation
+
+Root-level markdown outside the website must either link to the canonical website page or stay intentionally separate (convergence runbooks, contributor notes). Do not duplicate self-host operator instructions in a second place that will drift.
+
+
+
+## Validation commands
+
+Run from the repo root before merging docs or cutting an orb release:
+
+
+
+
+ Spot-check documented shell commands against the current `docker-compose.yml` profiles and release
+ scripts when you touch operator-facing prose — CI drift guards cover the highest-risk surfaces but
+ not every copy-pasted example.
+
diff --git a/apps/loopover-ui/content/docs/self-hosting-github-app.mdx b/apps/loopover-ui/content/docs/self-hosting-github-app.mdx
new file mode 100644
index 0000000000..0bffb9749c
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-github-app.mdx
@@ -0,0 +1,265 @@
+---
+title: GitHub App and Orb
+description: A self-host needs webhook delivery and installation tokens. Direct GitHub App is the default, recommended model -- Orb broker mode is private/managed-beta only.
+---
+
+## Choose a connection mode
+
+
+
+
+ Direct App mode is the public default: it costs LoopOver nothing to support and can't overrun a
+ shared rate-limit budget. Brokered mode routes every token mint through LoopOver's own
+ infrastructure and GitHub API quota — every external brokered install is LoopOver's rate-limit and
+ reliability problem, not just the operator's, so it stays private/managed-beta until the
+ safeguards below are in place.
+
+
+## One-click App creation (recommended for a Direct App)
+
+Before the App exists (no `GITHUB_APP_ID` set yet), the self-host serves a setup wizard at
+`GET /setup`. It renders a form that POSTs a GitHub App _manifest_ — the exact permission and
+event set below, pre-filled — to GitHub's own App-creation flow. GitHub creates the App with the
+correct configuration in one step and redirects back to exchange credentials automatically; there
+is no manual permission checklist to get right or wrong. The route is disabled once an App is
+configured, so it can't rebind a live install.
+
+
+
+
+
+Enter `SELFHOST_SETUP_TOKEN` in the browser form. For scripted setup checks, send the token in an
+`x-setup-token` header or `Authorization: Bearer` header instead; never place the setup token in
+the URL.
+
+
+ `https://reviews.example.com` above is a placeholder — it assumes you already have a real domain
+ terminating TLS. GitHub delivers webhooks to whatever `PUBLIC_API_ORIGIN` you set here, so it must
+ be an address GitHub's servers can actually reach: the `caddy` profile (see
+ [Security](/docs/self-hosting-security)'s TLS termination section) is the shipped way to get one,
+ or bring your own public reverse proxy. The `tailscale` profile's private tailnet address does
+ **not** work here — GitHub cannot deliver webhooks to it. A Tailscale-only instance should use
+ brokered pull mode instead (it polls for work rather than receiving pushed webhooks) — see "Pull
+ vs. push relay mode" below.
+
+
+
+ Manual App creation (below) is still fully supported — for an air-gapped instance, a stricter
+ change-review process, or simply a preference for reviewing every permission by hand before it
+ exists. Whichever path you take, the resulting App needs the SAME permissions: this doc's manual
+ list is kept in sync with the wizard's manifest and checked in CI, so the two can't silently drift
+ apart.
+
+
+## Direct App permissions
+
+- Pull requests: write.
+- Checks: write — the gate posts a check-run; `checks: read` alone 403s that write (silently
+ fails the first review with no obvious cause).
+- Issues: write.
+- Contents: write — required for BOTH merging and the auto-maintain `update_branch` action.
+ `contents: read` looks sufficient at creation time but silently breaks auto-merge later with no
+ error surfaced in the UI; there is no lesser permission that keeps merge/update-branch working.
+- Commit statuses: read.
+- Metadata: read.
+- Actions: write — lets a repo opt into cancelling a closed PR's in-flight CI runs (the
+ `contributorCapCancelCi` setting). Off by default and never required: a repo that doesn't enable
+ it, or an installation that hasn't re-approved this permission on an existing App, sees no
+ behavior change — the cancellation attempt is skipped and logged, never blocking the close
+ itself.
+
+Events: pull request, pull request review, push, issues, check suite, check run, and status.
+
+## Re-approving a permission bump on an existing App
+
+A future release can widen this permission list (most recently, Actions: write for the opt-in
+CI-cancellation feature). GitHub does **not** silently grant a new permission to an App that's
+already installed — the operator who owns the App must explicitly re-approve it, the same
+one-time consent step as the original install.
+
+Until you re-approve, the self-host keeps working exactly as before: any feature that needs the
+new permission degrades gracefully (skipped and logged, never a hard failure) rather than
+erroring. There's no forced upgrade window.
+
+To re-approve:
+
+1. Open your App's settings page — `https://github.com/settings/apps//permissions`
+ (organization Apps:
+ `https://github.com/organizations//settings/apps//permissions`).
+2. GitHub shows a diff between the App's currently-granted permissions and what the App manifest
+ now requests. Review it, then save — GitHub sends the installation owner a request to accept
+ the new grant.
+3. Accept the request (as the installation owner, on each installed org/account). The new
+ permission takes effect immediately; no App reinstall or webhook resubscription needed.
+
+## Direct App env
+
+`}
+/>
+
+## Telemetry is separate from token brokerage
+
+These are two independent things people conflate because they're both "Orb": anonymized
+fleet-calibration **telemetry export** (enabled by default, works in either connection mode) and
+**token brokerage** (optional, private/managed-beta only, lets your self-host get installation
+tokens from LoopOver instead of holding its own App key). Choosing Direct App mode does not opt
+you out of telemetry, and it's what makes [the homepage counters](/) and cross-fleet gate
+calibration reflect direct installs, not just brokered ones.
+
+
+
+
+ Repo/PR identifiers are HMAC-anonymized by **default** (`ORB_ANONYMIZE=true`), not unconditionally
+ — an operator can set `ORB_ANONYMIZE=false` to export raw repo/PR names instead. There's no
+ scenario where LoopOver's own hosted collector needs raw names; the toggle exists for an operator
+ running their **own** collector (see `ORB_COLLECTOR_URL` below) who wants readable identifiers in
+ their own infrastructure. Leave this at the default unless you control the collector end.
+
+
+`ORB_COLLECTOR_URL` overrides the export endpoint — default LoopOver's hosted collector, or point
+it at your own private collector if you're aggregating telemetry yourself instead of sending it to
+LoopOver. `ORB_COLLECTOR_TOKEN` is the bearer credential for that private collector; leave it unset
+when using LoopOver's own hosted collector, which accepts unauthenticated, rate-limited,
+aggregate-only exports.
+
+## Brokered Orb env
+
+
+ORB_BROKER_URL=https://api.loopover.ai
+ORB_RELAY_MODE=pull # or omit for push (the default) -- see "Choosing a relay mode" below`}
+/>
+
+`ORB_APP_ID` overrides the seed used to derive this instance's stable, anonymous `instance_id` in
+telemetry exports — normally derived from `GITHUB_APP_ID`. A brokered instance holds no App ID of
+its own (it uses the broker's tokens instead), so its identity falls back to the export secret
+unless you set `ORB_APP_ID` explicitly. Most operators never need to set this; it exists so a
+brokered instance's telemetry identity can be pinned independent of any App ID.
+
+## Choosing a relay mode: pull vs. push
+
+Brokered mode still needs a way for GitHub webhook events to reach your self-host through the
+broker. `ORB_RELAY_MODE` picks how:
+
+
+
+
+ If you're not behind a stable public ingress — a home connection, a NAT without port forwarding, a
+ tailnet-only deployment — set `ORB_RELAY_MODE=pull`. It needs no DNS record, TLS certificate, or
+ firewall rule of its own, and tolerates a transient broker outage more gracefully (see the release
+ checklist's known-warnings table below). Use push only once you already have a stable, publicly
+ reachable HTTPS origin for this instance — the Direct App setup wizard, for instance, always
+ requires one anyway, so an operator running Direct App today has it available for brokered push
+ mode too. See [Security](/docs/self-hosting-security)'s TLS termination section for how to stand
+ one up: the `caddy` profile for a public domain, or note that `tailscale`'s private tailnet
+ address does not satisfy push mode's internet-reachable requirement — pull mode is the right fit
+ for a Tailscale-only instance.
+
+
+
+ Before enabling this for anyone outside a controlled managed-beta cohort, weigh: (1) **rate-limit
+ blast radius** — every brokered install's GitHub API traffic draws from token pools LoopOver
+ manages, so one misbehaving or high-volume install can degrade every other brokered install; (2)
+ **quota management** — there is no automatic per-install cap on how much of that shared budget one
+ enrollment can consume; (3) **support burden** — a broken brokered install looks like a LoopOver
+ outage to its operator, not a self-host misconfiguration, and lands as a support request on
+ LoopOver directly; (4) **abuse/misconfiguration risk** — an enrollment secret that leaks or a
+ misconfigured relay can mint tokens or receive webhook traffic for repos the intended operator
+ doesn't control.
+
+
+## Minimum broker safeguards before a public rollout
+
+A maintainer go/no-go checklist — do not open brokered enrollment beyond a small, known,
+controlled cohort until every item below is true:
+
+- **Enrollment quota** — a hard cap on how many brokered installs can be active at once, not just
+ an informal agreement.
+- **Per-install concurrency limit** — one brokered install cannot occupy an unbounded share of the
+ token-minting or webhook-relay pipeline.
+- **Per-install rate budget** — a ceiling on GitHub API calls attributable to a single enrollment,
+ independent of the other installs sharing the broker.
+- **Revocation path** — an enrollment secret can be revoked immediately, without waiting for a
+ deploy, when it's compromised or the install is abusive.
+- **Metrics broken out by enrollment** — token-mint volume, webhook-relay volume, and error rate
+ are visible per-enrollment, not only aggregated across every brokered install, so one bad actor
+ is identifiable instead of hiding in the average.
+
+See [Troubleshooting](/docs/self-hosting-troubleshooting) for what a degraded brokered relay looks
+like in logs today, and [the release checklist](/docs/self-hosting-release-checklist)'s
+brokered-mode scenario for the smoke tests that exercise both relay modes.
+
+## Connectivity checks
+
+Confirm you can reach the instance at all before checking GitHub's own webhook delivery:
+
+
+
+`reviews.example.com` here stands in for whatever you're checking from — the `caddy` profile's
+domain, an existing reverse proxy, or (if you're on the same tailnet) a Tailscale instance's
+tailnet address on port 8787. This only confirms _you_ can reach the instance, not that _GitHub_
+can — a Tailscale-only instance in push mode will pass this check and still never receive a real
+webhook, since GitHub itself cannot reach a private tailnet address (see the callout above on
+`PUBLIC_API_ORIGIN`).
+
+After installing the App on a test repo, open a small PR and confirm the webhook delivery appears
+in GitHub and a job appears in self-host logs — this is the check that actually proves GitHub can
+reach you. Continue with [Operations](/docs/self-hosting-operations) for log and metric checks.
diff --git a/apps/loopover-ui/content/docs/self-hosting-operations.mdx b/apps/loopover-ui/content/docs/self-hosting-operations.mdx
new file mode 100644
index 0000000000..86a2e3c1c8
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-operations.mdx
@@ -0,0 +1,1213 @@
+---
+title: Operations
+description: "Daily operating checks for the review service: health, queue, logs, metrics, dashboards, and context services."
+---
+
+## Health endpoints
+
+
+
+## Useful commands
+
+
+
+## Important log events
+
+
+
+## Observability profile
+
+The observability profile starts Prometheus, Alertmanager, Loki, Promtail, and Grafana with
+dashboards for infra, review activity, and AI usage.
+
+Postgres installs also expose database internals through the bundled Postgres exporter:
+connection pressure, lock waits, long transactions, deadlocks, database/table growth, dead
+tuples, autovacuum activity, and backup freshness. Backup freshness appears when the
+`backup` profile is active.
+
+When OpenTelemetry and Sentry are enabled, job audit logs and Sentry events include
+trace_id/span_id fields so an operator can jump from a failed job or issue to the matching
+trace in Grafana or Tempo.
+
+
+
+
+
+## Host clock sync (NTP)
+
+
+ GitHub App JWTs are signed with a timestamp from this process's clock, backdated 60 seconds for
+ skew tolerance. If the host clock drifts past that margin, GitHub starts rejecting the JWT as
+ not-yet-valid — **every** GitHub App request fails with a generic `Bad credentials` error, with no
+ obvious link back to the clock. Configure at least two independent NTP sources on the host (not
+ just in the container) so a single dead source can't silently take the whole clock out from under
+ you.
+
+
+Check sync health with `chronyc sources` (or `ntpq -p` on an
+`ntpd` host) — every configured source should show a nonzero `Reach`
+value; `Reach: 0` means that source has never successfully synced. The
+`loopover_clock_skew_seconds` gauge on the **Clock Sync (NTP)** row
+of the main Grafana dashboard tracks the live drift between this process and GitHub's server
+time, sampled from the `Date` header of the GitHub App's own installation-token
+mint calls — no extra network probe required. The bundled Prometheus rules alert at 60s
+(warning) and 120s (critical) drift, both well under the margin that actually breaks JWT
+auth.
+
+## Alerting — required for a 24/7 deployment
+
+Alertmanager ships with a valid but **silent** default: every alert routes to a
+name-only receiver that discards it, so
+`docker compose --profile observability up -d` always starts clean even before
+you've configured anywhere to send notifications. This is intentional — the shipped config
+can't bake in a Slack/Discord/email destination that works for everyone — but it means
+nothing pages anyone until you enable a real receiver. Treat this as a required step, not an
+optional one, for any deployment you expect to run unattended.
+
+Don't edit the committed `alertmanager/alertmanager.yml` in place — deploys
+`git pull` this repo, so a local edit to a tracked file either blocks the next
+pull or gets silently overwritten by it. Instead, copy it to a gitignored
+`alertmanager/alertmanager.local` (matches the existing `*.local`
+ignore rule) and make your receiver/route changes there — the fastest verified path is
+uncommenting the `discord` receiver block and pointing the root route at it,
+using `webhook_url_file: /etc/alertmanager/discord_url` so the webhook URL itself
+lives in its own gitignored file next to it, never in a file docker-compose.yml or git ever
+tracks. Slack, email, and a generic webhook receiver (for PagerDuty or a custom handler) are
+also ready to uncomment in the same template. Then point Alertmanager at your local copy via
+`docker-compose.override.yml`:
+
+
+
+Restart with `docker compose up -d --no-deps alertmanager` to pick up both files.
+The whole `alertmanager/` directory is mounted read-only into the container, so
+any gitignored file you add there (the local config, a secret file it references) shows up
+at the same path with no docker-compose.yml edit required.
+
+Until you do, alerts are still visible without any extra setup: open Grafana and check the
+**Alerts** row on the main dashboard, which lists every currently-firing alert
+directly from Prometheus, independent of Alertmanager routing. Use this as your fallback
+check if you haven't wired up push notifications yet — it's exactly what the
+`Dead jobs stay at zero` routine check below is watching for.
+
+Dead-lettered jobs also get one automatic revival attempt every 30 minutes (
+`QUEUE_DEAD_LETTER_REVIVE_INTERVAL_MS`), as long as the job hasn't already been
+revived more than a small, bounded number of extra times (
+`QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS`, default 3) — so a job that
+died from a bug that's since been fixed and redeployed recovers on its own within the next
+cycle, without needing direct database access. A job that keeps failing the same way
+eventually exhausts this budget and stays dead, which is exactly what the alert above is
+watching for.
+
+## Two different Discord/Slack integrations
+
+Don't confuse these — they're unrelated features that happen to share the same two chat
+platforms:
+
+
+
+`DISCORD_WEBHOOK_URL` is a global fallback Discord channel for any repo without
+its own webhook. `DISCORD_REPO_WEBHOOKS` is a per-repo override — a JSON map of
+`owner/repo` to a webhook URL — for routing different repos' notifications to
+different channels. Both are unset (no Discord notifications) by default.
+
+
+
+`SLACK_WEBHOOK_URL` posts the same per-action events (merged/closed/manual) as a
+Block Kit section to one Slack channel. Unlike Discord there is no per-repo map today —
+every repo shares this one webhook. Unset means no Slack notifications.
+
+## Resource profiles
+
+**Measured** rows below come from a real production instance running the full
+profile set (`qdrant` + `redis` + `observability` +
+`backup` + `postgres` + `ollama`) at steady state —
+`docker stats` and `docker system df` snapshots, not a lab benchmark.
+**Estimated** rows are reasoned from that same baseline plus each
+service's declared `deploy.resources.limits` and image size in
+`docker-compose.yml` — they have not been measured directly and could be off,
+especially for CPU under real load. Treat estimates as a starting point for capacity
+planning, not a guarantee.
+
+
+
+
+
+
Profile
+
CPU (steady state)
+
Memory (steady state)
+
Basis
+
+
+
+
+
+ Minimal — app + redis only (no profile flags)
+
+
~3% of one core
+
~400–600MiB
+
+ Estimated: app + redis measured in isolation from the full-profile snapshot (app 2.6% CPU
+ / 365MiB; redis is idle-light and its 512MiB limit is never approached in the full-profile
+ run either).
+
+
+
+
+ + --profile postgres
+
+
+ +14% of one core (highest single-service CPU consumer)
+
+
+~200MiB
+
+ Measured: 14.24% CPU / 196MiB of its 2GiB limit — comfortable headroom on memory, but the
+ largest CPU line item in the whole stack.
+
+
+
+
+ + --profile qdrant
+
+
Low single-digit %
+
Well under its 2GiB limit
+
+ Measured (part of the full-profile snapshot's "everything else" low-CPU, under-limit
+ group). Grows with RAG corpus size — expect this to climb on installs with many indexed
+ repos.
+
+
+
+
+ + --profile observability
+
+
+ Low single-digit % per service, except Grafana/Tempo below
+
+
+ Grafana ~305MiB (60% of 512MiB); Tempo ~209MiB (20% of 1GiB); Prometheus/Loki/
+ Alertmanager/Promtail/otel-collector each well under their limits
+
+
+ Measured. Grafana is the closest any service comes to its ceiling in production — worth
+ watching if you add many custom dashboards or panels, but not currently a problem (40%
+ headroom remains).
+
+
+
+
+ + --profile ollama
+
+
+ Near-zero idle; spikes hard during inference
+
+
+ Model-dependent, up to its 8GiB limit
+
+
+ Estimated. Not part of the live production profile mix (that instance uses{" "}
+ AI_PROVIDER=codex, not Ollama) — the 8GiB default limit is sized for a single
+ loaded 7–8B quantized model per the compose comment, not measured against a running model.
+ Idle Ollama with no model pulled is cheap; a loaded model can legitimately approach the
+ limit, which is why it has the largest default ceiling in the file.
+
+
+
+
+ + --profile gpu
+
+
Near-zero
+
+ Tens of MiB — a single Go binary shelling out to nvidia-smi
+
+
+ Estimated. Adds gpu-exporter (nvidia_gpu_exporter) feeding the{" "}
+ gpu Prometheus job and the GPU metrics Grafana dashboard — requires the
+ NVIDIA Container Toolkit on the host; a device reservation only takes effect once this
+ profile is activated, so a non-GPU host is unaffected either way.
+
+
+
+
+ + --profile backup
+
+
Near-zero except during runs
+
+ Low, bursts during dump/restore
+
+
+ Measured as part of the full-profile snapshot (no dedicated resource limit is set for{" "}
+ backup/backup-exporter — both are short-lived or idle-polling
+ processes, not sustained consumers).
+
+
+
+
+ + --profile runners
+
+
+ Unbounded by default — can still starve the app under CI load
+
+
+ Bounded by RUNNER_MEM_LIMIT (default 2g) per replica
+
+
+ Estimated, and explicitly a known risk on the CPU side, not a guess about typical usage:
+ the runner service ships with a default memory ceiling (
+ RUNNER_MEM_LIMIT, default 2g, added by #3893) but no CPU limit. Production
+ experience already documented in docker-compose.override.yml.example found 3
+ uncapped runner containers starving the app for CPU on an 8-vCPU box under real CI load —
+ see that file for the cpu_shares/cpus mitigation before
+ co-locating runners with the review stack.
+
+
+
+
+ Full profile set (qdrant + redis + observability +{" "}
+ backup + postgres + ollama, no active inference, no
+ runners)
+
+ No service near its limit except Grafana (~60%)
+
+
+ Measured, in full, on a real production instance.
+
+
+
+
+
+
+### Disk
+
+Measured on the same production instance: 48GB of 151GB used on the host root volume (32%)
+at steady state. `docker system df` breakdown:
+
+
+
+The reclaimable image and build-cache space here is
+**expected steady state, not a leak** — this instance runs
+`scripts/deploy-selfhost-prebuilt.sh`, which rebuilds the image from the current
+git checkout on every deploy and intentionally keeps prior layers around in the build cache
+for faster rebuilds. The `loopover-docker-prune` systemd timer (below) already
+runs daily against this exact instance and reclaims it on a schedule, so this is not a
+number to chase down manually.
+
+### When a compose default might need to change
+
+Every `deploy.resources.limits.memory` in `docker-compose.yml` is
+operator-overridable via `.env` (see the `*_MEM_LIMIT` variables in
+`.env.example`). Against the measured full-profile data above, none of the
+current defaults look miscalibrated enough to change: nothing sits consistently near its
+limit in a way that risks an OOM kill under normal load (Grafana's ~60% is the closest
+and still has real headroom), and nothing is so oversized relative to plausible usage that
+it should be lowered — including Ollama's comparatively large 8GiB ceiling, which is
+sized for holding one quantized model in memory, not idle overhead. The one real gap is
+`--profile runners`'s CPU side: the service has a default memory ceiling (
+`RUNNER_MEM_LIMIT`, default 2g) but ships with no CPU limit at all; that is a
+known, documented tradeoff (see the table above and
+`docker-compose.override.yml.example`) rather than an oversight, since the right
+CPU ceiling depends entirely on the host's core count and how many runner replicas you run.
+
+### Capacity planning: how much disk for N repos at M PRs/month
+
+The 151GB host above is one measured point, not a formula. It says nothing about how disk
+use grows as you register more repos or review more pull requests — for that you have to
+reason about which tables and volumes actually grow with activity, versus which are fixed
+overhead. Treat every number below as an order-of-magnitude estimate to plan around, not a
+guarantee.
+
+
+
+Putting it together: for a small install (a handful of repos, tens of PRs/month), all of
+this is noise against the ~20GB of fixed Docker/image/volume overhead measured above — you
+will not notice review_audit or webhook_events growth for a long time. The estimate gets
+real at higher volume: an install running hundreds of PRs/month across dozens of repos, left
+unattended for a year or more, is a plausible case where the unbounded tables above (and the
+backups that multiply them) become the dominant long-term disk driver rather than Docker
+images and build cache. There is no first-party tool yet to prune review_audit or
+webhook_events — if you operate at that scale, monitor their row counts directly (
+`SELECT count(*) FROM review_audit`,
+`SELECT count(*) FROM webhook_events`) rather than assuming steady state.
+
+## Docker resource hygiene
+
+Every service in `docker-compose.yml` caps its own container logs (10MB × 3
+rotated files) out of the box, so log growth alone won't fill your disk. Unused Docker
+images and build cache are a separate, larger disk-growth vector on a host that rebuilds or
+pulls images repeatedly over months — Docker does not reclaim either automatically.
+
+Install the provided host-level timer to reclaim both on a schedule (anything unused for
+less than 7 days is left alone, so a recent deploy is never at risk):
+
+
+
+Run it manually at any time with `docker system df` before and after to see what
+it reclaimed: `sh scripts/selfhost-docker-prune.sh`.
+
+This should always prune **containers, images, and build cache** — never
+volumes. Pruning a volume deletes real application state (the database, backups, vector
+index, or a runner's registration and job data), not disposable build output, so it is
+never part of routine cleanup unless you intentionally want to delete that state.
+
+## Self-hosted runner temp storage
+
+If you run `--profile runners`, keep every runner job's scratch/temp writes
+on the mounted `runner-work` volume, never the container's plain
+`/tmp`. A container's own `/tmp` lives in Docker's
+overlay/containerd snapshot storage — a CI job that writes high-volume temp data there
+(language toolchain caches, build artifacts, ad hoc `mktemp` calls) grows the
+host's Docker root storage directly, not the volume, so it is invisible to
+volume-scoped cleanup and can fill the disk out from under the whole stack. The shipped
+`runner` service points `TMPDIR`, `TMP`, and
+`TEMP` at `/tmp/runner/tmp` (a subdirectory of the mounted
+`runner-work` volume) and keeps `RUNNER_WORKDIR` at
+`/tmp/runner` on the same volume. A one-shot `runner-tmp-init` service
+creates that directory on the volume (and makes it world-writable, matching real
+`/tmp` permissions) before the runner container starts, so this works out of the
+box on a fresh volume with no manual steps.
+
+Adding a second or third runner service in `docker-compose.override.yml` for
+higher CI throughput? Each one needs its own `runner-work`-style volume, its own
+init step, and the same temp env — YAML anchors don't cross separate compose files, so
+repeat the extension block in your override file:
+
+
+
+## Enabling Sentry (your own DSN)
+
+Sentry is **opt-in and off by default**. Leave `SENTRY_DSN` unset
+for a complete no-op with negligible overhead — no events leave your box. When you want
+error tracking, point the runtime at a project you control in your own Sentry organization.
+
+
+
+Official release images bake `LOOPOVER_VERSION` as the default release id;
+override with `SENTRY_RELEASE` when you tag custom builds. Mount secrets with
+`SENTRY_DSN_FILE` instead of inline env when you prefer a file-backed DSN. After
+changing Sentry env, restart the `loopover` service — there is no hot reload.
+
+
+ Community self-hosters should send events only to their own DSN. The shipped stack never phones
+ home to a maintainer-owned project unless you configure one.
+
+
+## Browser Sentry (operator UI)
+
+The operator UI (`apps/loopover-ui`) has its own, separate client-side Sentry
+integration for route errors, unhandled browser exceptions, and failed app-level resource
+loads — independent of the backend's `SENTRY_DSN` above.
+**Opt-in and off by default**: leave `VITE_SENTRY_DSN` unset for a
+complete no-op — the SDK is never even fetched by the browser. Session Replay is not
+enabled.
+
+`}
+/>
+
+Every browser event is scrubbed before it leaves the box: request cookies, headers, and body
+data are stripped outright; secret-shaped keys and values (tokens, bearer headers, JWTs) are
+redacted recursively; local filesystem paths are replaced with a placeholder; and
+`user` is always dropped — no PII is ever sent. Tags stay a small,
+low-cardinality set: `route` (pathname only), `release`,
+`environment`, and `app_surface`.
+
+
+ The UI's production build/deploy runs through Cloudflare's own Workers Build git integration, not
+ GitHub Actions, so `VITE_SENTRY_DSN`/ `VITE_SENTRY_RELEASE` are configured as Cloudflare build
+ environment variables, not repo secrets. Source maps are never produced by that regular build or
+ served publicly — the `.github/workflows/ui-sentry-release.yml` workflow (behind the same
+ maintainer-only `release` environment gate as the Orb image release) does an independent,
+ never-deployed build with source maps enabled and uploads them to Sentry as a release artifact
+ whenever `apps/loopover-ui` changes on `main`.
+
+
+## Sentry context taxonomy
+
+Self-host Sentry events carry a small, scrubbed taxonomy so operators can filter by
+subsystem without opening raw payloads. Structured error logs forwarded from
+`console.error` use the JSON `event` slug as the issue type; direct
+captures use a `kind` or review `operation` tag instead.
+
+
+
+Cron monitor slugs follow
+`gittensory-selfhost-{environment}-{loop}` (for example
+`gittensory-selfhost-production-scheduled-loop`). Pair monitor alerts with queue
+depth, dead-job counts, and the matching structured log event.
+
+## Sentry alert classes and runbook
+
+Tune Sentry alert rules for **persistent failure classes**, not one-off
+fail-open noise. The table below lists actionable signals, what they usually mean, and the
+first checks an operator should run. Prometheus/Grafana alerts in the observability profile
+cover the same failure modes from a metrics angle — use both when Sentry is enabled.
+
+
+ Page when backup freshness >2× BACKUP_INTERVAL_SECONDS or verify script
+ fails twice
+
+
+ Inspect docker compose logs backup, disk space, and{" "}
+ backup docs; do not delete the last
+ good backup after a failed run
+
+
+
+
Scheduled monitor misses
+
+ Sentry monitor alert on scheduled-loop, orb-export, or other
+ wrapped loops
+
+
+ Use Sentry's built-in monitor failure thresholds (2 consecutive misses on most loops)
+
+
+ Process may still be alive but cron work stopped — check selfhost_cron_error,
+ queue pump logs, and restart the app container if the loop crashed without taking down the
+ process
+
+
+
+
+
+
+## Sentry server name
+
+`SENTRY_SERVER_NAME` sets a clean, human name for this instance in Sentry (for
+example `loopover-us-east`). Unset defaults to the OS hostname — never the
+public-origin URL. Set this explicitly if you run more than one instance and want to tell
+their Sentry events apart at a glance instead of matching container hostnames.
+
+## Sentry tracing
+
+Leave `SENTRY_TRACES_SAMPLE_RATE` unset or blank to disable trace export, or set
+a positive sample rate such as `0.05` to send sampled review spans to Sentry. The
+custom OpenTelemetry provider installs Sentry hooks for review-stage spans carrying repo,
+PR, operation, outcome, and hashed installation tags.
+
+## Sentry cron monitors
+
+When `SENTRY_DSN` is set, the self-host runtime emits Sentry monitor check-ins
+for the recurring loops where silent stoppage matters most. Leaving `SENTRY_DSN`
+unset keeps monitor reporting off.
+
+
+
+Monitor loop slugs (the `{loop}` segment in the slug) are
+`scheduled-loop`, `orb-export`, `orb-relay-drain`,
+`orb-relay-register`, and `queue-dead-letter-revive`. A missed monitor
+means the process may still be alive but the recurring work is not checking in on schedule.
+Pair the monitor with queue depth, dead-job counts, and the structured error log for the
+same subsystem.
+
+## Grafana Sentry data source (in-Grafana issue visualization)
+
+Query recent Sentry issues, top issues by event count, and error-volume trend directly in
+Grafana — no more switching tabs to check Sentry, and errors line up in time with the rest
+of the stack's metrics/logs/traces. This is read-only visualization; alert routing to
+Sentry/Discord/Slack is a separate, unrelated concern covered above.
+
+
+ The `SENTRY_DSN` above authenticates event **ingestion** (sending errors to Sentry), not the
+ read/query API this data source needs. You need a separate **Sentry Internal Integration token**:
+ Sentry → Settings → Developer Settings → Custom Integrations → New Internal Integration (requires
+ an Admin/Manager/Owner role in Sentry), with **Read** access on the **Project**, **Issue &
+ Event**, and **Organization** resource scopes.
+
+
+The [grafana-sentry-datasource](https://grafana.com/grafana/plugins/grafana-sentry-datasource/)
+plugin installs automatically (`GF_INSTALL_PLUGINS`, same mechanism as the GitHub
+data source below). Add the data source itself after Grafana is up — a backend datasource
+whose token isn't ready at Grafana's own boot time would crash file-based
+provisioning, so this one is added over the API instead, exactly like the GitHub data
+source:
+
+
+SENTRY_ORG_SLUG=
+# SENTRY_API_URL=https://sentry.io # override only for a self-hosted Sentry instance`}
+/>
+
+
+
+The script is idempotent — safe to re-run after rotating the token. Open the
+**Sentry issues** dashboard once it succeeds. Same trade-off as the GitHub data
+source: this one is API-managed, so it stays editable via the Grafana UI rather than locked
+read-only like the file-provisioned data sources.
+
+## Re-gate sweeps (agent-regate-sweep)
+
+Live PR review is webhook-driven, but open PRs still need periodic re-evaluation — the base
+branch moves, duplicate clusters resolve, settings change, and approved PRs can sit unmerged
+until CI re-runs. A scheduled sweep (`agent-regate-sweep`, every ~2 minutes on
+the maintenance tick) fans out lightweight `agent-regate-pr` jobs for the stalest
+open PRs per repo (cap `SWEEP_MAX_PRS=3` by default, REST-budget sized).
+
+
+
+Log markers: `regate_sweep_throttled` (sweep temporarily paused),
+`regate_sweep_trigger_backlog_deferred` (prior regate work still draining —
+avoids piling duplicate fan-outs). In metrics, break down deferrals by
+`job_type=agent-regate-pr` or `agent-regate-sweep` when GitHub
+rate-limit pressure spikes.
+
+## Routine checks
+
+- Queue pending count is not growing without processing.
+- Dead jobs stay at zero or are investigated promptly.
+- Webhook deliveries are recent and have 2xx responses, with no enqueue failures.
+- AI usage matches expected review volume and model/effort choices.
+- REES and RAG failures are visible and bounded.
+- Postgres connections, lock waits, slow transactions, dead tuples, and table growth are
+ stable.
+- Backups are recent and restore-tested.
+
+## Updating and rolling back
+
+Day-two operator flow: pull or build a new app image, restart only the `loopover`
+service, verify `/ready`, and confirm the release id. Use
+[Releases and images](/docs/self-hosting-releases) to pick a tag; use the
+checklists below so updates never overwrite operator-owned secrets, config, or data.
+
+
+
+
+ .env and any *_FILE secret mounts — deploy scripts only write back{" "}
+ LOOPOVER_IMAGE (image path) or SENTRY_RELEASE /{" "}
+ LOOPOVER_VERSION (source path).
+
+ Named data volumes — especially loopover-data (SQLite DB, Codex/Claude auth under{" "}
+ /data), loopover-pg, qdrant-data,{" "}
+ loopover-backups, and Grafana's grafana-data.
+
+
+ Optional docker-compose.override.yml — still loaded via{" "}
+ SELFHOST_COMPOSE_FILES when set, or automatically when present beside{" "}
+ docker-compose.yml.
+
+
+
+
+
+
+### Preflight checklist
+
+1. Read release notes for migration or env changes — migrations are forward-only (see
+ Rollback below).
+2. Take a fresh backup when the release may change schema — see
+ [Backup and scaling](/docs/self-hosting-backup-scaling).
+3. Source path only: confirm `git status` is clean (no uncommitted local changes
+ the build would silently pick up). An ad-hoc snapshot like
+ `cp docker-compose.yml docker-compose.yml.bak-notes-20260707` does not count
+ against this — the trailing `*.bak-*`/`*.backup-*` patterns in
+ `.gitignore` keep stray manual backups out of `git status` entirely,
+ on top of the narrower `loopover-config.backup-*/` and
+ `.deploy-backups/` patterns that already covered those specific directories.
+ `scripts/selfhost-update.sh` (below) checks this for you and refuses to
+ continue on a dirty tree.
+4. Image path only: note the current tag or digest from `docker inspect` on the
+ running `loopover` container so rollback has a known-good target.
+5. Confirm routine health is green before you start —
+ `curl http://localhost:8787/ready` and a quick `docker compose ps`.
+
+### Path 1: pull a published image
+
+`scripts/deploy-selfhost-image.sh` pulls a tag or digest, restarts only the
+`loopover` service, waits for it to report `healthy` via
+`docker inspect`'s health status (configurable timeout, default 180s), and
+then persists the resolved image reference back to `LOOPOVER_IMAGE` in
+`.env` so the next plain invocation reuses it.
+
+
+
+`ghcr.io/jsonbored/gittensory-selfhost` (the pre-rename name) is no longer
+published to, but an existing pin to a specific tag or digest under it keeps resolving.
+
+The pull always runs with `--policy always`, so re-running the script against an
+unchanged tag is safe: if the registry has nothing new, it just restarts the same image and
+the health-check wait passes immediately.
+
+### Path 2: build from the current git checkout
+
+`scripts/selfhost-update.sh` is the recommended entry point for a Git-backed
+source checkout (#1660) — it is the single command that turns `git fetch` +
+fast-forward + rebuild + verify into one flow, instead of an operator having to remember the
+right order:
+
+
+
+It refuses to continue, with a clear error and no side effects, on any of the three things
+that make a plain `git pull` unsafe to script blindly: the working tree is not
+clean, the checkout is not on the expected branch (`main` by default), or local
+history has diverged from `origin/main` in a way that is not a fast-forward (
+`git merge --ff-only` — it never rebases, force-merges, or picks a side for you).
+Only once the fast-forward succeeds does it call
+`scripts/deploy-selfhost-prebuilt.sh` (below) to rebuild and restart, then
+`scripts/selfhost-post-update-check.sh` to verify health — so a normal update is
+one command and a failure at any step stops before the next one runs.
+
+None of this touches operator-owned state: `.env`, the
+`loopover-config/` mount, `.deploy-backups/`, any `*.local`
+or `docker-compose.local-*.yml` compose override, or Alertmanager file, and every
+named data volume are already gitignored or outside the source tree entirely, so a
+fetch-and-rebuild never touches them. See the
+[Quickstart](/docs/self-hosting-quickstart) for the initial clone; this
+script assumes that checkout already exists and already tracks `origin/main`.
+
+
+
+Want finer control — a pinned `SENTRY_RELEASE`, a Sentry source-map upload, or to
+fetch and rebuild as separate manual steps? Call the two scripts it wraps directly:
+
+
+
+`scripts/deploy-selfhost-prebuilt.sh` is the actual rebuild step (this is how
+`LOOPOVER_VERSION` ends up as a short git SHA instead of an image tag). It builds
+the bundle inside a Dockerized Node container — the host itself never needs Node or npm
+installed — then restarts only the `loopover` service the same way as the image
+path. `SENTRY_RELEASE` defaults to
+`gittensory-selfhost@` unless you
+override it, so each deploy from a new commit gets a distinct release id automatically. When
+`SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` are
+all configured, the script also injects and uploads Sentry source maps for that release
+before restarting the service (set `SELFHOST_SKIP_SENTRY_UPLOAD=1` to skip this
+even when those three are present).
+
+### Pre-deploy: preview what's incoming
+
+`scripts/selfhost-pre-deploy-summary.sh` (#5735) is a read-only preview of what
+`scripts/selfhost-update.sh` would pull in — the commit range between the current
+checkout (the last-deployed state) and the remote's tracked branch, plus a flag on any
+incoming commit that touches a path with a history of breaking a deploy on this instance:
+`docker-compose*.yml`, `grafana/provisioning/**`/
+`grafana/dashboards/**`, `migrations/**`, `Dockerfile*`,
+the deploy scripts themselves, and `.env.example`. It only runs
+`git fetch` — never a merge or checkout — so it is safe to run anytime, including
+with a dirty working tree, and takes the same `SELFHOST_UPDATE_REMOTE`/
+`SELFHOST_UPDATE_BRANCH` overrides as `selfhost-update.sh`:
+
+
+
+It is a skim tool, not a gate — it always exits 0 and never blocks
+`selfhost-update.sh` from running; a flagged path is a prompt to read the actual
+diff before deploying, not a hard stop.
+
+### Post-update checklist
+
+`scripts/selfhost-update.sh` already runs the health probe below for you unless
+you set `SELFHOST_SKIP_POST_UPDATE_CHECK=1`. Run it manually after the image
+path, after calling the two wrapped scripts directly, or after any manual
+`docker compose` update.
+
+1. Wait for the deploy script's health wait to finish (or run the helper below if you
+ updated manually with plain `docker compose`).
+2. `curl http://localhost:8787/ready` returns HTTP 200.
+3. `docker compose ps loopover` shows `healthy`.
+4. Tail logs for `selfhost_listening` and, on first boot after a schema bump,
+ `selfhost_migrations_applied` — not `selfhost_job_dead`.
+5. Confirm the release id — neither `/health` nor `/ready` exposes a
+ version string; check `.env` and the running container image instead.
+
+
+
+If any check fails, see [Troubleshooting](/docs/self-hosting-troubleshooting).
+
+### Optional: auto-pause on a post-deploy regression
+
+`scripts/selfhost-post-update-regression-gate.sh` (#5736) goes one step further
+than the post-update checklist above: it verifies the service doesn't just come back
+up, but _stays_ up once real traffic starts flowing. It observes a window of the
+`loopover` service's own logs for a dead-job spike (every attempt exhausted
+its retries) and, if the count exceeds a threshold, automatically flips the DB-backed global
+kill-switch (`global_agent_controls.frozen`) so a bad deploy that starts silently
+failing jobs pauses every agent write action fleet-wide instead of accumulating failures
+until you notice. It never depends on the optional observability profile
+(Prometheus/Grafana/Loki) being enabled -- it reads the service's own logs directly,
+the same way `docker compose logs loopover` always works regardless of which
+profiles you've opted into. Run it after `selfhost-post-update-check.sh`
+passes, once you're ready to let real webhook traffic through -- it blocks for the full
+observation window by design (3 minutes by default):
+
+
+
+If it trips, clear the pause once you've confirmed the regression is fixed:
+
+
+
+### Rollback: no dedicated command
+
+There is no `rollback` script. Rolling back means re-running one of the two
+scripts above pointed at an older target:
+
+- Image-based: re-run `deploy-selfhost-image.sh` with the prior tag or digest (
+ `docker inspect` on the running container, or your own deploy log, has the
+ digest you were on before the update).
+- Source-based: `git checkout` the prior commit, then re-run
+ `deploy-selfhost-prebuilt.sh`.
+
+
+ This repo has no down-migration convention — `scripts/check-migrations.mjs` only enforces a
+ contiguous, non-colliding numbering, not a reverse path. If a migration has already run forward
+ against the live database, rolling back the app code is **not safe in general**: older code can
+ break against a newer schema (a dropped/renamed column, a NOT NULL column it never writes, a
+ changed constraint), even though the migration itself succeeded. Before rolling back across a
+ migration boundary, check whether everything the newer migration(s) did is purely additive (new
+ nullable column, new table, new index) and, specifically, whether the code you're rolling back to
+ actually still runs against that schema — additive is usually fine; anything the old code can't
+ tolerate is not. Take a fresh backup first regardless — see [Backup and
+ scaling](/docs/self-hosting-backup-scaling) — and if in doubt, restore that backup to a scratch
+ database and boot the older code against it before doing the same on the live instance.
+
+
+## Uninstalling and decommissioning
+
+Tearing an instance down cleanly touches four independent things: the GitHub App
+installation, the data volumes, brokered-mode enrollment, and control-panel access. None of
+this is scripted today — do each step deliberately, in this order, and decide what to keep
+before you delete anything.
+
+### 1. Revoke the GitHub App installation
+
+Uninstalling stops GitHub from sending any further webhook events and immediately revokes
+the App's installation tokens — nothing on the self-host side needs to be told; there
+is no `installation` `deleted` webhook handler to run first. From the
+repo or org: Settings → Integrations → GitHub Apps → your App → Uninstall. Do this before
+stopping the container so you are not left with a dangling install pointed at a dead webhook
+URL.
+
+If you only want to pause reviews without losing the App's configuration (permissions,
+webhook URL, private key), suspend the installation instead of uninstalling it — GitHub
+stops delivering events to a suspended install but keeps everything else intact for a later
+resume.
+
+### 2. Decide what happens to the data volumes
+
+Stopping the container does not delete anything — `docker compose stop` or
+`docker compose down` (without `-v`) leaves every named volume (
+`loopover-data`, `loopover-pg`, `qdrant-data`,
+`loopover-backups`, `grafana-data`, and the rest declared in
+`docker-compose.yml`) on disk, along with the `./loopover-config` host
+directory (a bind mount, not a named volume, so it is never affected by `-v`
+either way). Pick one:
+
+
+
+
+ If you have not exported a backup off-host first, `docker compose down -v` permanently destroys
+ review history, settings, and the vector index with no recovery path — the volumes are the only
+ copy. See [Backup and scaling](/docs/self-hosting-backup-scaling) before running it on an instance
+ you care about.
+
+
+### 3. Deregister from the Orb broker (brokered mode only)
+
+If this instance runs in brokered mode (`ORB_ENROLLMENT_SECRET` is set — see
+[GitHub App and Orb](/docs/self-hosting-github-app)), be aware there is
+**no self-service revocation endpoint today** — the "Minimum broker
+safeguards" checklist on that page lists a revocation path as a prerequisite for a
+public brokered rollout that has not shipped yet. An enrollment record (
+`orb_enrollments`) lives in LoopOver's own central database, not your
+container, and nothing in this codebase writes a `revoked_at` value to it outside
+of tests. Practical steps until that exists:
+
+- Uninstalling the GitHub App (step 1) stops new webhook traffic and installation-token
+ issuance from reaching your instance in practice, even though the enrollment row itself
+ stays marked enrolled centrally.
+- Stop the container and let `ORB_ENROLLMENT_SECRET` go with it — with nothing
+ polling or listening, the secret is inert even if it still resolves to a valid enrollment.
+- If the secret may have leaked or you want it invalidated outright rather than just
+ orphaned, treat this the same as any other suspected credential compromise: contact the
+ Orb operator to have the enrollment revoked centrally, since there is no in-product way to
+ do it yourself yet.
+
+### 4. Remove ADMIN_GITHUB_LOGINS access
+
+`ADMIN_GITHUB_LOGINS` is read fresh from the environment on every control-panel
+request (`isAuthorizedGitHubSessionLogin` in `src/auth/security.ts`) —
+it is never cached at startup or baked into an issued session. To remove someone's
+operator access, delete their login from the comma/whitespace-separated list in
+`.env` and restart the `loopover` service so the process picks up the
+new value:
+
+
+
+This takes effect on their very next control-panel request after the restart — no signed-in
+session is grandfathered in, because authorization is re-checked against the current
+allowlist every time, not read from the session itself. If you are decommissioning the whole
+instance rather than removing one operator, this step is moot once the container is stopped.
diff --git a/apps/loopover-ui/content/docs/self-hosting-quickstart.mdx b/apps/loopover-ui/content/docs/self-hosting-quickstart.mdx
new file mode 100644
index 0000000000..c5d0d99567
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-quickstart.mdx
@@ -0,0 +1,266 @@
+---
+title: Quickstart
+description: A minimal self-host boot path for maintainers -- start the service, verify readiness, and keep the first rollout safe.
+---
+
+## 1. Copy the sample env
+
+`.env.selfhost.example` is the short path: required secrets plus a conservative
+first-boot config, with nothing about the Cloudflare Worker deploy. Copy it and fill in the
+placeholders — keep your real `.env` out of git and prefer mounted secret files
+for multiline values like the GitHub App private key.
+
+
+
+
+ The webhook secret and static bearer tokens (`GITHUB_WEBHOOK_SECRET`, `LOOPOVER_API_TOKEN`,
+ `LOOPOVER_MCP_TOKEN`, `INTERNAL_JOB_TOKEN`, `SELFHOST_SETUP_TOKEN`) ship commented out on purpose.
+ Generate a distinct random value for each one (e.g. `openssl rand -hex 32`) — never reuse the same
+ string across more than one of them. The app refuses to boot if any of these is left at a
+ known-placeholder or too-short value.
+
+
+
+ `.env.selfhost.example` already ships a conservative starting config — `dry-run` mode, a small
+ repo allowlist, unified comments, safety, and grounding, with AI, RAG, and REES left off. Switch
+ to live only after webhook delivery, logs, and review output match expectations. For every
+ optional env var (observability, backup, additional AI providers) see `.env.example`'s self-host
+ section or the [generated reference table](/docs/self-hosting-configuration).
+
+
+## 2. Choose your AI provider (optional)
+
+Skip this step for a fully deterministic review (no AI). Otherwise set
+`AI_PROVIDER` to one provider or a fallback chain. The self-host image bundles
+both CLIs by default; credentials and provider choice are runtime-only.
+
+
+
+
+
+
+
+Set `AI_DUAL_REVIEW=1` only when you deliberately want the first two providers to
+run as independent reviewers instead of a fallback chain.
+
+
+ Codex stores its OAuth credential in `auth.json` on the same filesystem that prompt-influenced
+ reviews can read, so it requires explicit opt-in ( `LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER=1`) and
+ a mounted `/data/codex` auth volume. Claude Code has no equivalent restriction. See [AI
+ providers](/docs/self-hosting-ai-providers) for the full reference.
+
+
+## 3. Boot the stack
+
+**Recommended: pull the published image.** No local build, no Node toolchain —
+the script pulls, restarts, and waits for the health check to pass.
+
+
+
+Pin a specific release instead of `:latest`, or point at your own registry:
+
+
+
+`ghcr.io/jsonbored/gittensory-selfhost` (the pre-rename name) is no longer
+published to, but an existing pin to a specific tag or digest under it keeps resolving.
+
+
+ Contributors and anyone customizing the Dockerfile can still build locally — `docker compose up -d
+ --build` builds the `loopover` service from the checkout instead of pulling a published image.
+ Everything else in this quickstart (env, health checks, GitHub App) is identical either way. Two
+ build-args trim the image: `--build-arg INSTALL_AI_CLIS=false` skips the Claude Code/Codex CLIs
+ (default `true`), and `--build-arg INSTALL_VISUAL_REVIEW=true` adds `puppeteer-core` for visual
+ capture (default `false` — needs a `BROWSER_WS_ENDPOINT` at runtime).
+
+
+
+
+## 4. Install or connect the GitHub App
+
+Point your App webhook to `https://your-host.example/v1/github/webhook`, set the
+same webhook secret in `GITHUB_WEBHOOK_SECRET`, install the App on one test repo,
+and open a small PR. The direct App and Orb modes are covered in
+[GitHub App and Orb](/docs/self-hosting-github-app).
+
+
+ Set `ADMIN_GITHUB_LOGINS` to a comma/whitespace-separated list of GitHub logins before signing in
+ to the control panel — it's the only allowlist for the operator role (operator dashboard, drift
+ status). No login is authorized as operator without it.
+
+
+## 5. Watch the first review
+
+Look for these logs during boot and the first webhook:
+
+
+
+A cold first boot on SQLite commonly logs a one-time
+`selfhost_migrations_applied` burst and a brief Redis connection retry while the
+sidecar finishes starting — both are expected and stop once the stack is warm. Anything else
+that looks wrong, or a `/ready` that stays unhealthy past a couple minutes, is
+covered in [Troubleshooting](/docs/self-hosting-troubleshooting).
+
+After the deterministic path is stable, continue with
+[Configuration](/docs/self-hosting-configuration) and then layer in AI, REES,
+or RAG deliberately.
+
+## 6. Activate your first repo
+
+Three separate knobs are easy to conflate — each does something different, and all three
+matter for a smooth first rollout:
+
+
+
+**Recommended first-repo path today:** add the repo to
+`LOOPOVER_REVIEW_REPOS`, seed a private global default, then enable advisory gate
+mode once webhook delivery works.
+
+
+
+Copy the shipped global private default into the compose-mounted config directory (edit your
+copy — never commit real policy to a public repo):
+
+
+
+Sign in to the control panel (`ADMIN_GITHUB_LOGINS` must include your GitHub
+login), open the repo workspace, preview what LoopOver would have flagged on recent PRs,
+then enable advisory mode in one click — the same patch as:
+
+" \\
+ -H "Content-Type: application/json" \\
+ -d '{}'`}
+/>
+
+
+ That activation endpoint turns on the gate check plus deterministic rules in **advisory** mode
+ (non-blocking, no auto-merge) — a CodeRabbit-style ramp. AI review stays off until you configure
+ it separately. Full semantics in [Configuration](/docs/self-hosting-configuration).
+
+
+
+ If reviews compute but no `LoopOver Orb Review Agent` check-run appears, open your App's
+ permissions page and confirm **Checks: write** is granted — `checks: read` alone 403s the write
+ silently. New permissions also require a one-time re-approval on each installation; see [GitHub
+ App and Orb](/docs/self-hosting-github-app).
+
+
+When output looks right, switch `SELFHOST_DEPLOYMENT_MODE` from
+`dry-run` to unset (live writes). For a shorter future path, see the onboarding
+proposal on [Self-hosted reviews](/docs/maintainer-self-hosting).
+
+## Defaults at a glance
+
+Nothing below needs a flag to start; everything past the first row needs an explicit
+`--profile` (combine freely) or an explicit `AI_PROVIDER`.
+
+
+
+
+ Orb fleet-calibration telemetry (verdict, outcome, cycle time — never repo names, code, or logins)
+ starts automatically once your GitHub App is configured — this is the self-hosting contract, not a
+ flag you turn on. The one way to disable it is the explicit air-gap flag: set `ORB_AIR_GAP=true`
+ for an instance that sends nothing.
+
+
+`--profile caddy` gets you real public HTTPS; `--profile tailscale`
+adds private tailnet reachability (it does not remove the default public port on its own —
+see the callout below) — see [Security](/docs/self-hosting-security)'s TLS
+termination section for the full walkthrough of each (Caddyfile setup, DNS prerequisites,
+hardening Tailscale for real isolation, and when to pick one over the other).
diff --git a/apps/loopover-ui/content/docs/self-hosting-rag.mdx b/apps/loopover-ui/content/docs/self-hosting-rag.mdx
new file mode 100644
index 0000000000..800dfb131b
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-rag.mdx
@@ -0,0 +1,89 @@
+---
+title: RAG indexing
+description: RAG adds relevant existing code and docs to the AI reviewer prompt. It is additive and fail-safe.
+---
+
+## Prerequisites
+
+
+
+## Choosing a vector backend
+
+SQLite vectors are the default and need no extra service — fine for a small instance or getting
+started. Qdrant (`QDRANT_URL`, `--profile qdrant`) is the preferred dedicated vector store for
+review context at scale. A third option, `PGVECTOR_ENABLED=true`, uses the Postgres pgvector table
+instead — only relevant if you're already running the `postgres` profile and want to avoid
+standing up a separate Qdrant service. Leave it `false` (the default) when `QDRANT_URL` is set;
+Qdrant remains preferred for RAG at scale.
+
+## Qdrant and Ollama example
+
+
+
+
+
+Use `QDRANT_DIM=1024` for 1024-dimensional models such as `bge-m3` or `mxbai-embed-large`. If a
+Qdrant collection already exists, recreate it before changing dimensions.
+
+`AI_EMBED_API_KEY` is the bearer credential for `AI_EMBED_BASE_URL`, if that endpoint requires one
+— a local Ollama typically doesn't, but a hosted OpenAI-compatible embeddings endpoint usually
+does. Setting `AI_EMBED_MODEL` alone does nothing without `AI_EMBED_BASE_URL` also set; unset,
+embeddings use the same provider as the rest of the review chain.
+
+## Indexing
+
+RAG needs an index before it can retrieve useful context. A cold or missing index degrades to no
+context; the review still runs.
+
+
+
+## Operational checks
+
+- Boot logs should include `selfhost_embed_provider` when an embedding provider is configured.
+- Qdrant mode should log `selfhost_vectorize` with backend `qdrant`.
+- Empty RAG context usually means the repo is not indexed, the embed model is unavailable, or
+ dimensions do not match.
+
+
+ RAG is context, not authority. The AI reviewer still has to verify every claim against the diff,
+ grounding, and review rules.
+
+
+Pair RAG with [AI providers](/docs/self-hosting-ai-providers) and optionally
+[REES](/docs/self-hosting-rees).
diff --git a/apps/loopover-ui/content/docs/self-hosting-rees-analyzers.mdx b/apps/loopover-ui/content/docs/self-hosting-rees-analyzers.mdx
new file mode 100644
index 0000000000..fc08757e92
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-rees-analyzers.mdx
@@ -0,0 +1,148 @@
+---
+title: REES analyzer reference
+description: Every analyzer name you can put in REES_ANALYZERS, what it inspects, what it reports, and whether it needs network or GitHub token access.
+---
+
+import { REES_ANALYZERS, REES_ANALYZER_NAMES, REES_PROFILES } from "@/lib/rees-analyzers";
+
+REES runs analyzers independently. A failed analyzer is marked degraded, completed analyzers
+still return findings, and an empty result produces no user-facing brief. Use exact analyzer
+names in `REES_ANALYZERS`. A typo-only analyzer list fails closed with no analyzers selected.
+Leave `REES_PROFILE` unset for the balanced profile, or set `fast` during incidents to favor
+local and low-cost registry checks.
+
+
+
+## Profiles
+
+
+
+## All analyzer names
+
+
+
+## Network and token model
+
+
+
+
+ If the REES endpoint is outside your trust boundary, set `REES_FORWARD_GITHUB_TOKEN=false`. REES
+ will still receive the PR diff/files when enabled, but token-aware analyzers will skip GitHub API
+ reads they cannot authenticate.
+
+
+## Analyzer details
+
+
+ {REES_ANALYZERS.map((analyzer) => (
+
+
+
+
{analyzer.title}
+
+ {analyzer.docs.summary}
+
+
+
+
+ {analyzer.name}
+
+
+ {analyzer.cost}
+
+
+
+
+
+
Looks at
+
{analyzer.docs.looksAt}
+
+
+
Reports
+
{analyzer.docs.reports}
+
+
+
Network
+
{analyzer.docs.network}
+
+
+
Operational note
+
{analyzer.docs.notes}
+
+
+
Profiles
+
{analyzer.profiles.join(", ")}
+
+
+
Requirements
+
{analyzer.requires.join(", ")}
+
+
+
+ ))}
+
+
+## Back to REES setup
+
+Use [REES enrichment](/docs/self-hosting-rees) for enablement, auth, troubleshooting, and where
+the brief appears in the review result.
diff --git a/apps/loopover-ui/content/docs/self-hosting-rees.mdx b/apps/loopover-ui/content/docs/self-hosting-rees.mdx
new file mode 100644
index 0000000000..9df7cdad0e
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-rees.mdx
@@ -0,0 +1,223 @@
+---
+title: REES enrichment
+description: REES runs external or heavier analyzers and returns a public-safe brief that the AI reviewer can use.
+---
+
+import { REES_ANALYZER_NAMES } from "@/lib/rees-analyzers";
+
+## Where REES fits
+
+REES fires inside the AI review path. It is not a separate status check, dashboard report, or PR
+attachment. When it returns a non-empty `promptSection`, the engine folds that brief into the AI
+reviewer prompt. The final result is seen only through the normal AI review summary, blockers,
+risks, nits, and decision.
+
+
+ A 200 response with no findings can produce no rendered brief. That is expected: the review
+ proceeds as if REES had no useful extra context for that PR.
+
+
+## When it fires
+
+
+
+## Run REES
+
+REES runs as its own small HTTP service, separate from the engine. The simplest way to run it is
+in-network alongside the engine, using the docker-compose stack in the repo root — no separate
+hosting to manage, and nothing published to the host (the engine reaches it only over the compose
+network):
+
+
+
+Point the engine at it and generate a fresh shared secret:
+
+`}
+/>
+
+No `SENTRY_*` variables are required for a working local REES. Set them only if you want REES
+error reporting — see "Service configuration" below for the variables REES reads, and add them
+for the `rees` service through a `docker-compose.override.yml` rather than the root `.env`: REES
+reads the same `SENTRY_DSN` name the main engine uses, so forwarding the whole `.env` file would
+point REES's error reporting at the engine's Sentry project instead of a dedicated one.
+
+### Pointing at an external or managed instance instead
+
+If you'd rather run REES elsewhere — a separate host, a managed provider, or one shared instance
+across multiple self-hosted installs — point `REES_URL` at it directly and skip the `rees`
+compose profile entirely. Generate a dedicated shared secret for that instance; never reuse a
+secret across two different REES instances you run:
+
+`}
+/>
+
+### Common options (either path)
+
+
+
+
+
+## Disable cleanly
+
+Set `LOOPOVER_REVIEW_ENRICHMENT=false` to turn off REES for the whole instance. To keep REES
+configured but prevent a repo from using it, remove that repo from `LOOPOVER_REVIEW_REPOS`. Token
+forwarding stays off unless you explicitly set `REES_FORWARD_GITHUB_TOKEN=true`.
+
+
+
+## Analyzer selection
+
+Leave `REES_ANALYZERS` unset, `all`, or `*` to use the selected `REES_PROFILE` defaults. To run a
+subset, use exact comma-separated analyzer names. Unknown names are ignored with a
+`rees_analyzer_config_invalid` warning and the remaining valid analyzers still run. If every
+configured name is invalid, the engine sends an empty analyzer list so the typo fails closed
+instead of running the full registry.
+
+
+
+
+
+See the [REES analyzer reference](/docs/self-hosting-rees-analyzers) for each analyzer's inputs,
+network behavior, and finding shape.
+
+## Request boundary
+
+When enabled, the engine POSTs the repo name, PR number, head SHA, base SHA when GitHub supplies
+it, title, changed file paths, changed file patches, and review diff to `REES_URL`. It forwards no
+GitHub token by default. If `REES_FORWARD_GITHUB_TOKEN=true`, the engine includes a GitHub read
+token so GitHub API analyzers can read private CODEOWNERS and blob sizes. The engine prefers a
+short-lived installation token and falls back to `GITHUB_PUBLIC_TOKEN`. Enable forwarding only
+when the REES service is inside your trust boundary.
+
+
+ Do not point `REES_URL` at a service you do not trust with PR diffs. Token forwarding is optional,
+ but the diff/files themselves can contain private code.
+
+
+## Service configuration
+
+The REES service must use the matching `REES_SHARED_SECRET`. Optional Sentry env captures
+analyzer degradations without logging request bodies, tokens, diffs, or review content.
+
+
+SENTRY_DSN=
+SENTRY_ENVIRONMENT=production
+SENTRY_TRACES_SAMPLE_RATE=0`}
+/>
+
+## Failure behavior
+
+
+
+## Security boundary
+
+
+ REES output is untrusted advisory context. The engine sanitizes the public brief and never accepts
+ REES-provided system instructions.
+
+
+For broader secret handling, see [Self-host security](/docs/self-hosting-security).
diff --git a/apps/loopover-ui/content/docs/self-hosting-release-checklist.mdx b/apps/loopover-ui/content/docs/self-hosting-release-checklist.mdx
new file mode 100644
index 0000000000..02829a9f05
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-release-checklist.mdx
@@ -0,0 +1,592 @@
+---
+title: First release checklist
+description: Everything to confirm before cutting orb-v0.1.0 — the first stable (non-prerelease) self-host image — after two beta cuts already validated the pipeline end to end. Run the smoke matrix against a candidate image before tagging any orb-vX.Y.Z or -rc/-beta prerelease; CI only exercises the plain SQLite + Redis + direct-App default.
+---
+
+## Versioning and release trigger
+
+`orb-v0.1.0-beta.1` and `orb-v0.1.0-beta.2` already exercised the full release pipeline — multi-arch build, provenance, SBOM, Sentry source-map upload and release validation, and GitHub Release creation — twice, successfully. Neither moved `latest` or produced an unmarked GitHub Release, because [a prerelease tag never does](/docs/self-hosting-releases). The natural next step is **not a third beta**: it is `orb-v0.1.0`, a plain `X.Y.Z` tag with no `-rc`/`-beta` suffix.
+
+The release workflow (`.github/workflows/release-selfhost.yml`) resolves this distinction itself from the tag text, not from a separate flag — pushing `orb-v0.1.0` runs through the identical build/provenance/SBOM/Sentry steps the two betas already proved out, but the `PRERELEASE` value it computes flips to `false`, which (per the release-image-tags guard added for this exact reason) is what allows the run to push the `latest` image tag and create a non-prerelease GitHub Release:
+
+
+
+
+ Only a non-prerelease `X.Y.Z` tag ever moves `latest` or the repo's unmarked "Latest release" — a
+ `-rc`/`-beta` tag runs the same pipeline but is always excluded from both. Confirm the tag has no
+ prerelease suffix before pushing it; there is no undo for `latest` once an operator has pulled it.
+
+
+Going forward, the scheme is ordinary semver under the `orb-v` prefix: `orb-v0.1.1` for a patch, the next minor version for a feature bump, and an `-rc.N`/`-beta.N` suffix on any tag that should run the pipeline without touching `latest` or the default GitHub Release. This checklist and the smoke matrix below apply to every future cut, not just the first.
+
+## First-release checklist
+
+Work through this list once, in order, before pushing the `orb-v0.1.0` tag.
+
+ && git push origin orb-v0.1.0 — the workflow rejects any commit not reachable from main with "Self-host releases must be cut from a commit reachable from main."',
+ },
+ {
+ title: "7. Release environment approved",
+ description:
+ "The release job runs under the release GitHub Environment; if reviewer approval is configured, approve the pending run so the build/push/Sentry/notes steps proceed.",
+ },
+ {
+ title: "8. Post-publish verification",
+ description:
+ "docker pull the published orb-v0.1.0 and latest tags, confirm both resolve to the same digest, and re-run the fresh-install smoke scenario against the pulled (not locally built) image.",
+ },
+ ]}
+/>
+
+## Smoke-test matrix
+
+Every scenario below shares the same core check — `scripts/smoke-selfhost.sh` boots one container against a fresh Redis on an isolated network, waits for it to become healthy, and asserts on `/health`, `/ready`, `/metrics`, and startup log events. What changes per scenario is the env you pass in and which events you expect (or forbid).
+
+
+
+
+
+
Scenario
+
Steps
+
Pass criteria
+
+
+
+
+
Direct GitHub App (default)
+
+ Run the base smoke command with no ORB_ENROLLMENT_SECRET.
+
+
+ /health, /ready ok; selfhost_migrations_applied{" "}
+ logged; selfhost_orb_relay_register does NOT appear (relay is brokered-only).
+
+
+
+
Brokered — push mode
+
+ Set ORB_ENROLLMENT_SECRET and a real, internet-reachable{" "}
+ PUBLIC_API_ORIGIN.
+
+
+ selfhost_orb_relay_register logged;{" "}
+ selfhost_orb_relay_register_failed does NOT appear (failure here is{" "}
+ error-level and release-blocking).
+
+
+
+
Brokered — pull mode
+
+ Set ORB_ENROLLMENT_SECRET and ORB_RELAY_MODE=pull, no inbound
+ origin needed.
+
+
+ selfhost_orb_relay_register logged; a failed announce (warn
+ -level) is tolerated since the drain loop keeps retrying.
+
+
+
+
Air-gapped / no telemetry
+
+ Set ORB_AIR_GAP=true.
+
+
+ No export attempt or export error logged; no outbound request to the collector URL at the
+ network level.
+
+
+
+
AI provider (Claude Code / Codex / both)
+
+ Set AI_PROVIDER to each supported value with real credentials.
+
+
+ selfhost_ai_provider logged; selfhost_ai_cli_missing does NOT
+ appear (release-blocking if it does — the image was built without{" "}
+ INSTALL_AI_CLIS=true).
+
+
+
+
SQLite (default) / Postgres
+
+ Base command covers SQLite; boot a Postgres container and set DATABASE_URL{" "}
+ for the Postgres path.
+
+
+ Both boot healthy and apply migrations; note in release notes which mode beta testers
+ actually validated.
+
+
+
+
Redis (always-on) + optional Qdrant RAG
+
+ Base command covers Redis; set QDRANT_URL against a booted Qdrant container
+ for the RAG path.
+
+
+ selfhost_redis_ready always logged; selfhost_vectorize logged
+ only when QDRANT_URL is set.
+
+
+
+
Fresh install
+
+ Pull the published orb-v0.1.0 tag on a clean host (no prior volumes) and boot
+ via compose.
+
+
+ Container reports healthy; /ready returns 200 without any manual
+ migration step.
+
+
+
+
Upgrade from a source-built deploy
+
+ On an instance previously deployed via scripts/deploy-selfhost-prebuilt.sh,
+ run{" "}
+
+ scripts/deploy-selfhost-image.sh ghcr.io/<owner>/loopover-selfhost:orb-v0.1.0
+
+ .
+
+
+ Only the loopover service restarts (--no-deps);{" "}
+ .env, data volumes, and loopover-config/ are untouched;{" "}
+ /ready returns 200 after the health-check wait.
+
+
+
+
Rollback to prior tag
+
+ Re-run scripts/deploy-selfhost-image.sh pinned to the prior tag/digest (e.g.{" "}
+ orb-v0.1.0-beta.2).
+
+
+ Service restarts healthy on the older image; confirmed safe only when nothing since the
+ prior tag added a forward-only migration the older code can't tolerate (see [Updating
+ and rolling back](/docs/self-hosting-operations)).
+
+
+
+
One-service app restart
+
+ Re-run either deploy script against the same tag with other profile services (Postgres,
+ Redis, Qdrant, Grafana) already up.
+
+
+ Only the loopover container recreates; profile-service containers and their
+ volumes are never touched.
+
+
+
+
Sentry release validation
+
+ Confirm the release workflow's "Validate Sentry release" step passed for
+ this tag (source maps uploaded, release finalized, commits attached).
+
+
+ review-enrichment/scripts/validate-sentry-release.mjs exits 0 within its
+ 5-attempt retry-poll; the Sentry release id matches the baked{" "}
+ LOOPOVER_VERSION.
+
+
+
+
Docs links resolve
+
+ Follow every link in the release notes template below (setup guide, releases page, this
+ checklist) from the published GitHub Release.
+
+
+ Every linked docs page loads and matches the version being released.
+
+
+
+
+
+
+The scenario-by-scenario commands below give exact env and expected/forbidden log events for each row above.
+
+
+
+### Direct GitHub App mode (default)
+
+No `ORB_ENROLLMENT_SECRET` — the container uses its own GitHub App private key. Telemetry export is always-on in this mode too; a clean run produces no export error.
+
+
+
+`selfhost_orb_relay_register` must NOT appear here — relay registration is brokered-only and silently skips in direct mode (see [GitHub App and Orb](/docs/self-hosting-github-app)).
+
+### Brokered mode (private / managed-beta only)
+
+`ORB_ENROLLMENT_SECRET` set — the container gets tokens from the central Orb instead of its own App key. Relay mode changes what "working" means: push mode (`ORB_RELAY_MODE` unset, the default) needs a real public `PUBLIC_API_ORIGIN` and a failed registration is release-blocking (logged at `error`); pull mode (`ORB_RELAY_MODE=pull`) needs no inbound endpoint at all and tolerates a failed registration (logged at `warn`) since the drain loop keeps retrying regardless. Run BOTH scenarios — they exercise genuinely different code paths, not just different env (see [choosing a relay mode](/docs/self-hosting-github-app)).
+
+
+
+### Air-gapped / no-telemetry mode
+
+`ORB_AIR_GAP=true` disables the fleet-calibration export entirely. There is no "air-gap confirmed" log event — the export function returns before doing anything, so silence (no export error, no export attempt) is the signal. Confirm at the network level too: no outbound request to the collector URL.
+
+
+
+### AI provider: Claude Code / Codex / both
+
+Each provider choice must log `selfhost_ai_provider` and must NOT log `selfhost_ai_cli_missing` (a CLI-subscription provider whose binary isn't on `PATH` silently produces no review output — this must be caught here, not in production).
+
+
+
+
+ These need real credentials to reach a genuinely healthy `/ready` (it probes the configured AI
+ provider). Where credentials aren't available for a given RC run, at minimum confirm
+ `selfhost_ai_cli_missing` does NOT appear — that alone catches the release-blocking case (image
+ built without `INSTALL_AI_CLIS=true`).
+
+
+### SQLite trial mode / Postgres production mode
+
+SQLite is the default — the base smoke command above already covers it (no `DATABASE_URL` set). For Postgres, boot a Postgres container on the same network first and point `DATABASE_URL` at it.
+
+
+
+
+ SQLite is the trial/single-node default; recommend Postgres for production in release notes
+ whenever this mode is what beta testers actually validated.
+
+
+### Redis cache + optional Qdrant RAG
+
+Redis is always-on in every scenario above (the base script already boots it) — confirm `selfhost_redis_ready` appears with `githubResponseCacheEnabled` matching whatever `GITHUB_CACHE_TTL_SECONDS` you set. For the optional Qdrant RAG path, boot Qdrant on the same network and point `QDRANT_URL` at it.
+
+
+
+## Expected startup events
+
+
+
+## Known warnings: acceptable in beta vs. release-blocking
+
+
+
+## Image-contents audit
+
+The `runtime-prebuilt` target — what the release workflow actually builds and pushes (`docker/build-push-action` is invoked with `target: runtime-prebuilt`) — copies exactly three things on top of the `runtime-base` layer: the pre-bundled `dist/server.mjs`, the `migrations/` SQL files, and `config/examples/` (generic, safe reference templates — shipping them activates nothing, since `LOOPOVER_REPO_CONFIG_DIR` still points at an operator-mounted `/config`). Nothing else reaches that stage.
+
+
+
+Net effect of the two `.dockerignore`/Dockerfile changes audited above (already shipped, not part of this checklist itself): the built image measured **754MB**, down from 942MB before them. Re-verify the size on the actual published `orb-v0.1.0` image as part of the checklist:
+
+
+
+
+ This audit is Dockerfile-derived, not a runtime scan. If a future dependency bump adds a
+ postinstall step that writes somewhere unexpected, re-check the `runtime-prebuilt` stage's
+ `COPY`/`RUN` steps directly rather than assuming this list still holds.
+
+
+## One default image, not full/minimal variants
+
+`INSTALL_AI_CLIS` is already a Dockerfile build-arg toggle (default `true`), and `INSTALL_VISUAL_REVIEW` is a second, independent one (default `false`) — see [custom images](/docs/self-hosting-releases). That means the "minimal" image the requirement asks about is already buildable today by anyone who wants it, as a _custom_ build.
+
+For this first official release, publish only the one default (`INSTALL_AI_CLIS=true`) image under `orb-v0.1.0`. Reasons:
+
+
+
+
+ Defer the full/minimal published-variant question, not the build-arg. If real operator demand for
+ a smaller published tag shows up post-release, it's a follow-up release-workflow change (a second
+ `docker/build-push-action` invocation with `INSTALL_AI_CLIS=false` and its own tag suffix), not a
+ blocker for cutting `orb-v0.1.0`.
+
+
+## GitHub Release notes template
+
+The release workflow's own "GitHub Release" step generates the notes body programmatically (see `.github/workflows/release-selfhost.yml`) — it does not use `--generate-notes`, specifically to avoid GitHub's 125,000-character release-body limit on a large commit history. The template below matches that generated body and extends it with the supported/experimental/optional/operator-owned breakdown this checklist calls for. Paste it into the release description in addition to (or in place of) the workflow-generated block when publishing `orb-v0.1.0`.
+
+
+
+After every applicable scenario passes, continue with the normal [upgrade flow](/docs/self-hosting-releases) to cut the tag and publish the image.
diff --git a/apps/loopover-ui/content/docs/self-hosting-releases.mdx b/apps/loopover-ui/content/docs/self-hosting-releases.mdx
new file mode 100644
index 0000000000..071afffe59
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-releases.mdx
@@ -0,0 +1,113 @@
+---
+title: Releases and images
+description: How to consume official self-host images, pin versions, build custom images, and keep source maps aligned.
+---
+
+## Image tags
+
+
+
+
+
+
+ `ghcr.io/jsonbored/loopover-selfhost` is the only name new releases publish under. The pre-rename
+ `ghcr.io/jsonbored/gittensory-selfhost` is no longer published to, but an existing pin to a
+ specific tag or digest under that name keeps resolving — pull it and switch to the new name at
+ your convenience.
+
+
+## Prerelease (beta/rc) images
+
+A tag like `orb-v0.1.0-rc.1` or `orb-v0.1.0-beta.1` runs the identical build/provenance/SBOM/Sentry pipeline as a stable release, but is marked prerelease on GitHub and is never pushed under `latest`. External beta testers should pull the exact prerelease tag, not `latest`.
+
+
+
+
+ Stable release behavior is unchanged: a plain `X.Y.Z` tag still moves `latest` and publishes an
+ unmarked (non-prerelease) GitHub Release.
+
+
+
+ Before tagging any `orb-v*` release or prerelease, run the [release
+ checklist](/docs/self-hosting-release-checklist) against the built image — CI only smoke-tests the
+ plain SQLite + Redis + direct-App default, not brokered mode, air-gapped mode, or any AI provider.
+
+
+## Upgrade flow
+
+**1.** Read release notes for env, migration, or behavior changes.
+
+**2.** Back up the database or confirm Litestream health.
+
+**3.** Pull and restart with `scripts/deploy-selfhost-image.sh` (or rebuild the checkout with `scripts/deploy-selfhost-prebuilt.sh`) — both restart only the `loopover` service (`--no-deps`) and wait for it to report `healthy` before returning, instead of a bare `docker compose up -d` that returns as soon as the container starts.
+
+**4.** Check `/ready`, logs, queue metrics, and one test PR.
+
+
+
+
+ Both scripts pin a version: the image script accepts a tag/digest argument or `LOOPOVER_IMAGE`;
+ the prebuilt script derives `SENTRY_RELEASE`/`LOOPOVER_VERSION` from the checked-out commit (`git
+ rev-parse --short=8 HEAD`) unless you set `SENTRY_RELEASE` yourself. A plain `docker compose pull
+ loopover && docker compose up -d loopover` still works, but skips the health-check wait loop and
+ input validation both scripts provide.
+
+
+## Custom images
+
+Custom builds are useful for testing local changes, including subscription CLIs, or trimming the image. They should not contain secrets. `INSTALL_AI_CLIS` (default `true`) installs the Claude Code and Codex CLIs; a sibling build-arg, `INSTALL_VISUAL_REVIEW` (default `false`), adds `puppeteer-core` for visual capture.
+
+
+
+## Sentry source maps
+
+
+ Official releases align `LOOPOVER_VERSION`, Sentry release ids, and uploaded source maps. For
+ custom images, leave `SENTRY_RELEASE` unset unless you uploaded source maps for that exact built
+ bundle.
+
+
+## Rollback
+
+There is no dedicated rollback command. Roll back by re-running `scripts/deploy-selfhost-image.sh` pinned to the prior image tag or digest (or `scripts/deploy-selfhost-prebuilt.sh` against an older checkout) — the same script you upgrade with, pointed backward.
+
+
+ This repo has no down-migration convention (`scripts/check-migrations.mjs` and `migrations/` only
+ ever add forward). If a migration already ran forward before you need to roll back, reverting the
+ app image does not revert the schema — the rolled-back code now runs against a newer schema than
+ it expects. Keep backups and read release notes for migration changes before upgrading a live
+ maintainer instance, and treat a post-migration rollback as a case that needs a manual schema/data
+ plan, not just an image swap.
+
diff --git a/apps/loopover-ui/content/docs/self-hosting-security.mdx b/apps/loopover-ui/content/docs/self-hosting-security.mdx
new file mode 100644
index 0000000000..7681701407
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-security.mdx
@@ -0,0 +1,210 @@
+---
+title: Security
+description: The self-host stack holds maintainer credentials and policy. Keep those boundaries explicit.
+---
+
+## Secret handling
+
+
+
+`docker-compose.yml` ships native Docker Compose `secrets:` mounts for the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/internal-job tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret, the PagerDuty routing key, and the Claude Code subscription token) — file-mounted at `/run/secrets/`, never exposed via `docker inspect` or `docker compose config` the way a plain `environment:`/`env_file` value is. This is purely additive: an inline `.env` value always takes priority if you set both, so you can migrate one secret at a time, or not at all. See `secrets/README.md` for the full file list.
+
+ secrets/github_webhook_secret.txt
+docker compose up -d --no-deps loopover`}
+/>
+
+## Optional: Infisical secrets management
+
+The hardened default above — `.env` plus Docker Compose `secrets:` — has no rotation, audit trail, or RBAC. If you want secrets-manager-grade rotation, audit logging, and access control on top of that default, you can opt into [Infisical](https://infisical.com) — an open-source, self-hostable secrets manager. This is **strictly optional and additive**: skip this section entirely and the hardened `.env`/Docker secrets default keeps working unchanged.
+
+
+ Infisical wires in at the deploy-script level via its own `infisical run -- ` wrapper, which injects secrets as real process environment variables at container launch. Nothing under `src/` knows or cares whether a given `env.SOMETHING` value came from Infisical, `.env`, or a Docker secret file.
+
+
+### Setup: cloud or self-hosted
+
+**1.** Install the [Infisical CLI](https://infisical.com/docs/cli/overview) on the machine that runs the deploy script (not inside the app container).
+
+**2.** Pick where your secrets live: Infisical Cloud (the default, zero infrastructure of your own) or a self-hosted Infisical instance — if you're already self-hosting LoopOver, you can self-host Infisical alongside it. Either way, run `infisical login` once, then `infisical init` from the repo root to link a local `.infisical.json` to an Infisical project.
+
+**3.** Create an environment inside that project (e.g. `prod`) matching how you think about this deployment, and add the secrets you want Infisical to manage — same variable names your `.env`/`docker-compose.yml` already use (`GITHUB_APP_PRIVATE_KEY`, `GITHUB_WEBHOOK_SECRET`, provider API keys, and so on).
+
+**4.** Opt in when deploying:
+
+
+
+With the flag unset (the default), neither script touches Infisical at all — not even a presence check — so an operator who has never heard of Infisical is completely unaffected. With it set, the restart step (the one that actually launches the container) runs through `infisical run --`; a missing `infisical` binary fails the deploy immediately with a clear error rather than silently deploying without the secrets you asked for.
+
+### Interaction with .env and Docker secrets — do not mix the same variable
+
+
+ `infisical run --` injects secrets into its own child process's environment — in this case, the
+ `docker compose up` invocation. Docker Compose only lets a host shell variable reach the container
+ for an `environment:` entry written as `SOMEVAR: "${SOMEVAR}"`. It does **not** reach a plain
+ `env_file: .env` block, which reads that file's literal contents at container runtime and is never
+ affected by the deploying shell's environment. The GitHub App private key, webhook secret, API/MCP
+ tokens, and the rest of the native-secrets list above are wired through the `_FILE` convention and
+ `env_file: .env`, not through `environment:` interpolation — an Infisical value for one of those
+ exact names, by itself, will **not** reach the container today. Infisical is the right fit for
+ *other* variables you reference via `"${VAR}"` interpolation in your own
+ `docker-compose.override.yml` (a provider API key you add yourself, for example) — not a drop-in
+ override for the pre-wired native-secrets list.
+
+
+The safest rule of thumb: for any given variable, pick _one_ source — Infisical or a plain `.env`/Docker secret file, never both for the same name. Setting the same name in both places doesn't error; whichever mechanism the container actually reads for that variable (see the callout above) wins silently, which is easy to misdiagnose later.
+
+## Private policy
+
+Keep sensitive review thresholds, autonomy, maintainer notes, and repo-specific rules in `LOOPOVER_REPO_CONFIG_DIR`, not in public repo config.
+
+
+
+## Network exposure
+
+- Expose the webhook endpoint only through TLS — see "TLS termination" below for the two shipped ways to get there.
+- Prometheus, Qdrant, Ollama, and the database ports are private by default (bound to `127.0.0.1` or only reachable on the compose network) — but **Grafana is the exception**. Its compose entry publishes `3000:3000`, which binds every interface, not just localhost. Bind it yourself (`127.0.0.1:3000:3000` in a compose override) — the reliable fix — before running the `observability` profile anywhere it isn't already firewalled. Running Tailscale alongside it does **not** narrow this on its own (see "TLS termination" below); combining the two safely still needs the same firewall or `tailscale serve` step.
+- Put an auth layer in front of dashboards and internal admin routes.
+- Use `/ready` for orchestrators, not as a public status surface.
+
+The `observability` profile also runs a `docker-proxy` service that never appears in any dashboard or metric. It fronts the Docker socket for Promtail's container log discovery: a plain `:ro` bind-mount of `/var/run/docker.sock` only protects the socket inode, not the Docker API behind it, so handing Promtail the raw socket is effectively host root — enumerate every container, read each one's environment and secrets, tail every log, or start a privileged container and escape to the host. `docker-proxy` is the only container that touches the socket, exposes just the read-only `/containers/*` and `/networks/*` endpoints Promtail's service discovery needs, denies every mutating call outright, and sits alone on its own Docker network shared only with Promtail — publishing no host port isn't enough on its own, since the default compose network is reachable by every other service in the stack.
+
+## Control-panel access
+
+GitHub sign-in to the control panel (the maintainer/owner dashboard) is gated by `ADMIN_GITHUB_LOGINS` — a comma- or whitespace-separated, case-insensitive allowlist of GitHub logins.
+
+
+
+
+ Unset or empty means NOBODY gets control-panel access — not even the person who just finished
+ setup. This is intentional, not a bug: add your own GitHub login here right after first-run setup,
+ or you will sign in successfully and see zero privileges with no explanation. The same allowlist
+ also exempts these logins from the agent's own-PR auto-close rules and lets them bypass per-repo
+ MCP scope (`MCP_READ_REPO_ALLOWLIST` / `MCP_ACTUATION_REPO_ALLOWLIST`).
+
+
+## AI credential boundaries
+
+
+ CLI auth files can be readable by the runtime. Do not mount a prompt-readable Claude Code or Codex
+ home into review execution unless you have intentionally isolated it. API-key and local model
+ providers are easier to reason about operationally.
+
+
+## REES boundary
+
+REES receives PR diff and file metadata. Use a private network URL when possible, require `REES_SHARED_SECRET`, and remember that the engine treats REES output as untrusted advisory context.
+
+## TLS termination
+
+These are the three shipped ways to get real HTTPS without hand-rolling a reverse proxy — but only Caddy and bring-your-own-proxy give you a _publicly reachable_ origin. If GitHub itself needs to reach this instance (a direct App in push mode, per [GitHub App and Orb](/docs/self-hosting-github-app)), Tailscale's private tailnet address does not satisfy that — GitHub's servers can't reach it. Tailscale is the right fit when only your own team/CI needs access, or as the transport for a [brokered, pull-mode](/docs/self-hosting-github-app) instance that never needs to receive an inbound webhook at all.
+
+
+
+### Caddy: automatic HTTPS with Let's Encrypt
+
+The `caddy` profile runs Caddy 2 in front of the `loopover` service, terminating TLS on `80`/`443`/`443/udp` (the last for HTTP/3) and obtaining a Let's Encrypt certificate automatically for whatever domain you set. It needs a real DNS record: point `DOMAIN` at this host's public IP _before_ starting the profile. The shipped Caddyfile has no fallback TLS directive, so if the ACME HTTP-01 challenge fails (DNS not propagated yet, port 80 unreachable), Caddy does **not** silently substitute a self-signed cert for a real domain — it logs the failure and retries with backoff, and the site has no working HTTPS until DNS and ACME both succeed. (A recognized non-public hostname like `localhost`, below, is a deliberately different case — Caddy issues its own internal-CA cert for those automatically, since it can never get a real one.)
+
+
+
+The shipped `caddy/Caddyfile` reverse-proxies to `loopover:8787` on the compose network, forwards the real client IP, enables compression, sets standard security headers (HSTS, `X-Content-Type-Options`, `X-Frame-Options`, a strict referrer policy), and logs as JSON to stderr:
+
+
+
+Edit this file directly if you need a different upstream, extra headers, or a second site block — Caddy re-reads it on container restart. For local testing without a real domain, set `DOMAIN=localhost`; Caddy issues a self-signed cert and your browser will warn about it, which is expected.
+
+
+ The `loopover` service's compose entry has a direct `ports: ["${PORT:-8787}:8787"]` mapping with a comment marking exactly this: remove it once Caddy is your public listener, or the app stays reachable on `:8787` with no TLS, bypassing the proxy entirely and defeating the whole point of adding it. (This rule is Caddy-specific — the Tailscale profile below needs the *opposite* treatment; see its own callout.)
+
+
+Prefer certificates you already manage — an internal CA, a wildcard cert issued elsewhere — instead of Let's Encrypt? Mount your own cert and key into the container and point the `{$DOMAIN}` block at a file-based TLS directive (`tls /path/to/cert /path/to/key`) instead of the automatic-HTTPS default; see [Caddy's `tls` directive docs](https://caddyserver.com/docs/caddyfile/directives/tls) for the syntax.
+
+### Already run a reverse proxy or load balancer?
+
+Skip the `caddy` profile entirely. Remove the same direct `ports:` mapping from the `loopover` service, keep it on the compose network (or publish `8787` bound to a private interface your existing proxy can reach), and terminate TLS the way you already do for everything else — nginx, Traefik, an AWS ALB, a Cloudflare Tunnel. Whatever fronts it just needs to forward to port `8787` and preserve the client IP the same way the shipped Caddyfile does.
+
+### Tailscale: adds tailnet reachability
+
+The `tailscale` profile joins the stack to your tailnet. It runs with `network_mode: host` — Tailscale needs host networking to advertise this machine's address on the tailnet. On its own, this only _adds_ a reachable address; see the callout below before assuming it also removes public reachability.
+
+
+
+
+ Tailscale doesn't replace the `loopover` service's listener the way Caddy does — it adds a new network interface to the *host*. Docker's default `ports: ["${PORT:-8787}:8787"]` mapping publishes to all of the host's interfaces, so once Tailscale is up, that same mapping is what makes port `8787` reachable at the host's tailnet IP too — **removing it, as you would for Caddy, makes the app unreachable everywhere, tailnet included.**
+
+
+The tradeoff: leaving the default `0.0.0.0`-bound mapping in place means `8787` is also still reachable from your LAN, and from the public internet if this host has a public interface at all — Tailscale doesn't narrow that on its own. If you want the instance reachable _only_ via the tailnet, either firewall the host to allow `8787` solely from your tailnet's address range, or bind the app's mapping to `127.0.0.1:8787:8787` and use [`tailscale serve`](https://tailscale.com/kb/1242/tailscale-serve) inside the `tailscale` container (it shares the host's loopback under `network_mode: host`) to proxy that localhost-only port onto the tailnet — check the pinned image's `tailscale serve --help` for the exact current flags. This profile is the right choice when the instance only needs to be reachable by your own team or CI, and you'd rather not manage a domain or certificate at all.
+
+## Public output boundary
+
+Public PR comments and checks must not leak secrets, private policy, provider credentials, private scoring context, or maintainer-only notes. For hosted and self-host boundaries, keep [Privacy and security](/docs/privacy-security) nearby.
diff --git a/apps/loopover-ui/content/docs/self-hosting-troubleshooting.mdx b/apps/loopover-ui/content/docs/self-hosting-troubleshooting.mdx
new file mode 100644
index 0000000000..852c9f369a
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-troubleshooting.mdx
@@ -0,0 +1,209 @@
+---
+title: Troubleshooting
+description: Start with readiness and logs, then isolate webhook, queue, AI, REES, RAG, or write-suppression problems.
+---
+
+## First checks
+
+
+
+## No review appears
+
+
+
+## AI summary unavailable
+
+- Confirm `AI_PROVIDER` is set and supported.
+- Confirm the provider key or local endpoint works from inside the container.
+- Set the matching provider model env, such as `ANTHROPIC_AI_MODEL`, `OPENAI_COMPATIBLE_AI_MODEL`, `OLLAMA_AI_MODEL`, `CLAUDE_AI_MODEL`, or `CODEX_AI_MODEL`.
+- Increase the matching provider timeout env, such as `CLAUDE_AI_TIMEOUT_MS` or `CODEX_AI_TIMEOUT_MS`, for large subscription-CLI reviews.
+- For CLI providers, confirm the CLI binary and credential path are available.
+
+## REES is silent
+
+A no-finding REES response can be intentionally invisible. For failures, search logs for `review_context_fetch_failed` with `contextType` set to `enrichment`.
+
+
+
+Check [REES enrichment](/docs/self-hosting-rees) for enablement and [REES analyzer reference](/docs/self-hosting-rees-analyzers) for analyzer names, network calls, and token requirements.
+
+## RAG returns no context
+
+- Confirm `LOOPOVER_REVIEW_RAG=true`, the repo is in `LOOPOVER_REVIEW_REPOS` (or has `features.rag: true` in private config), and RAG indexing has run — see [Configuration](/docs/self-hosting-configuration) for the three activation layers (`is_registered` is only required for Gittensor-scored paths, not RAG on a self-host allowlist).
+- Confirm Qdrant or the vector backend is reachable from the app container.
+- Confirm the embedding endpoint and model are running.
+- Confirm the repo has been indexed after enabling the feature.
+
+## Queue stuck or dead jobs
+
+Watch pending, processed, failed, and dead metrics. A high pending count can be webhook replay or maintenance work; dead jobs need direct investigation.
+
+
+
+## GitHub rate-limit responses or admission deferrals
+
+Two independent signals cover this: `loopover_github_rest_rate_limit_responses_total` counts actual 403/429 responses from GitHub, and the `loopover_jobs_rate_limit_admission_deferred_total` / `loopover_jobs_rate_limit_budget_deferred_total` / `loopover_jobs_rate_limited_by_type_total` counters track jobs the queue itself held back _before_ making a request, to avoid tripping a limit. All three job-side counters carry the same three labels — `kind` (`webhook` or `background`), `key_scope` (`installation`, `public`, `global`, or `other`), and `job_type` (the queue job's type, e.g. `agent-regate-pr`) — so you can break a spike down to exactly which token pool and which job type is under pressure.
+
+A short burst of deferrals is expected and self-resolving: the queue is deliberately trading a few seconds of delay to avoid a real 429. Treat it as a real problem only once it's **sustained** — which is exactly what `LoopoverGitHubRateLimitResponses` (real 403/429s observed) and `LoopoverQueueRateLimitDeferralsHigh` (a sustained deferral rate, not a blip) are tuned to alert on, rather than firing on every brief admission hold.
+
+
+
+If a single `key_scope=installation` pool is consistently the bottleneck, the fix is usually spreading load across more installation tokens (fewer repos per installation) or raising the GitHub App's own rate-limit tier, not code changes here.
+
+## Low GitHub response-cache hit rate
+
+`loopover_github_response_cache_total` (REST) and `loopover_github_graphql_cache_total` (GraphQL) both carry a `result` label — `hit`, `miss`, `set`, `coalesced`, `bypassed`, or `error` — and a `class` label identifying the endpoint family. A healthy cache should show most traffic as `hit` for endpoints that are read repeatedly in one review/maintenance pass (PR reads, check-run lookups); a low hit rate on those specific classes, not the overall average, is the useful signal.
+
+
+
+## Qdrant / vector-store errors
+
+`loopover_qdrant_errors_total` carries an `op` label (`upsert`, `query`, or `delete`) so you can tell whether indexing or retrieval is failing. `LoopoverQdrantErrorRateHigh` fires on a sustained error ratio, not an isolated blip.
+
+- Confirm `QDRANT_URL` (e.g. `http://qdrant:6333`) is reachable from the app container and the `qdrant` Compose profile is running.
+- If Qdrant requires auth, confirm `QDRANT_API_KEY` is set and matches the Qdrant deployment's configuration.
+- A dimension-mismatch error means the existing `loopover` collection (the fixed collection name self-host always uses) was created with a different embedding model than the one currently configured (`AI_EMBED_MODEL`). Recreating it — delete the collection and let the next index run recreate it at the current width — is the fix, but it temporarily removes ALL indexed RAG context for every repo until re-indexing completes, so treat it as a deliberate, disruptive step, not a routine one.
+
+
+
+## Orb export or relay problems
+
+For brokered self-host deployments, `loopover_orb_events_exported_total` and `loopover_orb_export_errors_total` track the hourly outcome-export loop; `LoopoverOrbExportErrorRateHigh` fires on a sustained error ratio there. The pull-mode relay loop (for installations receiving events outbound from Orb) reports through `loopover_orb_relay_drains_total` (`result=events` when it drained something, `result=empty` otherwise) and `loopover_orb_webhook_total` (`event` + `result` labels) for what happened to each relayed event once enqueued locally.
+
+If exports are failing but the relay itself looks healthy, the export loop's Sentry cron monitor (see [Self-host operations](/docs/self-hosting-operations)) is the fastest way to confirm whether the loop is even running, before digging into the error counters.
+
+`selfhost_orb_relay_register_failed` means the container could not announce itself to the broker at boot. What it means depends on `ORB_RELAY_MODE` (see [choosing a relay mode](/docs/self-hosting-github-app)):
+
+- **Push mode** (default) — logged at `error` and **release-blocking**: the container looks healthy but never receives an event, since there is no fallback delivery path. Check that `PUBLIC_API_ORIGIN` is a real, internet-reachable, TLS-terminated URL — the broker rejects a loopback or private address at registration time, so a misconfigured or unreachable origin fails here every time, not intermittently.
+- **Pull mode** — logged at `warn` and non-fatal: the drain loop (`loopover_orb_relay_drains_total`) keeps retrying on its own schedule regardless, so a transient failure here recovers on its own once the broker is reachable again. A registration failure that never clears across many retries still points at `ORB_ENROLLMENT_SECRET` being wrong, revoked, or not yet provisioned server-side.
+
+Either mode: confirm `ORB_BROKER_URL` is reachable from inside the container first — a broker that is down or unreachable produces the same registration-failed event regardless of relay mode or credential correctness.
+
+## AI provider circuit breaker keeps opening
+
+Each AI provider (self-host `AI_PROVIDER` entries) has its own circuit breaker: after 3 consecutive failures it stops attempting real calls to that provider for 60 seconds, recorded as `loopover_ai_provider_circuit_open_total{provider="..."}` (skipped calls) alongside `loopover_ai_provider_failures_total{provider="..."}` (real failures). It self-heals automatically — there is no manual reset — but it will reopen immediately if the underlying problem is still there.
+
+- Search logs for `circuit_open: provider "..."` to confirm which provider tripped, and `selfhost_ai_provider_failed_in_chain` for the real error each failed attempt hit before the breaker opened.
+- A provider that keeps re-tripping after its cooldown almost always means a persistent problem, not a transient blip: an expired/invalid API key, a CLI binary missing from the image (see `selfhost_ai_cli_missing` at boot), or the endpoint being genuinely unreachable from the container.
+- `LoopoverAiProviderCircuitOpen` fires on any circuit-open event in a 15-minute window — a single trip during a real but brief outage is expected; a rule that keeps firing across multiple windows points at the persistent case above.
+
+## Grafana metrics/dashboards show no data
+
+The metrics path is the app's own `/metrics` (scraped directly by Prometheus) plus, for Claude Code's OTEL telemetry specifically, app or smoke process → OTEL collector → its Prometheus exporter (`:8889`) → Prometheus.
+
+
+
+- If the smoke command fails at `otel-collector:4318/v1/metrics`, the collector is not reachable from the app container.
+- If it pushes successfully but cannot read it back from `otel-collector:8889/metrics`, the collector's Prometheus exporter is unhealthy or the pipeline in `otel/otel-collector-config.yml` is misconfigured.
+- If the app's own `/metrics` check fails, that is unrelated to the OTEL collector — check the app container directly (`docker compose logs loopover`).
+- If the smoke command passes but a Grafana dashboard panel is still blank, check that panel's own PromQL expression against a metric name actually emitted in `src/` — a renamed or removed metric a dashboard still references renders as a permanent zero/no-data, not an error.
+
+## Grafana traces error or show no data
+
+The trace path is app or smoke process → OTEL collector → Tempo → Grafana. Tempo is only started by the observability profile, and app traces are only emitted when `OTEL_TRACES_EXPORTER` includes `otlp`.
+
+
+
+- If the smoke command fails at `otel-collector:4318/v1/traces`, the collector is not reachable from the app container.
+- If it pushes successfully but cannot read `tempo:3200/api/traces/`, Tempo is unhealthy, not ingesting, or not sharing the Compose network.
+- If the smoke command passes but Grafana Explore fails, check the Tempo data source URL. It should point at `http://tempo:3200`, not the OTLP ingest ports.
+- For a temporary live debugging run, set `OTEL_TRACES_SAMPLER_ARG=1` so every root trace is sampled, then lower it again after diagnosis.
+
+## Readiness fails
+
+
diff --git a/apps/loopover-ui/content/docs/self-hosting-unified-ams-orb.mdx b/apps/loopover-ui/content/docs/self-hosting-unified-ams-orb.mdx
new file mode 100644
index 0000000000..868175ba2a
--- /dev/null
+++ b/apps/loopover-ui/content/docs/self-hosting-unified-ams-orb.mdx
@@ -0,0 +1,126 @@
+---
+title: Running ORB and AMS together
+description: Run ORB (self-hosted PR review) and AMS (the autonomous miner) together on one host -- shared state, aligned .env, and working AMS Grafana panels in fleet mode.
+---
+
+ORB (self-hosted PR review) and AMS (the autonomous miner) each run on their own, but there is one
+combined setup that closes the loop on a single host — with shared on-disk state so the AMS
+observability panels actually populate.
+
+Two compose files are involved, and they are deliberately separate:
+
+
+
+Standing both up on one host is just two `docker compose` invocations. The one thing that needs
+care is **where AMS keeps its SQLite state**, because the ORB `ams-observability` exporter reads
+that state from a host directory — and fleet mode does not write there by default.
+
+## 1. Bring up ORB
+
+Follow the [Self-hosting quickstart](/docs/self-hosting-quickstart) to configure ORB's `.env` and
+boot the stack. For this combined setup, enable both the `observability` profile (Prometheus,
+Alertmanager, Loki, and Grafana) and the `ams-observability` profile (the
+`ams-reporting-exporter` that feeds AMS data into Grafana):
+
+
+
+
+ Grafana itself ships under `--profile observability`; the AMS exporter that populates its AMS
+ datasources ships under `--profile ams-observability`. Enable both, or the AMS panels stay empty
+ even though the rest of the observability stack is up.
+
+
+## 2. Bring up AMS in fleet mode
+
+Fleet mode reads credentials from an env file and runs the continuous worker loop. Build and start
+it from the repo root:
+
+
+
+On its own this works — but the miner's SQLite ledgers now live in a Docker **named volume**
+(`miner-data`), whose real host path is a Docker-managed internal detail. The ORB exporter,
+meanwhile, reads the ledgers from a host directory (default `~/.config/loopover-miner`), so the
+two never line up on their own and the Grafana AMS datasources stay **silently empty**.
+
+## 3. Bridge the state so AMS panels populate
+
+The AMS package ships an opt-in override that relocates the fleet miner's `/data/miner` state onto
+the *same* host directory the exporter reads — using the same `LOOPOVER_MINER_CONFIG_DIR`
+variable and default, so there is no `docker volume inspect` archaeology. Copy the example (it is
+gitignored) and run all three compose files together with both profiles:
+
+
+
+The override bind-mounts `/data/miner` to `${LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}`
+— Compose merges by container path, so this *replaces* the base file's `miner-data` named-volume
+mount for the same target rather than adding a second one. The exporter's own bind is the same
+source: `${LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}:/ams-ledgers:ro`. Both sides now
+read one location.
+
+
+ Leave `LOOPOVER_MINER_CONFIG_DIR` unset on both to use the shared default, or set it once so both
+ the fleet miner and the ORB exporter follow it. If only one side sets it, they diverge again and
+ the AMS panels go empty — this is the mismatch the fleet-mode bridge exists to close.
+
+
+## 4. Verify the AMS panels
+
+
+
+
+ This is the fleet-mode bridge that `#5805` introduced; the AMS-deployment reference in
+ `packages/loopover-miner/DEPLOYMENT.md` ("Running fleet mode alongside ORB's ams-observability
+ profile") documents the same override for a package-internal audience. Laptop-mode AMS already
+ writes to `~/.config/loopover-miner` directly and needs no override — only fleet mode's named
+ volume does.
+
diff --git a/apps/loopover-ui/content/docs/troubleshooting.mdx b/apps/loopover-ui/content/docs/troubleshooting.mdx
new file mode 100644
index 0000000000..4966690173
--- /dev/null
+++ b/apps/loopover-ui/content/docs/troubleshooting.mdx
@@ -0,0 +1,95 @@
+---
+title: Troubleshooting
+description: The MCP ships with diagnostics. Start with doctor, then drill in.
+---
+
+## Health checks
+
+
+
+Or hit the public API endpoint directly to confirm reachability:
+
+
+
+## Self-host Docker observability
+
+The Docker stack exposes three different operator signals: structured logs from the
+`loopover` container, Prometheus metrics at `/metrics`, and optional
+OpenTelemetry traces through the observability profile. Metrics answer _how much_
+work is happening; traces answer _where time went_ inside a review job.
+
+
+
+App traces are off unless `OTEL_TRACES_EXPORTER` includes `otlp`. When
+enabled, the self-host runtime exports durable queue-job spans and AI-provider attempt spans
+over OTLP/HTTP; the default collector endpoint is normalized to `/v1/traces`.
+Span attributes are bounded to operational labels such as job type, queue backend, provider,
+model, and request kind.
+
+
+ Do not put request bodies, prompts, diffs, credentials, or private config in trace attributes. The
+ built-in self-host spans intentionally avoid those fields.
+
+
+## Common issues
+
+### Login hangs on device flow
+
+Confirm you can reach `github.com/login/device` in your browser. Re-run
+`loopover-mcp login` and paste the new code.
+
+### "Stale fidelity" warning
+
+Upstream Gittensor changed. See [Upstream drift](/docs/upstream-drift) for what
+that means and how to interpret cached analysis until fidelity recovers.
+
+### MCP not appearing in my editor
+
+Re-run the appropriate `init-client` command and restart the editor. See
+[MCP client setup](/docs/mcp-clients) for per-editor config locations.
+
+### 401 Unauthorized from the API
+
+Your LoopOver session expired. Run `loopover-mcp login` again. Static bearer
+tokens are not user-facing.
+
+## API status & offline mode [#api-status]
+
+The site continuously monitors the LoopOver API and surfaces problems through a banner under
+the header and a single deduped toast with a **Recheck** button.
+
+### You're offline [#offline]
+
+We detected your browser is offline (`navigator.onLine === false`). Live API
+actions in the API reference are paused. Reconnect and the site auto-rechecks
+`/health` within a few seconds.
+
+### API unreachable [#api-unreachable]
+
+`/health` couldn't be reached at all. This usually means a network problem
+between you and the API edge. Try the **Recheck** button in the banner, or run
+`curl https://api.loopover.ai/health` from your machine to confirm.
+
+### API timing out [#api-timeout]
+
+`/health` didn't respond within the 4-second probe window. The API may be slow or
+restarting. Retry — most timeouts resolve within a minute.
+
+### API degraded [#api-degraded]
+
+`/health` returned a non-2xx response. Some endpoints may still work; check the
+Roadmap for incident notes or wait for the recheck cycle to clear.
diff --git a/apps/loopover-ui/content/docs/tuning.mdx b/apps/loopover-ui/content/docs/tuning.mdx
new file mode 100644
index 0000000000..90105bbedf
--- /dev/null
+++ b/apps/loopover-ui/content/docs/tuning.mdx
@@ -0,0 +1,405 @@
+---
+title: Tuning your reviews
+description: How to configure LoopOver CI and LoopOver review -- gate modes, score thresholds, guardrails, and feature flags -- through .loopover.yml and your repo settings.
+---
+
+## How configuration fits together
+
+**LoopOver review** is the engine that scores, gates, and comments on your pull
+requests. You shape its behavior in two places, and you never have to touch the review
+algorithm itself:
+
+- **Per-repo settings** — gate modes, score thresholds, guardrails, and which
+ surfaces are enabled. Set them in the dashboard, or declare them as config-as-code in a
+ `.loopover.yml` file in the repo.
+- **Feature flags** — the `LOOPOVER_REVIEW_*` family of environment
+ variables on the worker. These switch whole capabilities (safety scanning, grounding, RAG
+ context, the unified comment, the content lane, observability, self-tuning, and more) on
+ or off for the deployment.
+
+The review algorithm — the deterministic gate, the scoring signals, the slop detector, the
+grounding and RAG context builders, and the comment renderer — is open source. Anyone can
+read exactly how a verdict is reached. The settings above sit _on top_ of that open
+algorithm and never reveal review _direction_, so a contributor cannot read them and
+game the gate.
+
+This page covers those fields in depth, for the cloud service or a self-host alike. If
+you're running your own instance, see
+[Self-host configuration](/docs/self-hosting-configuration) for the
+environment layer (deployment-wide flags, secrets, and where config files can live) that
+sits underneath everything below.
+
+
+ Every feature flag ships **OFF**. A repo with no settings and no `.loopover.yml` falls back to a
+ quiet, non-blocking profile: the gate is `off`, AI review is `off`, slop scoring is `off`,
+ comments go only to detected contributors, and no check-run is published. Turning anything on is
+ always an explicit opt-in — you roll capabilities forward, and back, one flag and one repo at a
+ time.
+
+
+## Precedence
+
+Most specific wins:
+
+- `.loopover.yml` in the repo, then
+- per-repo database settings, then
+- built-in safe defaults.
+
+Path holds are explicit config-as-code only: a configured
+`settings.hardGuardrailGlobs` ADDS repo-specific globs on top of a fixed set of
+built-in invariant guardrails that always apply and can never be disabled. Omitted or empty
+means only those built-in invariants hold.
+
+The friendly `gate:` block in `.loopover.yml` is a typed alias for the
+gate-related fields and wins over the generic `settings:` block for those same
+fields. LoopOver looks for the manifest at the first match of `.loopover.yml` →
+`.github/loopover.yml` → `.loopover.json` →
+`.github/loopover.json`.
+
+## Feature flags (LOOPOVER*REVIEW*\*)
+
+These are worker environment variables, every one defaulting to **OFF**.
+"Truthy" means one of `1`, `true`, `yes`, or
+`on` (case-insensitive); anything else — including unset, empty, or
+`false` — is OFF. When a flag is OFF its code path is inert: the review behaves
+exactly as if the feature did not exist.
+
+One flag is a **scope** rather than a capability:
+`LOOPOVER_REVIEW_REPOS` is a per-repo allowlist that must _also_ pass for
+any per-PR feature to run on a given repo. So a per-PR feature activates only when
+**its own flag is ON and the repo is allowlisted**.
+
+- `LOOPOVER_REVIEW_REPOS` — the per-repo allowlist. Comma-separated
+ `owner/repo` names that may run the per-PR features (safety, grounding, RAG,
+ reputation, unified comment). Empty or unset means no repos — every per-PR feature stays
+ dormant for everyone regardless of the global flags. Case-insensitive and trimmed; stray
+ commas are ignored. The cron and endpoint flags (ops, self-tune, parity audit, content
+ lane, draft) are **not** scoped by this list.
+- `LOOPOVER_REVIEW_SAFETY` — safety scan in the review path: it neutralizes
+ prompt-injection in untrusted PR title/body/diff before the AI reviewer sees it, and scans
+ the diff for leaked secrets, surfacing a `secret_leak` blocker. Per-PR (also
+ needs the repo in the allowlist).
+- `LOOPOVER_REVIEW_GROUNDING` — grounds the AI reviewer with the PR's
+ _finished_ CI status plus the _full post-change content_ of the changed
+ files, so the model verifies claims against reality instead of predicting CI or flagging
+ symbols defined just outside the diff hunk. Per-PR.
+- `LOOPOVER_REVIEW_E2E_TESTS` — master kill-switch for the opt-in,
+ maintainer-triggered AI-generated E2E test coverage feature. Off by default; a repo also
+ needs its own `features.e2eTests: true` override in `.loopover.yml`
+ before the feature is active for it. Per-PR.
+- `LOOPOVER_REVIEW_IMPROVEMENT_SIGNAL` — master kill-switch for the read-only,
+ advisory PR quality-delta signal (the positive-axis counterpart to the slop risk score).
+ Off by default; config-as-code activation only for now — no tier reads the resolved value
+ yet, so turning this on has no visible effect until a later release wires real behavior
+ behind it. Per-PR.
+- `LOOPOVER_REVIEW_CONTINUOUS` — fleet-wide default AI review re-trigger cadence.
+ Off by default (one-shot): AI-generated content (main review, slop advisory, linked-issue
+ satisfaction) is produced once per PR and never regenerated automatically afterward — only
+ an explicit maintainer retrigger (the PR-panel checkbox, or `@loopover review`
+ as a maintainer) spends a fresh call. Truthy switches the fleet default to continuous —
+ every push/CI-completion/sweep re-runs AI content generation. A repo's own
+ `review.auto_review.cadence` in `.loopover.yml` always overrides
+ this default, in either direction. Never affects the deterministic gate (CI status,
+ mergeability, static-rule blockers), which always re-evaluates regardless.
+- `LOOPOVER_REVIEW_RAG` — retrieval-augmented context: queries the codebase
+ vector index for related code and docs (callers, related modules, existing conventions)
+ and appends a "Relevant existing code / docs" section to the reviewer prompt. Additive
+ only. Inert until a vector index exists for the repo — a cold or missing index degrades to
+ no context. Per-PR.
+- `LOOPOVER_REVIEW_IMPACT_MAP` — deterministic impact map: from the codebase
+ vector index plus the PR's changed exported symbols, computes which other repo files
+ plausibly need re-checking, and renders that as a compact section in the unified review
+ comment (also feeds it to the AI reviewer as additive reference context). ANDed with the
+ per-repo `review.impact_map` opt-in — neither alone is sufficient. Per-PR.
+- `LOOPOVER_REVIEW_CULTURE_PROFILE` — appends a "repo quality-culture profile"
+ reference block to the reviewer prompt: typical merged-PR size and common accepted labels,
+ derived from this repo's own merge history. Additive reference only — never a gate or
+ scoring input. Also requires the per-repo `review.culture_profile: true` opt-in
+ in `.loopover.yml`. Per-PR.
+- `LOOPOVER_REVIEW_MEMORY` — repeat-false-positive suppression: matches an
+ advisory (non-blocking) AI finding against this repo's stored suppression signals (a
+ maintainer's own past false-positive dismissals) and demotes or drops it before the
+ unified comment renders. A maintainer records a signal with
+ `@loopover resolve [finding-code]` (or a whole-PR
+ `@loopover resolve` ack). Advisory-only by construction — never applied to gate
+ blockers, so it can never change the merge/close disposition. Also requires the per-repo
+ `review.memory: true` opt-in in `.loopover.yml`. Per-PR.
+- `LOOPOVER_REVIEW_REPUTATION` — submitter-reputation spend control. A new,
+ burst, or low-reputation submitter is downgraded to a deterministic-only review; good
+ reputation proceeds normally. Never surfaced publicly — no comment, label, or check shows
+ reputation. Per-PR.
+- `LOOPOVER_REVIEW_ENRICHMENT` — runs the review-enrichment analyzer registry
+ (duplication, churn hotspots, blame links, approval integrity, undocumented exports, and
+ more) and folds their findings into the review context. Per-PR.
+- `LOOPOVER_REVIEW_INLINE_COMMENTS` — posts AI-review findings as inline
+ diff-anchored PR review comments instead of (or alongside) the summary comment. Per-PR.
+- `LOOPOVER_REVIEW_FIX_HANDOFF` — renders a review finding as a structured,
+ machine-readable "apply this fix" block for the contributor's own local agent to consume —
+ content only, no server-side write, no execution. Per-PR.
+- `LOOPOVER_REVIEW_PLANNER` — enables `@loopover plan`, an on-demand
+ structured implementation plan posted to the PR thread. Per-PR.
+- `LOOPOVER_REVIEW_SCREENSHOTS` — visual capture: renders and attaches
+ before/after screenshots for PRs that change UI. Per-PR.
+- `LOOPOVER_REVIEW_OPS` — observability, read-only. On the cron tick an anomaly
+ scan over the gate-block ledger and calibration data emits a structured
+ `ops_anomaly` log when something drifts, and a bearer-gated
+ `GET /v1/internal/ops/stats` serves an outcome aggregate. Does not mutate
+ config. Global.
+- `LOOPOVER_REVIEW_SELFTUNE` — the self-improvement loop. On the cron tick it
+ computes tuning recommendations from your own outcome data, shadow-soaks any strictly
+ tightening recommendation, and auto-promotes it only after the soak passes. It can
+ **only ever tighten** the gate — a loosening recommendation is never applied.
+ Global, and safe to leave on.
+- `LOOPOVER_REVIEW_PARITY_AUDIT` — parity readiness, shadow record-only. Records
+ each finalized gate decision and serves a readiness report at
+ `GET /v1/internal/parity`. Changes no review behavior. Global.
+- `LOOPOVER_REVIEW_CONTENT_LANE` — routes content repos (curated lists,
+ registries) through the dedicated content lane — duplicate detection, source-evidence
+ reachability, security scanning, scope classification, registry grounding — instead of the
+ code gate. Global.
+- `LOOPOVER_REVIEW_DRAFT` — the public draft-submission flow (the
+ `/v1/drafts` endpoints: contributor draft → GitHub OAuth → fork PR). With the
+ flag off every draft endpoint 404s. Requires the
+ `DRAFT_TOKEN_ENCRYPTION_SECRET` and `GITHUB_OAUTH_CLIENT_SECRET`
+ secrets. Global.
+- `LOOPOVER_REVIEW_STATS_TOKEN` — the bearer secret for the stats data endpoint.
+ Not an on/off switch; it is the token value. When set, the stats route requires this
+ bearer token.
+
+
+ A safe rollout is two flips: turn the capability flag `true`, then add the repo to
+ `LOOPOVER_REVIEW_REPOS`. Because both must be true, you can leave a capability globally enabled
+ while it stays dormant everywhere except the repos you have explicitly allowlisted — and you roll
+ a single repo back by removing it from the list without disturbing the others.
+
+
+## Gate modes
+
+Per-repo behavior is the **effective settings**: the database row for the repo,
+overlaid with the repo's `.loopover.yml`. Most gate dimensions are tri-state:
+
+- `off` — the dimension is not evaluated.
+- `advisory` — the finding is surfaced in the comment or context but never
+ blocks.
+- `block` — the finding can become a hard `LoopOver Orb Review Agent`
+ blocker. A block outcome fails the gate for any author identically —
+ confirmed-Gittensor-contributor status doesn't change _who_ can be blocked,
+ only the mode chooses _which_ deterministic checks are active. Confirmed status is
+ carried through for on-chain scoring, a separate concern from the gate's own
+ merge/close decision.
+
+There is no single gate master switch — each dimension below is independently controlled by
+its own mode field (most default to `off` or `advisory`; see each
+dimension's default below). `gate.enabled` is a legacy, unrelated field: it is
+only a boolean shorthand for `gate.checkMode` (`required` /
+`visible` / `disabled`), which controls solely whether the
+`LoopOver Orb Review Agent` check-run publishes on GitHub. Neither field turns
+gate evaluation, comments, labels, audit, or autonomous merge/close on or off — set the
+dimension modes below directly, and set `gate.checkMode` explicitly instead of
+the ambiguous `gate.enabled`. The main dimensions:
+
+- `gate.pack` — the policy pack: `gittensor` (default; registry-aware,
+ tracks confirmed-Gittensor-contributor status for scoring) or `oss-anti-slop`
+ (runs the deterministic rules against any author on any repo, with no
+ confirmed-contributor tracking at all).
+- `gate.duplicates` — duplicate / superseding-PR detection. Default
+ `block`.
+- `gate.linkedIssue` — what happens when a PR has _no linked issue at all_.
+ Default `advisory` (surfaced in the review panel, never blocks — issues
+ aren't always available). Set `block`, or turn on the dashboard "Require
+ linked issue" toggle, to make a missing issue an explicit opt-in blocker (if the toggle is
+ on but this is still `off`, it is auto-promoted to `block`). This is
+ unrelated to closing a PR that links an _ineligible_ issue (owner-assigned, wrong
+ label, etc.) — that is a separate, deterministic rule, not this gate.
+- `gate.readiness.mode` — the PR-quality / merge-readiness score gate. Default
+ `advisory`. Pair it with `gate.readiness.minScore` (0–100; at or
+ above this score the quality dimension passes; `null` uses the engine's default
+ band).
+- `gate.slop.mode` — the deterministic anti-slop signal. Default `off`
+ (opt-in). `advisory` surfaces the slop score and warnings; `block`
+ also hard-blocks at or above `gate.slop.minScore` (0–100; `null`
+ uses `60`, the "high" band). Set `gate.slop.aiAdvisory: true` to add
+ a free advisory-only `ai_slop_advisory` finding — it never feeds the slop score
+ or the gate.
+- `gate.copycat.mode` — code containment/similarity gate against prior art
+ (earlier open or recently merged PRs on the same repo). Default `off`.
+ Escalating tiers: `warn` surfaces an advisory finding only; `label`
+ also applies a label; `block` also closes the PR and counts toward the
+ repeat-offender strikes ledger. Pair it with `gate.copycat.minScore` (0–100;
+ `null` uses the engine default, `85`). Direction is always by
+ submission timestamp, so the earlier (original) author is never flagged.
+- `gate.mergeReadiness` — composite merge-readiness gate. Default
+ `off`, no min score.
+- `gate.manifestPolicy` — when `block`, the manifest's declared policy
+ (required linked issue and test expectations) becomes an enforceable blocker.
+ Manual-review path holds use `settings.hardGuardrailGlobs` instead. Default
+ `off`.
+- `gate.size` — PR-size hold: flags an oversized diff. Default `off`.
+- `gate.lockfileIntegrity` — flags lockfile-tamper risk (a lockfile changed
+ without its matching manifest, or vice versa). Default `off`.
+- `gate.claMode` — CLA / license-acknowledgment gate. Default `off`.
+- `gate.selfAuthoredLinkedIssue` — whether a PR may link an issue opened by the
+ same author. Default `advisory`.
+- `gate.linkedIssueSatisfaction` — an AI assessment of whether the PR's diff
+ actually satisfies its primary linked issue's intent, distinct from
+ `gate.linkedIssue` (which only checks a link exists). Default `off`.
+ `advisory` renders the assessment in the review comment without blocking;
+ `block` additionally lets a confidence-floor-passing "unaddressed" verdict
+ become a blocker.
+- `settings.moderationGateMode` — whether the moderation-rules engine
+ (contributor cap, blacklist, review-nag feeding a shared cross-repo violation tally) runs
+ on this repo at all. `inherit` (default) defers to the instance-wide
+ `global_moderation_config.enabled`; `off`/`enabled` force
+ this repo regardless of the global default.
+- `gate.aiReview.mode` — AI review. Default `off`.
+ `advisory` posts AI review notes only; `block` lets a dual-model
+ high-confidence consensus defect become a blocker.
+
+### Bring your own model (AI review)
+
+The AI-review write-up can optionally use your own frontier model. By default the blocking
+decision runs on a pair of free built-in models and requires agreement; an operator can
+override this per repo with `aiReviewCombine` (`single` /
+`consensus` / `synthesis`) — in `single` mode, one
+reviewer's verdict is the decision. BYOK changes which model writes the advisory text, not
+this combine behavior.
+
+- `gate.aiReview.byok` — when `true` and a provider key is configured,
+ the advisory write-up uses the maintainer's frontier model. Default `false`.
+- `gate.aiReview.provider` — `anthropic`, `openai`, or
+ `null` (use the stored key's own provider). Must match the stored key's
+ provider or BYOK is skipped and falls back to the built-in pair.
+- `gate.aiReview.model` — model override for the BYOK write-up (for example
+ `claude-3-5-sonnet-latest`); `null` uses the key record's model,
+ else a conservative per-provider default.
+
+
+ The provider key itself never lives in `.loopover.yml`. It is held only in the encrypted key store
+ and unlocked by the `TOKEN_ENCRYPTION_SECRET` worker secret — absent that secret, BYOK is
+ unavailable and AI review silently falls back to the free built-in model pair.
+
+
+## Guardrails and scope
+
+Top-level keys in `.loopover.yml` declare the repo's focus and validation
+expectations. These feed deterministic findings such as `manifest_missing_tests`
+and — when `gate.manifestPolicy: block` — can become enforceable blockers. Manual
+path holds are configured only through `settings.hardGuardrailGlobs`.
+
+- `wantedPaths` — globs for work areas you want; PRs touching these are
+ preferred. Default `[]`.
+- `preferredLabels` — labels you prefer on incoming PRs; a missing one is
+ surfaced. Default `[]`.
+- `linkedIssuePolicy` — `required` / `preferred` /
+ `optional`. How strongly a linked issue is expected. Default
+ `optional`.
+- `testExpectations` — test paths expected to change with code; a
+ `manifest_missing_tests` finding fires when absent. Default `[]`.
+- `issueDiscoveryPolicy` — `encouraged` / `neutral` /
+ `discouraged`. Default `neutral`.
+- `maintainerNotes` — private review context, never published to any public
+ GitHub surface. Default `[]`.
+- `publicNotes` — notes explicitly opted into public output (public-safe
+ filtered; unsafe lines are dropped). Default `[]`.
+
+## Other repo settings
+
+Anything you can toggle in the dashboard can also be set as code under
+`settings:` in `.loopover.yml`. Common ones, all defaulting to the
+safe values shown:
+
+- `commentMode` — comment audience: `off` /
+ `detected_contributors_only` (default) / `all_prs`.
+- `publicAudienceMode` — `oss_maintainer` (default) /
+ `gittensor_only`.
+- `publicSignalLevel` — `minimal` / `standard` (default).
+- `checkRunMode` — publishes the **LoopOver Context** check (not the
+ **Orb Review Agent** gate check, which is `reviewCheckMode`):
+ `off` (default) / `enabled`. Pair with
+ `checkRunDetailLevel` (`minimal` (default) / `standard`).
+- `publicSurface` — `off` / `comment_and_label` (default) /
+ `comment_only` / `label_only`.
+- `autoLabelEnabled` (default `true`), `gittensorLabel`
+ (default `gittensor`), and `createMissingLabel` (default
+ `true`) — the base per-PR context label, shown to the public surface.
+- `typeLabelsEnabled` (default `true`) and `typeLabels` — a
+ separate, independent taxonomy label family: internal triage metadata gated by its own
+ toggle, not by `autoLabelEnabled` above. `typeLabels` is an open
+ `category → label name` map, not fixed to any specific set — the built-in
+ `bug`/`feature`/`priority` categories default to
+ `gittensor:bug`/`gittensor:feature`/`gittensor:priority`
+ (examples, not required names), and you can add any number of your own categories (e.g.
+ `security: area:security`) for your own taxonomy. An explicit
+ `typeLabels: {}` means zero configured categories for the repo.
+- `includeMaintainerAuthors` (default `false`),
+ `requireLinkedIssue` (default `false`), `backfillEnabled`
+ (default `true`), and `badgeEnabled` (README status badge, default
+ `false`), and `publicQualityMetrics` (public review-quality page,
+ default `false`).
+- `agentPaused` (per-repo kill-switch, default `false`) and
+ `agentDryRun` (shadow mode, default `false`).
+- `autonomy` (per-action-class level; default is observe, deny-by-default),
+ `autoMaintain` (`{ mergeMethod, requireApprovals }`; default
+ `squash` / `1`), and `commandAuthorization` (role policy;
+ built-in default).
+
+## Example .loopover.yml
+
+A worked manifest: focus and validation up top, a refined gate, BYOK AI review, and a few
+dashboard-equivalent overrides.
+
+
+
+
+ Start conservative: enable the gate in `advisory` before `block`, watch the surfaced findings, and
+ only then tighten. Combined with the tightening-only self-tune loop, this keeps the gate from ever
+ blocking a contributor on a setting you have not validated.
+
+
+For the privacy guarantees behind these surfaces, see
+[Privacy & security](/docs/privacy-security). For the maintainer install and
+trust flow, see [Install & trust](/docs/maintainer-install-trust). If you're
+self-hosting, see [Self-host configuration](/docs/self-hosting-configuration)
+for the environment layer these settings sit on top of, plus the config-precedence rules and
+a link to the fully-commented `.loopover.yml.example`.
diff --git a/apps/loopover-ui/content/docs/upstream-drift.mdx b/apps/loopover-ui/content/docs/upstream-drift.mdx
new file mode 100644
index 0000000000..fa78cd78b4
--- /dev/null
+++ b/apps/loopover-ui/content/docs/upstream-drift.mdx
@@ -0,0 +1,38 @@
+---
+title: Upstream drift
+description: Gittensor moves. LoopOver tracks every meaningful change to scoring, registry, and issue-discovery so your decisions stay grounded.
+---
+
+## How drift works
+
+LoopOver stores versioned snapshots of the Gittensor source and ruleset from
+[entrius/gittensor:test](https://github.com/entrius/gittensor). Semantic payloads are hashed so we can detect scoring, registry, or issue-discovery drift
+without re-deriving the whole world.
+
+
+ **Upstream relationship.** `entrius/gittensor` is the upstream project LoopOver analyzes. LoopOver
+ is [jsonbored/loopover](https://github.com/jsonbored/loopover) — an independent base-agent layer
+ for the Gittensor ecosystem, not affiliated with the official subnet.
+
+
+## Signal fidelity vs readiness
+
+The API distinguishes service health from data quality. Readiness can be green while signal
+fidelity is `stale`, `degraded`, or `blocked`. The MCP
+surfaces fidelity in every response so agents don't act on stale assumptions.
+
+## Endpoints
+
+
+
+
+ When drift is detected, the MCP CLI prints a heads-up before any analyze/preflight/plan output.
+ Treat the response as a snapshot tied to the printed ruleset version.
+
diff --git a/apps/loopover-ui/eslint.config.js b/apps/loopover-ui/eslint.config.js
index b32dc4b6a3..742ca3f303 100644
--- a/apps/loopover-ui/eslint.config.js
+++ b/apps/loopover-ui/eslint.config.js
@@ -6,7 +6,8 @@ import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
- { ignores: ["dist", ".output", ".vinxi"] },
+ // .source is fumadocs-mdx's generated collection output (source.config.ts -> content/docs), same category as dist/.output.
+ { ignores: ["dist", ".output", ".vinxi", ".source"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
diff --git a/apps/loopover-ui/package.json b/apps/loopover-ui/package.json
index b543d28872..40862a0008 100644
--- a/apps/loopover-ui/package.json
+++ b/apps/loopover-ui/package.json
@@ -48,6 +48,7 @@
"@radix-ui/react-toggle": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.11",
+ "@scalar/api-reference-react": "^0.9.55",
"@sentry/react": "^10.63.0",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-query": "^5.101.2",
@@ -59,6 +60,8 @@
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"embla-carousel-react": "^8.6.0",
+ "fumadocs-core": "^16.11.4",
+ "fumadocs-mdx": "^15.1.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.577.0",
"motion": "^12.42.2",
diff --git a/apps/loopover-ui/source.config.ts b/apps/loopover-ui/source.config.ts
new file mode 100644
index 0000000000..128a5c1754
--- /dev/null
+++ b/apps/loopover-ui/source.config.ts
@@ -0,0 +1,5 @@
+import { defineDocs } from "fumadocs-mdx/config";
+
+export const docs = defineDocs({
+ dir: "content/docs",
+});
diff --git a/apps/loopover-ui/src/components/site/command-table.tsx b/apps/loopover-ui/src/components/site/command-table.tsx
new file mode 100644
index 0000000000..2f26013bdb
--- /dev/null
+++ b/apps/loopover-ui/src/components/site/command-table.tsx
@@ -0,0 +1,66 @@
+const DEFAULT_ROLE_SUMMARY: Record = {
+ help: "maintainer, collaborator, confirmed_miner (default policy)",
+ ask: "maintainer, collaborator, confirmed_miner",
+ preflight: "maintainer, collaborator, confirmed_miner",
+ blockers: "maintainer, collaborator, confirmed_miner",
+ "duplicate-check": "maintainer, collaborator, confirmed_miner",
+ "miner-context": "maintainer, collaborator, confirmed_miner",
+ "next-action": "maintainer, collaborator, confirmed_miner",
+ reviewability: "maintainer, collaborator, confirmed_miner",
+ "repo-fit": "maintainer, collaborator, confirmed_miner",
+ packet: "maintainer, collaborator, confirmed_miner",
+ "queue-summary": "maintainer, collaborator",
+ "confirmed-miners": "maintainer, collaborator",
+ "review-now": "maintainer, collaborator",
+ "needs-author": "maintainer, collaborator",
+ "duplicate-clusters": "maintainer, collaborator",
+ "burden-forecast": "maintainer, collaborator",
+ "intake-health": "maintainer, collaborator",
+ "outcome-patterns": "maintainer, collaborator",
+ "noise-report": "maintainer, collaborator",
+ "gate-override": "maintainer, collaborator",
+ review: "maintainer, collaborator, confirmed_miner",
+ pause: "maintainer, collaborator",
+ resume: "maintainer, collaborator",
+ resolve: "maintainer, collaborator",
+ configuration: "maintainer, collaborator",
+ explain: "maintainer, collaborator",
+};
+
+export function CommandTable({
+ title,
+ entries,
+}: {
+ title: string;
+ entries: ReadonlyArray<{ id: string; title: string; description: string }>;
+}) {
+ return (
+ <>
+
{title}
+
+
+
+
+
Syntax
+
Effect
+
Default roles
+
+
+
+ {entries.map((entry) => (
+
+
+ @loopover {entry.id}
+
+
{entry.description}
+
+ {DEFAULT_ROLE_SUMMARY[entry.id] ?? "see policy"}
+
- LoopOver is deterministic. When AI summaries are enabled, they sit
- on top of the structured response — they never replace it, never add facts that
- aren't in the response, and never change ranked actions, blockers, or scoreability
- numbers.
-
-
-
Where they appear
-
-
- In the /app/playground tool runs, behind an opt-in "Include AI summary"
- toggle, above the JSON.
-
-
- As an optional AI-clarified rewrite of the public PR intelligence comment, gated
- server-side by AI_PUBLIC_COMMENTS_ENABLED and always falling back to the
- deterministic comment body on any error, quota limit, or unsafe output.
-
-
Never in maintainer packets without explicit maintainer opt-in.
-
-
- The playground's toggle currently renders a local, deterministic preview of the structured
- response — it does not call the backend AI summary service described below yet. Treat it as
- a stand-in for what a wired-up summary would look like.
-
-
-
What is sent to the model
-
- A compacted signal bundle — the run's objective, actor login, surface, status, and data
- quality, plus up to five ranked actions (kind, recommendation, why, blockers) and up to
- eight freshness warnings. For a public rewrite, scoreability/risk fields are stripped before
- the bundle is built, not filtered out of the model's response after the fact.
-
-
- No source code, no PAT, no GitHub identity beyond the acting login, and no per-user history
- beyond the current run are sent.
-
-
-
Model choice
-
- There is no per-user or per-session model picker. The operator configures one AI provider
- for the whole instance — see{" "}
- self-hosting AI providers for the
- Codex/Claude Code/Ollama/OpenAI-compatible/Anthropic options. Summaries are off by default (
- AI_SUMMARIES_ENABLED); public-comment rewriting is a separate,
- also-off-by-default switch (AI_PUBLIC_COMMENTS_ENABLED).
-
-
-
- Never the source of truth. If the summary disagrees with the structured
- response, trust the structured response. The summary is a convenience layer, never an
- authority.
-
+
+ Loading…
}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.ams-observability-callout.test.tsx b/apps/loopover-ui/src/routes/docs.ams-observability-callout.test.tsx
index 08b7263f25..58090b507e 100644
--- a/apps/loopover-ui/src/routes/docs.ams-observability-callout.test.tsx
+++ b/apps/loopover-ui/src/routes/docs.ams-observability-callout.test.tsx
@@ -1,62 +1,21 @@
+import { readFileSync } from "node:fs";
import { render, screen } from "@testing-library/react";
-import { type ReactNode } from "react";
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import {
AMS_OBSERVABILITY_DOC_URL,
AmsObservabilityCallout,
} from "../components/site/ams-observability-callout";
-import { MinerQuickstart } from "./docs.miner-quickstart";
-import { MinerWorkflow } from "./docs.miner-workflow";
-import { SelfHostingOperations } from "./docs.self-hosting-operations";
-
-vi.mock("@tanstack/react-router", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- Link: ({ to, children }: { to: string; children: ReactNode }) => {children},
- };
-});
-
-vi.mock("@/components/site/docs-page", () => ({
- DocsPage: ({ children, title }: { children: ReactNode; title: string }) => (
-
- ),
-}));
-
-vi.mock("@/components/site/workflow-mirror", () => ({
- WorkflowMirror: () => ,
-}));
// Every route that embeds the shared callout, so a new route add/remove can't silently skip one (#5191).
-const ROUTES_WITH_CALLOUT: ReadonlyArray<[string, () => ReactNode]> = [
- ["/docs/self-hosting-operations", SelfHostingOperations],
- ["/docs/miner-quickstart", MinerQuickstart],
- ["/docs/miner-workflow", MinerWorkflow],
-];
+// These routes render from content/docs/*.mdx via the fumadocs client-loader (see docs-source.ts's
+// comment), so this is now a content drift-guard -- checking the .mdx source for the JSX tag -- rather
+// than a component render, matching the pattern in docs-selfhost-activation-paths.test.ts.
+const ROUTES_WITH_CALLOUT = [
+ ["/docs/self-hosting-operations", "content/docs/self-hosting-operations.mdx"],
+ ["/docs/miner-quickstart", "content/docs/miner-quickstart.mdx"],
+ ["/docs/miner-workflow", "content/docs/miner-workflow.mdx"],
+] as const;
describe("AMS observability cross-reference callout", () => {
it("renders a link to the Observing your miner guide", () => {
@@ -72,10 +31,8 @@ describe("AMS observability cross-reference callout", () => {
expect(url.hostname).toBe("github.com");
});
- it.each(ROUTES_WITH_CALLOUT)("wires the callout into %s", (_path, RouteComponent) => {
- const { container } = render();
- const link = container.querySelector(`a[href="${AMS_OBSERVABILITY_DOC_URL}"]`);
- expect(link).not.toBeNull();
- expect(link?.textContent).toBe("Observing your miner");
+ it.each(ROUTES_WITH_CALLOUT)("wires the callout into %s", (_path, docPath) => {
+ const source = readFileSync(docPath, "utf8");
+ expect(source).toContain(" {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["beta-onboarding"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Beta onboarding — LoopOver docs" },
@@ -26,238 +37,13 @@ export const Route = createFileRoute("/docs/beta-onboarding")({
});
function BetaOnboarding() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- Product positioning. LoopOver is a deterministic base-agent and
- control-plane layer for the Gittensor ecosystem. It is{" "}
-
- jsonbored/loopover
-
- , independent of{" "}
-
- entrius/gittensor
-
- . Use it to plan work, preflight branches, and keep GitHub review surfaces quiet — not as an
- official Gittensor frontend, wallet UI, or payout dashboard.
-
-
-
Miner journey
-
- Miners and contributors use the local MCP package. Source contents stay on your machine but
- branch metadata (such as branch names, SHAs, changed file paths, commit messages, validation
- details, labels, body text, linked issues, and scenario notes) is sent to authenticated
- LoopOver MCP/API responses for analysis and packet preparation.
-
-
-
- Install the MCP. Global install or npx — see{" "}
- Quickstart.
-
-
-
- Sign in. GitHub Device Flow — no PAT storage.
-
-
-
- Run diagnostics. Confirms API reachability, auth, source-upload posture,
- and optional local score-preview wiring.
-
-
-
- Plan next work. Ranked actions, lane context, and blockers —
- copilot-only; does not open PRs or post comments.
-
-
-
- Preflight the branch. Branch blockers, queue pressure, and maintainer-fit
- notes before you push.
-
-
-
- Prepare a public-safe packet. Maintainer-readable PR description with no
- private scoring language.
-
-
-
-
- Wire the same tools into Codex, Claude Desktop, or Cursor via{" "}
- MCP client setup. Signed-in miners can also use the{" "}
- Workbench and Miner dashboard{" "}
- in the control panel.
-
-
-
Maintainer journey
-
- Maintainers self-host the review stack and install a GitHub App, configure per-repo policy,
- preview what could appear on a confirmed-miner PR, then pull context on demand.
-
-
-
- Self-host, then install your own App. Choose repositories and approve
- permissions — default posture is silence. Start with{" "}
- self-hosting setup, which covers the
- direct App's install checklist, then{" "}
- GitHub App configuration for the review behavior (PR
- panel, checks, gate modes).
-
-
- Configure settings. Opt in to at most one configured label and one sticky
- sanitized comment per confirmed-miner PR. Tune repo policy in installation settings or via
- the API.
-
-
- Preview the public surface. Dry-run what would be written to GitHub
- without mutating state. Keep LoopOver Context advisory; require{" "}
- LoopOver Orb Review Agent only after blocking rules are explicitly
- configured.
-
-
- The signed-in Maintainer console and{" "}
- Repos tab surface the same preview diff when live data is
- available.
-
-
-
- Use maintainer commands. On-demand context in the PR thread — output
- stays maintainer-scoped when appropriate.
-
-
- Repo owners care about registration readiness and sensible .loopover.yml{" "}
- configuration before promoting labels or maintainer-cut policy.
-
-
-
- Run a readiness report. Blockers, warnings, recommended registration
- mode, and issue policy — private API only.
-
-
-
- Review config guidance. Recommended config diff with reasons and
- tradeoffs — apply via PR when ready.
-
-
-
- Use the control panel. Open Repository owner{" "}
- (or the Owner tab under Repos) to inspect the same signals
- with a live repo selector after you sign in with GitHub.
-
-
-
- Readiness is separate from upstream drift: a repo can look ready while Gittensor rules are
- stale. Check Upstream drift when you change scoring
- assumptions.
-
-
-
Operator journey
-
- Operators watch deployment health, product usage, value rollups, and upstream drift across
- installations. These surfaces are private and authenticated — never mirrored to public
- GitHub comments.
-
-
-
- Open usage & value. Weekly rollups, activation status, and
- noise-reduction metrics in the control panel.
-
- Read the weekly value report. Summary lines plus rollup freshness and
- warnings when backfills lag or fidelity degrades.
-
-
- Check drift status. Compare ruleset snapshots and signal fidelity before
- trusting miner or maintainer guidance.
-
-
-
-
- When drift is not current, treat MCP and API responses as tied to the printed
- ruleset version. See Upstream drift for semantics.
-
-
-
Public vs private boundaries
-
- Public GitHub output must never include wallets, hotkeys, payout or reward estimates, raw
- trust scores, public score estimates, private reviewability details, or farming language.
- Private MCP, API, and control-panel surfaces may show authenticated scoreability, blockers,
- projections, and evidence — framed as guidance, not guaranteed outcomes.
-
-
- If you are unsure whether copy belongs on a PR thread, start with a maintainer packet or MCP
- preflight. Public comments stay advisory and sanitized; richer context stays in private
- channels.
-
-
-
- The request body is a strict schema — every field must match a known key exactly (
- repoFullName not a nested repo object, changedFiles with{" "}
- additions/deletions, linkedIssues not{" "}
- linked_issues) and unknown keys are rejected rather than ignored.
-
-
-
- File metadata is allowed (path, line counts). File contents are not
- requested, accepted, or stored. The MCP enforces this on the client.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.fumadocs-spike-api-reference.tsx b/apps/loopover-ui/src/routes/docs.fumadocs-spike-api-reference.tsx
new file mode 100644
index 0000000000..01a8300f7a
--- /dev/null
+++ b/apps/loopover-ui/src/routes/docs.fumadocs-spike-api-reference.tsx
@@ -0,0 +1,26 @@
+import { ApiReferenceReact } from "@scalar/api-reference-react";
+import "@scalar/api-reference-react/style.css";
+import { ClientOnly, createFileRoute } from "@tanstack/react-router";
+
+// SPIKE (#6037): standalone Scalar API reference page against the existing OpenAPI
+// output (npm run ui:openapi), per the issue's "add @scalar/api-reference as a
+// standalone page ... instead of fumadocs-openapi" requirement. Scalar's underlying
+// Vue widget mounts into the DOM directly and isn't SSR-safe, so it's wrapped in
+// ClientOnly (client-hydration-only, same category of constraint the MDX-body
+// rendering hit -- but here it's an isolated widget, not something that needs to
+// participate in SSR content, so ClientOnly is a clean, non-hacky fit).
+export const Route = createFileRoute("/docs/fumadocs-spike-api-reference")({
+ component: SpikeApiReference,
+});
+
+function SpikeApiReference() {
+ return (
+ Loading API reference…
+ }
+ >
+
+
+ );
+}
diff --git a/apps/loopover-ui/src/routes/docs.github-app.tsx b/apps/loopover-ui/src/routes/docs.github-app.tsx
index e848ce169d..e800fc811c 100644
--- a/apps/loopover-ui/src/routes/docs.github-app.tsx
+++ b/apps/loopover-ui/src/routes/docs.github-app.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/github-app.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/github-app")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["github-app"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "GitHub App configuration — LoopOver docs" },
@@ -26,349 +37,13 @@ export const Route = createFileRoute("/docs/github-app")({
});
function GithubApp() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- Once installed, a LoopOver GitHub App reviews every pull request on the
- repos you select. Self-hosting your own App (see{" "}
- self-hosting setup) is the only currently
- available path. Each review produces two surfaces: the{" "}
- LoopOver Orb Review Agent check run (and the advisory{" "}
- LoopOver Context check), and a single review comment posted by{" "}
- loopover[bot] that updates in place as the PR evolves. The review behavior
- below this page's Install section (PR panel, checks, gate modes, config-as-code) is the same
- regardless of which connection mode your self-hosted App uses.
-
-
-
Install
-
- Self-hosting is the only currently available path. Run the review stack
- yourself, then install your own GitHub App on exactly the repos you choose using the
- self-host setup wizard. The direct App's required permissions and events are covered in{" "}
- GitHub App and Orb — use that page's
- checklist for a self-hosted install.
-
-
- LoopOver previously ran a private managed-beta shared App that repo owners could install
- directly, without self-hosting. That shared install path is currently paused while
- self-hosted Orb is the primary way to run LoopOver — a new centrally hosted offering is
- planned for the future. If you previously used the shared managed-beta App, see{" "}
- self-hosting setup to move your repos over.
-
-
-
First 10 minutes
-
-
Install the app on one test repository first.
-
- Confirm the installation appears in the private API, then open its health record.
-
-
-
- Check repo readiness before enabling public output.
-
-
-
- Preview the exact public surface without posting to GitHub.
-
-
-
- Leave LoopOver Context advisory while you tune copy and settings. Make{" "}
- LoopOver Orb Review Agent required only after the repo explicitly enables
- blocking rules.
-
-
-
-
Default posture
-
- LoopOver is advisory-first. Public comments, labels, the Context check, and the review-agent
- check are controlled per repo. Missing issue links, non-Gittensor contributors, busy queues,
- and weak overlap signals do not block merge by default.
-
-
-
PR panel
-
- The PR panel is the review comment the LoopOver app posts on each pull request. It is one
- sticky comment authored by loopover[bot] that updates in place — the app edits
- the same comment instead of adding new ones. It shows a public-safe readiness score,
- concrete signal evidence, and short actions for linked issues, related work, review load,
- validation evidence, open PR queue, contributor context, and Gate result.
-
-
- By default the comment is posted only to detected contributors (commentMode is{" "}
- detected_contributors_only). Set commentMode to{" "}
- all_prs to comment on every PR, or off to suppress the comment
- entirely. Operators who have rolled the deployment onto the unified review comment (see
- below) get the single in-place comment shape; otherwise the legacy multi-panel comment is
- used unchanged.
-
-
-
Checks
-
- The LoopOver app publishes its review as check runs.{" "}
- LoopOver Orb Review Agent is the gate result, controlled by{" "}
- reviewCheckMode (required / visible /{" "}
- disabled). LoopOver Context is the separate advisory
- companion, controlled by its own checkRunMode (off /{" "}
- enabled) — these two switches are independent axes, not one setting for both
- checks. checkRunDetailLevel (minimal / standard) only
- affects LoopOver Context's output depth; it has no effect on the Orb Review
- Agent check.
-
-
- LoopOver Context is advisory and should not be required in branch
- protection. LoopOver Orb Review Agent is opt-in and can be made required
- after a repo owner chooses blocking rules.
-
-
- Branch protection should require LoopOver Orb Review Agent only after the
- repo has verified installation health, previewed the public panel, and configured at least
- one block rule. Do not require LoopOver Context; it is there
- to inform reviewers, not stop merges.
-
-
-
Gate modes
-
- The deterministic gate is the heart of the LoopOver review. Its master switch is{" "}
- reviewCheckMode (required / visible /{" "}
- disabled); each dimension then refines an already-enabled gate with a tri-state
- mode — off (not evaluated), advisory (surfaced, never blocks), or{" "}
- block (can become a hard LoopOver Orb Review Agent blocker). A
- configured blocker fails the gate for any author identically —
- confirmed-Gittensor-contributor status does not change who can be blocked; it's
- carried through only for on-chain scoring, a separate concern from the gate's own
- merge/close decision.
-
- qualityGateMode + qualityGateMinScore — the PR-quality score
- gate. Default advisory; only blocks when set to block with a
- configured min score.
-
-
- slopGateMode + slopGateMinScore — the deterministic anti-slop
- signal. Default off; advisory surfaces the slop score and
- warnings, block also hard-blocks at or above the min score (engine default
- band 60).
-
- manifestPolicyGateMode — makes the repo manifest's declared policy (blocked
- paths, required linked issue, test expectations) enforceable. Default off.
-
-
- aiReviewMode — AI review. Default off; advisory{" "}
- posts AI review notes only, block lets a dual-model high-confidence consensus
- defect become a blocker.
-
-
- sizeGateMode — PR-size hold. Default off; a PR at or above the
- configured file/line thresholds is held for manual review rather than auto-merged, never a
- hard failure.
-
-
- lockfileIntegrityGateMode — lockfile-tamper-risk check (a resolved/integrity
- change with no matching version bump, or a dependency pointed off the npm registry).
- Default off.
-
- selfAuthoredLinkedIssueGateMode — flags or blocks a PR whose author also
- opened the linked issue. Default advisory.
-
-
- linkedIssueSatisfactionGateMode — an AI assessment of whether the PR's diff
- actually satisfies its primary linked issue's intent, distinct from{" "}
- linkedIssueGateMode (which only checks a link exists). Default{" "}
- off; advisory renders in the review comment without blocking,{" "}
- block additionally lets a confidence-floor-passing "unaddressed" verdict
- become a blocker.
-
-
- moderationGateMode — whether the moderation-rules engine (contributor cap,
- blacklist, review-nag feeding a shared cross-repo violation tally) runs on this repo.
- Default inherit (defers to the instance-wide default); off/
- enabled force it per repo.
-
-
-
- The policy pack (gatePack) selects which rule set runs: gittensor{" "}
- (registry-aware, tracks confirmed-Gittensor-contributor status for scoring) or{" "}
- oss-anti-slop (the deterministic rules against any author on any repo, with no
- confirmed-contributor tracking at all).
-
-
-
- Configure as code (.loopover.yml)
-
-
- Every setting can be committed to .loopover.yml at the repo root instead of, or
- layered over, the dashboard. Precedence is .loopover.yml > repository
- settings > safe defaults; an unset field falls back to the next layer. It only chooses{" "}
- what LoopOver does — a configured blocker gates every author identically,
- regardless of config.
-
-
-
- Maintainer-supplied footer and note text is dropped if it contains forbidden public language
- (reward, score, wallet, hotkey, payout, etc.); the Gittensor attribution and register link
- always remain on the footer.
-
-
- The per-repo settings above choose what LoopOver does on each PR. The next section
- covers the deployment-wide capability switches that turn whole review features on or off.
-
-
-
- Review capability flags (LOOPOVER_REVIEW_*)
-
-
- Beyond per-repo settings, operators turn whole review capabilities on or off with
- the LOOPOVER_REVIEW_* worker environment variables. Every flag defaults to{" "}
- OFF: when a flag is off its code path is inert and the review behaves
- exactly as if the feature did not exist. "Truthy" is one of 1,{" "}
- true, yes, or on. You roll capabilities forward — and
- back — one flag, and one repo, at a time.
-
-
- Per-PR features require two conditions: the capability flag is on{" "}
- and the repo is listed in LOOPOVER_REVIEW_REPOS. With an empty repo
- allowlist every per-PR feature stays dormant for everyone, no matter the global flags.
-
-
-
- LOOPOVER_REVIEW_REPOS — per-repo cutover allowlist. Comma-separated{" "}
- owner/repo names that may run the per-PR features. Add repos one at a time to
- roll forward; remove to roll back.
-
-
- LOOPOVER_REVIEW_SAFETY — safety scan: defangs untrusted PR title/body/diff
- (prompt-injection neutralization) before the reviewer sees it, and surfaces a{" "}
- secret_leak blocker for leaked secrets in the diff. Per-PR.
-
-
- LOOPOVER_REVIEW_GROUNDING — grounds the AI reviewer with the PR's finished CI
- status and the full post-change content of the changed files, so the model verifies its
- claims against reality. Per-PR.
-
-
- LOOPOVER_REVIEW_RAG — retrieval-augmented context: appends semantically
- related code/docs from the codebase vector index to the reviewer prompt. Per-PR; inert
- until a VECTORIZE index exists for the repo.
-
-
- LOOPOVER_REVIEW_REPUTATION — submitter-reputation spend control: downgrades a
- new / burst / low-reputation submitter to a deterministic-only review. Internal-only,
- never surfaced publicly. Per-PR.
-
-
- LOOPOVER_REVIEW_OPS — read-only observability: a cron anomaly scan over your
- own review-outcome data plus a bearer-gated stats aggregate. Global (not scoped by the
- repo allowlist).
-
-
- LOOPOVER_REVIEW_SELFTUNE — self-improvement loop that computes tuning
- recommendations from review outcomes, shadow-soaks any strictly-tightening recommendation,
- and can only ever tighten the gate. Global.
-
-
- LOOPOVER_REVIEW_CONTENT_LANE — routes content repos (curated lists,
- registries) through the dedicated content lane instead of the code gate. Global.
-
-
- LOOPOVER_REVIEW_DRAFT — public draft-submission flow (contributor draft →
- GitHub OAuth → fork PR). Global; also needs the draft secrets set.
-
-
-
- A safe rollout for a per-PR feature is two flips: set the capability flag truthy, then add
- the repo to LOOPOVER_REVIEW_REPOS. Because both must hold, a capability can
- stay globally enabled while remaining dormant everywhere except the repos you have
- explicitly added.
-
-
-
-
Dogfood mode
-
- For repos like JSONbored/loopover and awesome-claude, enable PR
- comments, labels, Context, and Gate together to test the full product surface. If another
- maintainer agent can merge quickly, configure that agent to wait for{" "}
- LoopOver Orb Review Agent before merge or close.
-
-
-
Install diagnostics
-
- After installing, verify your install health from the API. The readiness endpoint separates
- service health from data quality.
-
-
- If the install route changes, check the deployed GITHUB_APP_SLUG before
- publishing setup copy. Self-hosted deployments use whatever slug you chose during setup.
-
-
-
- New maintainers should start with{" "}
- self-hosting setup, then continue with{" "}
- Maintainer workflow or the{" "}
- beta onboarding checklist after the health endpoint
- reports clean permissions and events.
-
-
-
- LoopOver's GitHub App never requests source push, never stores repository contents, and
- never publishes wallet, hotkey, payout, trust, reward, or private scoring language.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.how-reviews-work.tsx b/apps/loopover-ui/src/routes/docs.how-reviews-work.tsx
index aab9b0ac8e..ba76ec66ff 100644
--- a/apps/loopover-ui/src/routes/docs.how-reviews-work.tsx
+++ b/apps/loopover-ui/src/routes/docs.how-reviews-work.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/how-reviews-work.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/how-reviews-work")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["how-reviews-work"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "How reviews work — LoopOver docs" },
@@ -26,296 +37,13 @@ export const Route = createFileRoute("/docs/how-reviews-work")({
});
function HowReviewsWork() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
The shape of a review
-
- When a pull request opens or updates, LoopOver CI runs a review in two
- layers and reports the result in one place:
-
-
-
- The gate — a deterministic pass that never asks an AI. It runs a fixed
- set of rules (duplicates, linked issues, merge-readiness, anti-slop, manifest policy) and
- each rule is off, advisory, or block.
-
-
- The AI review — a dual-model read of the diff that writes review notes
- and, when you opt in, lets a high-confidence consensus become a blocker.
-
-
-
- comment on the PR, plus an optional LoopOver Orb Review Agent check run.
- The review algorithm is open-source; what changes between repos is the configuration you
- tune. See Tuning your reviews for the review options and
- defaults.
-
-
- Defaults are quiet. With no settings and no .loopover.yml, the gate is{" "}
- off, AI review is off, and the comment is posted only to detected
- contributors. Every capability is an explicit opt-in.
-
-
-
1. The gate: advisory vs. block
-
- The gate is deterministic — same inputs, same verdict, no model in the loop. Its master
- switch is reviewCheckMode (required / visible /{" "}
- disabled). Once enabled, each dimension is independently set to one of
- three modes:
-
-
-
- off — the dimension is not evaluated at all.
-
-
- advisory — the finding is surfaced in the comment, but it
- never blocks the merge.
-
-
- block — the finding can become a hard{" "}
- LoopOver Orb Review Agent blocker.
-
-
-
- A block outcome fails the gate for any author identically — confirmed-
- Gittensor-contributor status doesn't change who can be blocked, only the mode
- chooses which checks are active. Confirmed status is carried through for on-chain
- scoring, a separate concern from the gate's own merge/close decision.
-
-
-
The gate dimensions
-
These are the deterministic rules the gate runs, with their default modes:
- Linked-issue gate (linkedIssueGateMode, default{" "}
- advisory) — checks the PR references an issue, as strongly as{" "}
- linkedIssuePolicy asks.
-
-
- Quality / merge-readiness score gate (qualityGateMode,
- default advisory) — the PR-quality score; passes at or above{" "}
- qualityGateMinScore.
-
-
- Slop gate (slopGateMode, default off) — the
- deterministic anti-slop signal. advisory surfaces the slop score and
- warnings; block also hard-blocks at or above slopGateMinScore{" "}
- (engine default band 60).
-
-
- Copycat / plagiarism gate (copycatGateMode, default{" "}
- off) — a code containment/similarity check against prior art (earlier open or
- recently merged PRs on the same repo). Escalating tiers: warn surfaces an
- advisory finding only; label also applies a label; block also
- closes the PR and counts toward the repeat-offender strikes ledger. The direction is
- always by submission timestamp, so the earlier (original) author is never flagged.
-
- Manifest-policy gate (manifestPolicyGateMode, default{" "}
- off) — when block, the repo's declared policy (required linked
- issue and test expectations) becomes enforceable. Manual-review path holds are controlled
- separately by settings.hardGuardrailGlobs.
-
-
- PR-size hold (sizeGateMode, default off) — a PR
- at or above the configured file/line thresholds is held for manual review, never a hard
- failure.
-
-
- Lockfile-integrity gate (lockfileIntegrityGateMode, default{" "}
- off) — flags a lockfile-tamper-risk finding (a resolved/integrity change with
- no matching version bump, or a dependency pointed off the npm registry).
-
- Self-authored-linked-issue gate (
- selfAuthoredLinkedIssueGateMode, default advisory) — flags or
- blocks a PR whose author also opened the linked issue.
-
-
- Linked-issue satisfaction gate (
- linkedIssueSatisfactionGateMode, default off) — an AI assessment
- of whether the PR's diff actually satisfies its primary linked issue's intent (distinct
- from the linked-issue gate above, which only checks that a link exists).{" "}
- advisory renders the assessment in the review comment without ever blocking;{" "}
- block additionally lets a confidence-floor-passing "unaddressed" verdict
- become a hard blocker.
-
-
- Moderation-rules engine (moderationGateMode, default{" "}
- inherit) — whether the contributor-cap / blacklist / review-nag mechanisms
- feed a shared, cross-repo violation tally on this repo; inherit defers to the
- instance-wide default, off/enabled force it per repo.
-
-
-
- Which deterministic rules even apply is set by the policy pack (
- gatePack): gittensor (registry-aware, tracks confirmed-Gittensor-
- contributor status for scoring) or oss-anti-slop (runs the rules against any
- author on any repo, with no confirmed-contributor tracking at all).
-
-
-
-
2. The dual-AI review and consensus
-
- AI review is its own dimension (aiReviewMode, default off). It
- reads the diff and produces review notes — concrete findings tied to the change, not a vague
- verdict. Two modes:
-
-
-
- advisory — the AI write-up is posted as notes only. It never blocks.
-
-
- block — a dual-model high-confidence consensus defect is
- allowed to become a blocker.
-
-
-
- By default, the blocking decision runs on a pair of free models and only
- blocks when both models independently agree, with high confidence, on a real defect
- — no single-model block and no tie-breaker third model, so a confident-but-wrong single
- model can't block a good PR on its own. An operator can override this per repo (
- aiReviewCombine: single / consensus /{" "}
- synthesis); in single mode, one reviewer's verdict is the
- decision.
-
-
-
Bring your own model (advisory only)
-
- With aiReviewByok: true and a configured provider key, the advisory{" "}
- write-up can use a maintainer's own frontier model (aiReviewProvider /{" "}
- aiReviewModel, e.g. claude-3-5-sonnet-latest). The consensus
- blocker always stays on the free model pair, so BYOK improves the prose without ever
- changing who can be blocked.
-
-
- The LOOPOVER_REVIEW_GROUNDING flag grounds the reviewer prompt with the PR's
- finished CI status and the full post-change content of the changed files — so the model
- verifies its claims instead of predicting CI or flagging a symbol defined just outside the
- diff hunk. LOOPOVER_REVIEW_RAG adds semantically related existing code and docs
- as extra context. Both are additive and opt-in.
-
-
-
3. The unified review comment
-
- The result is rendered as one in-place comment on the PR — updated in place
- on each push rather than stacked. It has three parts, top to bottom:
-
-
-
- The alert — a one-line headline verdict: whether the gate blocks, what
- the single most important blocker is, or that the PR is clear. This is the line a reader
- scans first.
-
-
- Decision drivers — a short, always-visible checklist of the signals that
- actually decided the verdict: the AI code review and the gate result. This is the
- at-a-glance answer to why the verdict came out the way it did.
-
-
- Collapsibles — expandable sections for everything else: a "Context &
- advisory signals" table (linked issue, related work, validation posture, contributor
- context — signals that inform but never block the verdict), the AI review notes, slop
- warnings, duplicate matches, manifest findings. Folded away by default so the comment
- stays short, opened when a reader wants the evidence.
-
-
-
- Who sees the comment, and how much detail it carries, is a repo setting:{" "}
- commentMode chooses the audience (off /{" "}
- detected_contributors_only / all_prs), and{" "}
- publicSignalLevel (minimal / standard) controls how
- much of the signal detail is published. Private review context (maintainerNotes
- ) is never published to a public surface.
-
-
- Public-facing comments are sanitized before they leave the worker. Private scoring, reward,
- and reputation language never appears in the PR thread — and reputation-based spend control
- (LOOPOVER_REVIEW_REPUTATION) is never surfaced in any comment, label, or check.
-
-
-
4. The signals behind a verdict
-
- Each row in the advisory signals table comes from a named finding. The common ones you will
- see:
-
-
-
- secret_leak — the safety scan (LOOPOVER_REVIEW_SAFETY) found a
- leaked secret in the diff. The same scan also defangs untrusted PR text before the AI
- reviewer reads it.
-
-
- guardrail_hold — the PR touches a path listed in{" "}
- settings.hardGuardrailGlobs. This is a manual-review hold, not an auto-close
- reason.
-
-
- manifest_missing_tests — code changed but the expected test paths (
- testExpectations) did not.
-
-
- Slop score + warnings — the deterministic anti-slop signal. With{" "}
- slopAiAdvisory: true, a free advisory-only ai_slop_advisory{" "}
- finding is added too — it never feeds the score or the gate.
-
-
- Duplicate match — the other PR this one duplicates or supersedes.
-
-
- AI review notes — the dual-model findings, and (in block{" "}
- mode) any consensus defect.
-
-
-
- The check run can carry the same signals at adjustable depth — but this is the{" "}
- LoopOver Context check, not the Orb Review Agent gate check:{" "}
- checkRunMode (off / enabled) publishes it, and{" "}
- checkRunDetailLevel (minimal / standard) sets how
- much the check summary spells out. LoopOver Orb Review Agent is published
- separately, controlled by reviewCheckMode (see above).
-
-
-
Putting it together
-
- A pull request flows through the deterministic gate, then the dual-AI review, and the union
- of both is rendered as one alert + signal table + collapsibles comment. The gate decides{" "}
- can this merge with fixed rules you can read; the AI review adds judgment as
- advisory notes, escalating to a blocker only on two-model consensus; and the comment is the
- single, sanitized place a contributor reads the whole verdict. Tune every mode, threshold,
- and surface in Tuning your reviews.
-
- {DEFAULT_ROLE_SUMMARY[entry.id] ?? "see policy"}
-
-
- ))}
-
-
-
- >
- );
-}
-
function LoopOverCommandsReference() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- Commands never flip the gate to advisory and never bypass the one-shot disposition.{" "}
- pause and resume affect only auto-review scheduling — not gate
- enforcement. See How reviews work for the
- gate/review split.
-
-
-
Syntax
-
- Post a comment on a pull request (or issue thread) mentioning @loopover{" "}
- followed by a verb. Trailing free text becomes the command argument where noted (for example{" "}
- @loopover ask what should I fix first?).
-
- [argument or reason]`} />
-
-
-
-
-
-
Per-command authorization overrides
-
- Default allowed roles ship in the worker configuration. A maintainer can tighten or widen a
- single verb via commandAuthorization in .loopover.yml (resolved in
- the same order as other per-repo settings: manifest → database → defaults).
-
-
-
- Maintainer-only digest verbs ignore a plain pr_author role even when widened —
- only maintainer, collaborator, and confirmed_miner survive the clamp for those commands.
-
-
-
Related docs
-
-
- Maintainer workflow — when to invoke commands
- in a PR thread
-
-
- How reviews work — gate, dual-AI review, and
- unified comment
-
-
- Tuning your reviews — per-repo review and agent execution
- modes
-
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.maintainer-install-trust.tsx b/apps/loopover-ui/src/routes/docs.maintainer-install-trust.tsx
index 869d9fc626..cf3a48cdbf 100644
--- a/apps/loopover-ui/src/routes/docs.maintainer-install-trust.tsx
+++ b/apps/loopover-ui/src/routes/docs.maintainer-install-trust.tsx
@@ -1,10 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
-import { MAINTAINER_COMMAND_LIST, PUBLIC_COMMAND_LIST } from "@/lib/command-reference";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/maintainer-install-trust.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the loader below
+// resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/maintainer-install-trust")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["maintainer-install-trust"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Maintainer install and trust guide — LoopOver docs" },
@@ -30,224 +40,13 @@ export const Route = createFileRoute("/docs/maintainer-install-trust")({
});
function MaintainerInstallTrust() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- LoopOver is advisory-first. It may help you review contribution readiness, but it does not
- replace human maintainer judgment, expose private scoreability signals, or make reward,
- payout, wallet, hotkey, or trust-score claims in public surfaces.
-
-
-
Install the App
-
- Self-hosting is the only currently available path. Start from{" "}
- self-hosting setup — the direct App's
- required permissions and events are covered in{" "}
- GitHub App and Orb. Keep the first rollout
- narrow until the repo owner has verified permissions, webhook delivery, and public copy.
-
-
- LoopOver previously ran a private managed-beta shared App with its own install checklist
- below. That shared install path is currently paused while self-hosted Orb is the primary way
- to run LoopOver — a new centrally hosted offering is planned for the future. The checklist
- below reflects the shared App's permission set and is kept for when that offering returns;
- use the self-hosting checklist above for a self-hosted install today.
-
-
-
Install LoopOver on one test repository or a selected repository set.
-
- Approve Metadata: read, Pull requests: read, and{" "}
- Issues: write. Add Checks: write only when Context or
- review-agent check runs are enabled for the repository.
-
-
- Keep webhook events enabled for issues, issue_comment,{" "}
- pull_request, and repository.
-
-
- Leave comments, labels, Context checks, and review-agent checks in advisory mode until
- preview output matches the repo's maintainer policy.
-
-
-
- A self-hosted direct App needs Pull requests: write (not read) and{" "}
- Checks: write is mandatory, not optional — the numbered checklist above is
- scoped to the shared managed-beta App only.
-
-
-
-
Launch verification flow
-
- Treat launch as a controlled trust review. Do not enable public comments or required checks
- until every step below has a maintainer-visible result.
-
- verify installation health and webhook delivery
- -> preview public panel and command output
- -> confirm private signals stay private
- -> enable advisory Context, labels, or comments
- -> capture screenshots/recordings for UI or extension changes
- -> decide whether the review-agent check should be required in branch protection`}
- />
-
-
Maintainer controls
-
-
-
Command authorization
-
- Commands should be treated like privileged review actions. Use them to fetch context on
- demand, not to create always-on public scoring. The default authorized roles are{" "}
- maintainer, collaborator, and{" "}
- confirmed miner — a repo can narrow (or further restrict) this per command
- via commandAuthorization in its settings.
-
-
-
- A separate maintainer-only queue-digest command family defaults to maintainers and
- collaborators only:
-
-
-
- If a command would include private reviewability, private scoreability, duplicate-risk, or
- contributor-history context, the result must stay in maintainer-visible surfaces. Public
- replies should only contain sanitized actions a contributor can safely use.
-
-
-
Public-safe previews
-
- Preview every public output path before enabling it. The same public-safety boundary applies
- to GitHub comments, issue bodies, PR bodies, extension-visible public panels, and copied
- snippets.
-
-
-
No wallet or hotkey identifiers.
-
No reward, payout, or emission estimates.
-
No trust-score, public score prediction, or private scoreability language.
-
No private reviewability blockers or maintainer-only duplicate-risk notes.
-
No farming instructions, bounty gaming language, or rank-chasing advice.
-
-
- For the full boundary, keep Privacy & security as
- the source of truth. For AI-written text, use the{" "}
- AI summaries policy before posting generated copy.
-
-
-
Browser extension states
-
- The extension is a maintainer review aid. It should make state and scope obvious instead of
- implying that a contributor or public viewer can see private packets.
-
- no repo context, no private packet
-Signed in without repo scope
- -> prompt for authorized GitHub App installation or browser session
-Authorized maintainer on PR page
- -> public-safe PR panel + private maintainer blockers
-Unauthorized viewer or stale session
- -> public-safe state only, no private blockers
-API unavailable or stale data
- -> degraded state with retry guidance, never guessed scores`}
- />
-
- UI, frontend, browser-extension, or GitHub-overlay pull requests need maintainer-reviewable
- screenshots or a short recording that shows the relevant states. A checked template box is
- not enough evidence.
-
-
-
Audit expectations
-
- A healthy installation should leave an audit trail that maintainers can reason about without
- exposing repository source or contributor secrets.
-
-
-
Installation health shows permissions and webhook readiness.
-
Settings preview shows the exact public copy before posting.
-
Command previews identify the maintainer action that produced them.
-
Extension sessions are scoped to authorized review context.
-
Failures are inspectable through diagnostics instead of silent public output.
-
-
-
CI checks are not reviewer approval
-
- Keep GitHub CI/check state separate from reviewer and mergeability state. A green CI run or
- advisory Context check can prove automation completed, but it does not prove the PR is
- acceptable, non-duplicative, or safe to merge. Human maintainers still decide whether the
- contribution fits the repo, issue, and subnet goals.
-
-
- If the repo enables LoopOver Orb Review Agent, document which blockers are
- enforced and why. Otherwise, treat LoopOver output as reviewer context only.
-
-
-
Reject weak LoopOver-driven PRs
-
- Maintainers should request changes or close PRs that misuse LoopOver output. The tool is a
- contribution operating layer, not a guarantee that work deserves merge.
-
-
-
Reject PRs with no linked issue, no reproduction, or no validation evidence.
-
Reject UI or extension PRs that omit screenshots or recordings of changed flows.
-
- Reject copied snippets that leak private scoring, reward, trust, wallet, or hotkey text.
-
-
Reject duplicated work when the PR does not explain overlap and maintainer value.
-
- Reject generated broad rewrites that are not scoped to the issue acceptance criteria.
-
-
Reject PRs that confuse passing CI with maintainer approval.
-
-
-
Next docs
-
- Continue with Maintainer workflow for daily PR
- review, Troubleshooting for install diagnostics, and{" "}
- Browser extension for overlay behavior.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.maintainer-self-hosting.tsx b/apps/loopover-ui/src/routes/docs.maintainer-self-hosting.tsx
index 5529446a49..5c2efcb025 100644
--- a/apps/loopover-ui/src/routes/docs.maintainer-self-hosting.tsx
+++ b/apps/loopover-ui/src/routes/docs.maintainer-self-hosting.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/maintainer-self-hosting.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/FeatureRow primitives -- not
+// fumadocs-ui's bundled components. See docs-source.ts's comment for why the loader below
+// resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/maintainer-self-hosting")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["maintainer-self-hosting"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-hosted reviews — LoopOver docs" },
@@ -25,354 +36,14 @@ export const Route = createFileRoute("/docs/maintainer-self-hosting")({
component: MaintainerSelfHosting,
});
-const SECTION_LINKS = [
- {
- title: "Quickstart",
- description:
- "Bring up the container, smoke-test readiness, and confirm the GitHub webhook path.",
- to: "/docs/self-hosting-quickstart",
- },
- {
- title: "Configuration",
- description:
- "Understand env vars, private repo config, feature flags, and safe baseline defaults.",
- to: "/docs/self-hosting-configuration",
- },
- {
- title: "GitHub App and Orb",
- description:
- "Choose a direct GitHub App or brokered Orb enrollment and set the right permissions.",
- to: "/docs/self-hosting-github-app",
- },
- {
- title: "AI providers",
- description: "Wire Anthropic, OpenAI-compatible, Ollama, Claude Code, or Codex safely.",
- to: "/docs/self-hosting-ai-providers",
- },
- {
- title: "REES enrichment",
- description:
- "Run external analyzers, configure REES_ANALYZERS, and understand where results show up.",
- to: "/docs/self-hosting-rees",
- },
- {
- title: "REES analyzer reference",
- description:
- "Review every analyzer name, input, finding shape, network call, and token requirement.",
- to: "/docs/self-hosting-rees-analyzers",
- },
- {
- title: "RAG indexing",
- description: "Configure embeddings, Qdrant, indexing jobs, and cold-index behavior.",
- to: "/docs/self-hosting-rag",
- },
- {
- title: "Operations",
- description:
- "Health checks, logs, metrics, safe update/rollback checklists, deploy scripts, and daily operator routines.",
- to: "/docs/self-hosting-operations",
- },
- {
- title: "Backup and scaling",
- description: "SQLite, Litestream, Postgres, Redis, restores, and multi-instance tradeoffs.",
- to: "/docs/self-hosting-backup-scaling",
- },
- {
- title: "Releases and images",
- description: "Official images, tags, source maps, upgrade cadence, and local custom builds.",
- to: "/docs/self-hosting-releases",
- },
- {
- title: "Release checklist",
- description:
- "Versioning, the smoke matrix, an image-contents audit, and release notes for an orb-vX.Y.Z release.",
- to: "/docs/self-hosting-release-checklist",
- },
- {
- title: "Security",
- description:
- "Secret handling, private policy, public output boundaries, network exposure, and auth.",
- to: "/docs/self-hosting-security",
- },
- {
- title: "Troubleshooting",
- description:
- "Review not firing, REES silent, AI unavailable, RAG empty, queue stuck, and webhook failures.",
- to: "/docs/self-hosting-troubleshooting",
- },
- {
- title: "Docs accuracy audit",
- description:
- "Checklist mapping website docs to docker-compose, env, release, observability, and backup sources of truth.",
- to: "/docs/self-hosting-docs-audit",
- },
-] as const;
-
function MaintainerSelfHosting() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- Treat the self-host stack like production infrastructure. Keep secrets out of images and
- public repos, start in advisory or dry-run mode, and only enable write autonomy after you
- have watched real reviews, logs, metrics, and failure paths.
-
-
-
What this section covers
-
- Self-hosting is a major product path, not a single install command. The service can run as a
- quiet advisory reviewer, a private maintainer copilot, or a full review operator. The docs
- are split by operating concern so you can onboard gradually.
-
-
-
-
Recommended reading order
-
-
- Start with Quickstart to get a local
- instance healthy.
-
-
- Read Configuration before enabling repo
- review features.
-
-
- Set up GitHub App and Orb so webhooks and
- installation tokens are correct.
-
-
- Add AI providers,{" "}
- REES enrichment, the{" "}
- REES analyzer reference, and{" "}
- RAG indexing only after the deterministic path is
- stable.
-
-
- Use Operations,{" "}
- Backup and scaling, and{" "}
- Security before exposing the service to
- production traffic.
-
-
- Run the release checklist before
- tagging or promoting a candidate image.
-
-
-
-
Pages
-
- {SECTION_LINKS.map((item) => (
-
-
{item.title}
-
- {item.description}
-
-
- ))}
-
-
-
Onboarding simplification proposal (#1574)
-
- This section records today's accurate setup path and the gaps worth closing next — the
- issue deliverable for making self-host onboarding as fast as CodeRabbit while keeping robust
- per-repo policy in container-private config.
-
- Pull or build the image (INSTALL_AI_CLIS=true by default;{" "}
- --build-arg INSTALL_AI_CLIS=false for deterministic-only).
-
-
- One-click GitHub App via /setup + SELFHOST_SETUP_TOKEN (
- Checks: write included — re-approve on existing Apps after permission bumps).
-
-
- Mount ./loopover-config and copy{" "}
- config/examples/global.loopover.yml →{" "}
- loopover-config/.loopover.yml for a centralized private default (per-repo
- files deep-merge on top).
-
-
- Add each pilot repo to LOOPOVER_REVIEW_REPOS, watch a PR in{" "}
- dry-run, then enable advisory gate mode from the control panel or{" "}
- POST /v1/repos/:owner/:repo/activation.
-
-
- Go live by unsetting SELFHOST_DEPLOYMENT_MODE; tune autonomy in private
- config when ready.
-
-
-
-
Gaps and proposed improvements
-
-
- None of the proposals above require code changes to adopt today's path — they describe
- UX we can add without weakening the private-config model or env-level kill switches.
-
-
-
How self-hosting fits with hosted docs
-
- The hosted maintainer workflow still applies: review modes, gate settings, safety rules, and
- privacy boundaries are the same concepts. Self-hosting adds infrastructure choices,
- deployment secrets, private config, and local operating responsibility. Use{" "}
- Tuning your reviews for gate semantics and this section for
- running the service yourself.
-
-
-
Moving a repo between hosted and self-host
-
- "Hosted" here means the private managed-beta shared loopover App
- described in GitHub App configuration — it previously
- accepted new installs and is currently paused while self-hosted Orb is the primary way to
- run LoopOver. A new centrally hosted offering is planned for the future. "Switching from
- hosted to self-host" below still applies to any repo already installed on the hosted App;
- "switching back to hosted" isn't possible until hosted installs reopen, but the steps are
- kept here for when they do.
-
-
- A repo installed on the hosted App is reviewed by LoopOver's own cloud Worker and its
- own database. "Self-host" means your own container from{" "}
- Quickstart, with its own GitHub App (or
- brokered Orb enrollment) and its own data store. There is{" "}
- no automated migration path between the two — moving a repo is a manual App
- swap plus re-creating whatever settings you had, not a toggle.
-
-
-
Switching a repo from hosted to self-host
-
-
- Stand up your self-host instance first and confirm /ready is healthy — see{" "}
- Quickstart — before touching the hosted
- install, so the repo is never briefly reviewed by nothing.
-
-
- Create your own GitHub App via the self-host{" "}
- setup wizard (or brokered Orb enrollment).
- You cannot repoint the existing shared hosted App at your self-host container — the shared
- App's credentials belong to LoopOver's cloud Worker, and{" "}
- src/selfhost/setup-wizard.ts always mints a distinct App tied to your
- instance's own webhook URL.
-
-
- Install your new self-host App on the repo, choosing only that repo (or the org, if you're
- migrating several at once).
-
-
- Uninstall the shared hosted App from that repo (repo Settings → Integrations → GitHub Apps
- → loopover → Uninstall, or the equivalent org-level App settings page) once you've
- confirmed the self-host App is reviewing PRs correctly. Leaving both installed means two
- reviewers post competing checks and comments on the same PRs.
-
-
-
-
What does not carry over automatically
-
- Hosted-side settings live in LoopOver's own cloud database, keyed by repo full name —{" "}
- resolveRepositorySettings (src/settings/repository-settings.ts)
- reads them from env.DB, which is a completely different database instance than
- your self-host container's. A self-host instance has no access to, and no import path
- for, whatever thresholds, gate modes, or review-mode settings you configured on the hosted
- side through the control panel or API. If you want the same behavior, you have to
- re-configure it on the new instance from scratch — there is no export/import tool for this
- today.
-
-
-
- One thing genuinely does carry over: a repo's own .loopover.yml
- {" "}
- (config-as-code), because it lives in the repository's git history, not in either
- service's database. resolveRepositorySettings overlays it on top of
- whatever DB settings exist, on either hosted or self-host — so gate-mode overrides,
- thresholds, and other settings expressed in that file apply identically the moment the new
- App starts reviewing, with nothing to re-enter.
-
-
- Past review comments, check-run history, and any per-PR state LoopOver recorded while the
- hosted App was active stay wherever they were created — GitHub comments and check runs are
- never deleted or copied by an uninstall/install, but nothing in the self-host database is
- backfilled from the hosted side. A migrated repo starts its self-host review history from
- zero.
-
-
- What stays identical for contributors either way: the review still posts as a{" "}
- loopover[bot]-style comment (under your own App's slug once you migrate,
- not literally loopover[bot]) plus the same check-run shape, and the gate
- semantics in Tuning your reviews and{" "}
- How reviews work are unchanged — only the
- infrastructure and the settings storage location differ.
-
-
-
Switching a repo from self-host back to hosted
-
- Hosted installs are currently paused — this direction isn't possible until the shared App
- reopens (see the callout above). Once it does, the reverse migration has the same shape and
- the same gap: uninstall your self-host App from the repo, install the shared hosted App (see{" "}
- GitHub App configuration), and re-create any DB-backed
- settings on the hosted side. .loopover.yml again carries over for free since it
- travels with the repo; nothing else does. Your self-host instance's data volumes are
- untouched by this — see{" "}
- Uninstalling and decommissioning if you also
- intend to shut the instance down rather than keep it idle or reuse it for other repos.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.maintainer-workflow.tsx b/apps/loopover-ui/src/routes/docs.maintainer-workflow.tsx
index 328c4f912e..bba84e27c5 100644
--- a/apps/loopover-ui/src/routes/docs.maintainer-workflow.tsx
+++ b/apps/loopover-ui/src/routes/docs.maintainer-workflow.tsx
@@ -1,11 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
-import { WorkflowMirror, type MirroredStep } from "@/components/site/workflow-mirror";
-import { MAINTAINER_COMMAND_LIST, PUBLIC_COMMAND_LIST } from "@/lib/command-reference";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/maintainer-workflow.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/WorkflowMirror
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment for
+// why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/maintainer-workflow")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["maintainer-workflow"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Maintainer workflow — LoopOver docs" },
@@ -28,145 +37,13 @@ export const Route = createFileRoute("/docs/maintainer-workflow")({
});
function MaintainerWorkflow() {
- const steps: MirroredStep[] = [
- {
- title: "Plan",
- miner: (
- <>
- The contributor pulls a private decision pack via MCP — lane fit, repo targets, ranked
- next actions. Nothing is posted to your repo.
- >
- ),
- maintainer: (
- <>
- Default posture is silence. No always-on public check runs, no score numbers, no labels on
- non-confirmed-miner PRs.
- >
- ),
- nextStep: {
- miner: { label: "What miners see", to: "/docs/miner-workflow" },
- maintainer: { label: "Privacy boundary", to: "/docs/privacy-security" },
- },
- },
- {
- title: "Analyze",
- miner: (
- <>
- Metadata-only branch analysis runs locally. Source code stays on the contributor's
- machine.
- >
- ),
- maintainer: (
- <>
- On confirmed-miner PRs, request the maintainer packet on demand — no background scanning
- of your repo.
-
- >
- ),
- nextStep: {
- miner: { label: "Branch analysis reference", to: "/docs/branch-analysis" },
- maintainer: { label: "Maintainer packet API", to: "/api" },
- },
- },
- {
- title: "Preflight",
- miner: (
- <>
- The contributor sees branch blockers, account/queue blockers, and maintainer-fit notes
- before opening the PR.
- >
- ),
- maintainer: (
- <>
- You can ask for the same reviewability view in the PR thread.
-
- >
- ),
- nextStep: {
- miner: { label: "Common blockers", to: "/docs/troubleshooting" },
- maintainer: { label: "Upstream drift", to: "/docs/upstream-drift" },
- },
- },
- {
- title: "Packet",
- miner: (
- <>
- The contributor opens the PR with a public-safe packet — clean description, no private
- scoring language.
- >
- ),
- maintainer: (
- <>
- At most one sticky sanitized comment and one configured label per confirmed-miner PR. Pull
- richer context on demand via the API.
-
- >
- ),
- nextStep: {
- miner: { label: "PR packet format", to: "/docs/miner-workflow" },
- maintainer: { label: "Self-host reviews", to: "/docs/maintainer-self-hosting" },
- },
- },
- ];
-
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
The mirrored loop
-
- Each step on the right is what you see in the repo; the matching step on the left is what
- the contributor is doing privately via MCP at the same point.
-
-
- New installations should start with{" "}
- self-hosting setup, then{" "}
- GitHub App configuration: install on one repo, verify
- installation health, preview the public panel, then decide whether{" "}
- LoopOver Orb Review Agent should become a required check.
-
-
-
-
On-demand commands
-
- By default, the public commands below are authorized for maintainers, collaborators, and
- confirmed miners (not the general public) — output stays scoped to maintainer-visible
- packets when appropriate. A repo can narrow this per command via{" "}
- commandAuthorization in its settings.
-
-
-
- A separate maintainer-only queue-digest family defaults to maintainers and collaborators
- only:
-
-
-
- For syntax, default roles, PR action verbs, and the gate vs auto-review boundary, see the{" "}
- @loopover command reference.
-
-
-
- Public-facing comments are sanitized before they leave the Worker. Private scoring, reward,
- and risk language never appears in the PR thread.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.mcp-clients.tsx b/apps/loopover-ui/src/routes/docs.mcp-clients.tsx
index d1b8dcd7c8..89ce86ffbf 100644
--- a/apps/loopover-ui/src/routes/docs.mcp-clients.tsx
+++ b/apps/loopover-ui/src/routes/docs.mcp-clients.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/mcp-clients.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/mcp-clients")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["mcp-clients"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "MCP client setup — LoopOver docs" },
@@ -26,98 +37,13 @@ export const Route = createFileRoute("/docs/mcp-clients")({
});
function McpClients() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Generate config
-
These commands print config only. They do not mutate your local client files.
-
-
- --print mcp uses the same JSON snippet as Claude Desktop and Cursor for other
- stdio MCP hosts that expect the mcpServers shape. Every generated snippet
- assumes loopover-mcp is on your PATH (install it globally first,
- per Quickstart) — pass{" "}
- --command /absolute/path/to/loopover-mcp if your client doesn't inherit your
- shell PATH.
-
-
-
Codex (OpenAI)
-
-
-
Claude Desktop
-
-
-
Cursor
-
-
-
VS Code
-
- VS Code's native MCP support uses a servers map with an explicit transport type
- instead of the mcpServers shape the other JSON hosts use:
-
-
-
-
Remote MCP
-
- The Worker also exposes a remote MCP endpoint. Use this when your agent runs in the cloud or
- you don't want a local Node process.
-
-
-
-
- Local --stdio is the default recommendation. It keeps auth + analysis on your
- machine and is the easiest path to log into with GitHub Device Flow.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.miner-coding-agent.test.tsx b/apps/loopover-ui/src/routes/docs.miner-coding-agent.test.tsx
index 4453e11244..85b3121846 100644
--- a/apps/loopover-ui/src/routes/docs.miner-coding-agent.test.tsx
+++ b/apps/loopover-ui/src/routes/docs.miner-coding-agent.test.tsx
@@ -1,6 +1,5 @@
-import { render, screen } from "@testing-library/react";
-import { type ReactNode } from "react";
-import { describe, expect, it, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
import { CODING_AGENT_DRIVER_NAMES } from "../../../../packages/loopover-engine/src/miner/driver-factory";
import {
@@ -9,66 +8,20 @@ import {
MinerCodingAgentDriverDocs,
} from "./docs.miner-coding-agent";
-vi.mock("@tanstack/react-router", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- Link: ({ to, children }: { to: string; children: ReactNode }) => {children},
- };
-});
-
-vi.mock("@/components/site/docs-page", () => ({
- DocsPage: ({
- children,
- title,
- eyebrow,
- description,
- }: {
- children: ReactNode;
- title: string;
- eyebrow?: string;
- description?: string;
- }) => (
-
- ),
-}));
+// Renders from content/docs/miner-coding-agent.mdx via the fumadocs client-loader (see
+// docs-source.ts's comment) -- a synchronous component render can't exercise that path
+// without a full router context, so this is now a content drift-guard on the .mdx source,
+// matching the pattern in docs-selfhost-activation-paths.test.ts.
+const MDX_PATH = "content/docs/miner-coding-agent.mdx";
describe("miner coding-agent docs page", () => {
- it("mounts the docs route and renders the expected sections", async () => {
- render();
-
- expect(await screen.findByRole("heading", { name: "Miner coding-agent driver" })).toBeTruthy();
- expect(screen.getByRole("heading", { name: "Provider selection" })).toBeTruthy();
- expect(screen.getByRole("heading", { name: "Model and timeout overrides" })).toBeTruthy();
- expect(
- screen.getByRole("heading", { name: "Recognizing a stale or missing credential" }),
- ).toBeTruthy();
- expect(screen.getByRole("heading", { name: "Related docs" })).toBeTruthy();
+ it("documents the expected sections", () => {
+ const source = readFileSync(MDX_PATH, "utf8");
+ expect(source).toContain("title: Miner coding-agent driver");
+ expect(source).toContain("## Provider selection");
+ expect(source).toContain("## Model and timeout overrides");
+ expect(source).toContain("## Recognizing a stale or missing credential");
+ expect(source).toContain("## Related docs");
});
it("keeps the provider list aligned with the engine's accepted provider names", () => {
diff --git a/apps/loopover-ui/src/routes/docs.miner-coding-agent.tsx b/apps/loopover-ui/src/routes/docs.miner-coding-agent.tsx
index 67458e507d..be28a73fda 100644
--- a/apps/loopover-ui/src/routes/docs.miner-coding-agent.tsx
+++ b/apps/loopover-ui/src/routes/docs.miner-coding-agent.tsx
@@ -1,8 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/miner-coding-agent.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
+//
+// The three arrays below no longer feed this page's own JSX -- their prose was
+// hand-transcribed into the migrated .mdx above -- but they stay exported because
+// docs.miner-coding-agent.test.tsx imports them directly (asserting
+// MINER_CODING_AGENT_PROVIDER_ITEMS against packages/loopover-engine's
+// CODING_AGENT_DRIVER_NAMES, and MINER_CODING_AGENT_ENV_ROWS's env var names). Keep
+// both this data and the corresponding MDX content in sync if either changes.
export const MINER_CODING_AGENT_PROVIDER_ITEMS: Array<{ title: string; description: string }> = [
{
title: "noop",
@@ -86,6 +98,12 @@ export const MINER_CODING_AGENT_TRUST_ROWS: Array<{ title: string; description:
];
export const Route = createFileRoute("/docs/miner-coding-agent")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["miner-coding-agent"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Miner coding-agent driver — LoopOver docs" },
@@ -108,99 +126,13 @@ export const Route = createFileRoute("/docs/miner-coding-agent")({
});
export function MinerCodingAgentDriverDocs() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- The miner resolves MINER_CODING_AGENT_PROVIDER as a comma-separated preference
- list. The first configured name wins, unknown names are skipped, and an empty or unset list
- leaves production construction fail-closed instead of guessing a default backend.
-
-
-
- This seam is explicit on purpose: if you do not configure a provider, the miner does not
- silently pick one for you.
-
-
-
Provider selection
-
-
-MINER_CODING_AGENT_TIMEOUT_MS=120000
-
-# Prefer Codex, fall back to Claude.
-MINER_CODING_AGENT_PROVIDER=codex-cli,claude-cli
-MINER_CODING_AGENT_CODEX_MODEL=`}
- />
-
- `noop` and agent-sdk ignore the model and timeout knobs. Only the CLI
- subprocess providers consume them.
-
-
-
Model and timeout overrides
-
- The only driver-specific knobs today are the provider-specific model overrides and the
- shared wall-clock timeout. Anything else is task-level orchestration, not provider config.
-
- The shared troubleshooting table for Claude Code and Codex lives on{" "}
-
- Self-host AI providers
-
- . This page keeps the miner-specific reminder: the credential lives on the operator's
- machine or mounted volume, not in repo config.
-
-
-
- If the CLI cannot see its credential, the miner cannot spawn a healthy provider. Fix the
- operator-owned credential path first, then come back to the miner env vars.
-
-
-
Related docs
-
-
- Miner quickstart by lane — install and verify the
- miner before you wire a coding agent.
-
-
- Miner workflow — the rest of the contributor loop
- after the driver is configured.
-
-
- Self-host AI providers — the broader
- credential and provider reference that shares the troubleshooting table above.
-
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.miner-quickstart.tsx b/apps/loopover-ui/src/routes/docs.miner-quickstart.tsx
index 398692776c..e40f234905 100644
--- a/apps/loopover-ui/src/routes/docs.miner-quickstart.tsx
+++ b/apps/loopover-ui/src/routes/docs.miner-quickstart.tsx
@@ -1,11 +1,20 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
-import { AmsObservabilityCallout } from "@/components/site/ams-observability-callout";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
-import { Link } from "@tanstack/react-router";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/miner-quickstart.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/AmsObservabilityCallout
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/miner-quickstart")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["miner-quickstart"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Miner quickstart by lane — LoopOver docs" },
@@ -28,172 +37,13 @@ export const Route = createFileRoute("/docs/miner-quickstart")({
});
export function MinerQuickstart() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- LoopOver is copilot-only. It ranks and explains your options and drafts public-safe PR
- packets. It does not edit code, open PRs, or post comments for you, it makes no earnings
- promises, and it never predicts a public number. Every command below also accepts{" "}
- --json for machine-readable output, and your source never leaves your machine —
- only branch metadata (changed file paths, commit messages) is sent to authenticated LoopOver
- MCP/API responses.
-
-
- If you are setting up Claude Code or Codex as the miner's coding-agent driver, read{" "}
- Miner coding-agent driver first so the env vars
- match the provider you actually plan to run.
-
-
-
0. Install and sign in (every lane)
-
- The MCP is published as @loopover/mcp. Run it with npx or install
- it globally, then authenticate with GitHub Device Flow — LoopOver never asks for a Personal
- Access Token.
-
-
-
- Session tokens are LoopOver tokens backed by GitHub identity, not your
- GitHub PATs. Source upload stays disabled (LOOPOVER_UPLOAD_SOURCE=false) and
- local absolute paths are redacted from anything that leaves your machine. Log out anytime
- with loopover-mcp logout.
-
-
-
1. Choose your lane
-
- Lanes describe how you contribute. Pick the one that matches the work in front of
- you, then read what the target repo actually supports: agent plan and{" "}
- repo-decision report the repo's configured lane so you can align before
- you start.
-
-
-
- The repo's configured lane comes back as one of these (it is set by the repo's
- registry config, not by you):
-
-
-
- direct_pr — implementation PRs only. Prefer focused PRs with clear evidence,
- linked context, and low review churn.
-
-
- issue_discovery — discovery work only. Focus on high-proof issue reports and
- avoid self-resolved issue loops.
-
-
- split — both paths are active. Pick one intentionally: issue discovery for
- reports, direct PR for implementation.
-
-
- inactive — registered but with no current allocation. Treat it as normal
- upstream contribution work unless the registry changes.
-
-
- unknown — not registered (or no config yet). Do not assume the repo is ready
- for Gittensor-specific contribution guidance.
-
-
-
-
2. Direct PR lane
-
- You are implementing a change and opening a PR directly. Use this in a{" "}
- direct_pr or split repo. Plan, preflight your branch metadata,
- then generate the public-safe packet to paste into the PR body.
-
-
-
-
3. Issue-solving PR lane
-
- You are fixing a specific open issue. Same loop as a direct PR, but link the issue in your
- branch so preflight can credit the linked context. Confirm the linked-issue signal in the
- preflight output before opening the PR.
-
-
-
-
4. Issue discovery lane
-
- You are reporting a high-proof issue rather than opening a PR. Use this in an{" "}
- issue_discovery or split repo. Start from the plan to see which
- discovery work is worth it, and keep reports specific and reproducible — avoid self-resolved
- loops.
-
-
-
-
5. Docs and context work
-
- Documentation and context contributions still ship as PRs, so they follow the direct PR
- loop. Run preflight on the branch metadata and generate a packet — the packet is the same
- public-safe artifact regardless of whether the change is code or docs.
-
-
-
-
6. Repo-specific lanes
-
- Some repos run their own lane policy. Always let the repo tell you:{" "}
- repo-decision returns the configured lane plus contributor guidance, and{" "}
- analyze-branch lets you model a scenario (pending merges, expected open PRs)
- before you commit to a path.
-
-
-
-
Validation expectations (every lane)
-
- Before you open anything, the loop should be clean: doctor green, your branch
- metadata preflighted, and a validation note attached. Pass what you actually ran via{" "}
- --validation "status|command|summary" (for example{" "}
- "passed|npm test|all green") so the preflight verdict reflects real
- validation, not a guess.
-
-
-
- The PR packet from agent packet is public-safe: it is scrubbed
- of economic and identity signals (wallet/hotkey, payout, trust-score, ranking, and
- public-prediction language) before it can be pasted into a public GitHub surface. Pair this
- page with the miner workflow for the full loop and{" "}
- privacy & security for the boundary details.
-
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.miner-workflow.tsx b/apps/loopover-ui/src/routes/docs.miner-workflow.tsx
index d0bbb52fab..355737c58d 100644
--- a/apps/loopover-ui/src/routes/docs.miner-workflow.tsx
+++ b/apps/loopover-ui/src/routes/docs.miner-workflow.tsx
@@ -1,12 +1,21 @@
-import { createFileRoute } from "@tanstack/react-router";
-import { Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
-import { AmsObservabilityCallout } from "@/components/site/ams-observability-callout";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
-import { WorkflowMirror, type MirroredStep } from "@/components/site/workflow-mirror";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/miner-workflow.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/WorkflowMirror/
+// AmsObservabilityCallout primitives -- not fumadocs-ui's bundled components. See
+// docs-source.ts's comment for why the loader below resolves only a plain,
+// serializable path string.
export const Route = createFileRoute("/docs/miner-workflow")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["miner-workflow"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Miner workflow — LoopOver docs" },
@@ -27,117 +36,13 @@ export const Route = createFileRoute("/docs/miner-workflow")({
});
export function MinerWorkflow() {
- const steps: MirroredStep[] = [
- {
- title: "Plan",
- miner: (
- <>
- Pull a decision pack — lane context, repo targets to pursue or avoid, freshness, and
- ranked next actions.
-
- >
- ),
- maintainer: (
- <>
- Nothing visible in the repo. The plan step is private MCP context for the contributor; no
- public comments or labels are emitted.
- >
- ),
- nextStep: {
- miner: { label: "How scoreability works", to: "/docs/scoreability" },
- maintainer: { label: "Privacy boundary", to: "/docs/privacy-security" },
- },
- },
- {
- title: "Analyze",
- miner: (
- <>
- Metadata-only branch analysis on the current branch — refs, changed-file metadata, labels,
- linked issues, commit messages, validation summaries.
-
- >
- ),
- maintainer: (
- <>
- Still silent in the repo. Branch analysis runs locally against the API; no source is
- uploaded and no check runs are created.
- >
- ),
- nextStep: {
- miner: { label: "Branch analysis reference", to: "/docs/branch-analysis" },
- maintainer: { label: "What we don't upload", to: "/docs/privacy-security" },
- },
- },
- {
- title: "Preflight",
- miner: (
- <>
- Combine branch analysis with account/queue context to surface branch blockers, account
- blockers, and maintainer-fit notes.
-
- >
- ),
- maintainer: (
- <>
- On confirmed-miner PRs you can later request the same view with
- @loopover preflight — the response is sanitized for the PR thread.
- >
- ),
- nextStep: {
- miner: { label: "Common preflight blockers", to: "/docs/troubleshooting" },
- maintainer: { label: "All @loopover commands", to: "/docs/maintainer-workflow" },
- },
- },
- {
- title: "Packet",
- miner: (
- <>
- Produce a public-safe PR packet — a description that reads cleanly to maintainers, with no
- private scoring or risk language leaking out.
-
- >
- ),
- maintainer: (
- <>
- At most one sticky sanitized comment and one configured label per confirmed-miner PR.
- Private scoring, reward, and risk language never appear in the thread.
- >
- ),
- nextStep: {
- miner: { label: "Set up your MCP client", to: "/docs/mcp-clients" },
- maintainer: { label: "Self-host reviews", to: "/docs/maintainer-self-hosting" },
- },
- },
- ];
-
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- If the workflow will spawn Claude Code or Codex, configure that driver first in{" "}
- Miner coding-agent driver.
-
-
The mirrored loop
-
- Each step on the left is what the contributor runs; the matching step on the right is what
- the maintainer sees in the repo at the same point.
-
-
-
-
- Cleanup first. When the preflight reports queue pressure or unsquashed
- commits, prefer cleaning open work over opening more — risk-adjusted priority is part of the
- score model.
-
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.owner-checklist.tsx b/apps/loopover-ui/src/routes/docs.owner-checklist.tsx
index 8753d130e5..72d44a2be1 100644
--- a/apps/loopover-ui/src/routes/docs.owner-checklist.tsx
+++ b/apps/loopover-ui/src/routes/docs.owner-checklist.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/owner-checklist.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/owner-checklist")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["owner-checklist"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Repo-owner onboarding checklist — LoopOver docs" },
@@ -26,134 +37,13 @@ export const Route = createFileRoute("/docs/owner-checklist")({
});
function OwnerChecklist() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- LoopOver is quiet by default: it installs without posting comments or
- adding labels until you turn those surfaces on. This checklist is what to confirm first.
- Everything owner-only runs through the private API or the{" "}
- owner console; readiness is reported as bands and statuses,
- never as a raw private number.
-
-
- Start by pulling your readiness report — it returns ready plus a list of{" "}
- blockers, and drives every item below. You can also wire a review-only agent
- with the repo-owner-intake profile (it explains and drafts setup plans; it
- never pushes config, labels issues, or posts comments).
-
-
-
-
-
1. Repository registration
-
- Confirm the repo is in the current LoopOver registry. If it is not, that is the first{" "}
- blocker in the readiness report and nothing else applies yet. Register and
- review from the owner console.
-
-
-
2. Repo policy & config quality
-
- Your policy lives in .loopover.yml (sections: settings,{" "}
- gate, review). The readiness report grades it as{" "}
- configQuality (excellent / good / needs_attention / fragile); a{" "}
- fragile config is a hard blocker. Preview exactly what a given config would
- do on a real PR before you commit it:
-
-
-
-
3. Labels & trusted pipeline
-
- The report checks labelPolicy.trustedPipelineReady and lists missing or unused
- registry labels. Configure the labels you actually use before turning on{" "}
- labelMultipliers.
-
-
- Honest tradeoff: enabling trusted-label multipliers before your labels are real and applied
- consistently incentivizes the wrong work. Start without them and add them once the label
- pipeline is verified.
-
-
-
4. Issue quality
-
- Clear, reproducible, well-scoped issues are the cheapest way to reduce low-quality PR
- pressure: contributors aim at real targets instead of guessing. The issue-quality signal
- feeds the contribution lanes below, so tidy your open issues before broadening intake.
-
-
-
5. Contribution lanes
-
- A repo's lane is configured (not chosen by contributors) and the config recommendation
- endpoint suggests one with its tradeoffs. The lane is one of:
-
-
-
- direct_pr — implementation PRs only (issueDiscoveryShare = 0).
-
- split — both lanes active (0 < issueDiscoveryShare < 1).
-
-
- inactive — registered but with no current allocation.
-
-
- unknown — not registered or no config yet.
-
-
-
- Honest tradeoff: the split lane is recommended only when contributor intake is
- healthy and config quality is excellent. Adding an issue-discovery slice surfaces more
- outside work but adds triage load and duplicate risk — default to direct_pr{" "}
- until you have capacity.
-
-
-
6. Validation expectations & gate readiness
-
- Declare your validation commands in .loopover.yml so contributors know what
- "done" means, and so the gate can run. The report reports{" "}
- testCoverageHealth as gate_ready or gate_unknown;
- gate checks only run when you have explicitly configured them. Until then the gate stays
- advisory.
-
-
-
7. Maintainer capacity & queue health
-
- The report grades queueHealth (low / medium / high / critical) from your open
- PR/issue burden, and maintainerCutReadiness tells you whether the repo is calm
- enough to reserve a maintainer lane.
-
-
- Honest tradeoffs: opening more lanes means more triage. A maintainer cut credits upkeep but
- reduces the miner share. Requiring a linked issue improves traceability but deters quick
- drive-by PRs. Pick deliberately for the capacity you actually have.
-
-
-
8. Public/private boundaries
-
- Decide what becomes visible: publicSurface (comments + labels),{" "}
- commentMode, and publicAudienceMode. Everything that can reach a
- public GitHub surface is run through the sanitizer first, so economic and identity signals
- are stripped — along with local file paths — and nothing is framed as a guaranteed outcome.
-
-
- Turning on public comments and labels increases visibility — and the volume of drive-by PRs.
- Keep surfaces quiet until items 1–7 are green. See{" "}
- privacy & security for the full boundary and
- the owner workflow for the end-to-end setup path.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.privacy-security.tsx b/apps/loopover-ui/src/routes/docs.privacy-security.tsx
index 3940dd5a82..cf7ed5d052 100644
--- a/apps/loopover-ui/src/routes/docs.privacy-security.tsx
+++ b/apps/loopover-ui/src/routes/docs.privacy-security.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/privacy-security.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/privacy-security")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["privacy-security"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Privacy & security — LoopOver docs" },
@@ -26,120 +37,13 @@ export const Route = createFileRoute("/docs/privacy-security")({
});
function PrivacySecurity() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Hard rules
-
-
No source upload by default. MCP sends metadata only.
-
No PAT storage. Auth uses GitHub Device Flow.
-
No wallet or hotkey display.
-
No raw trust-score display.
-
No payout/reward guarantees, anywhere.
-
No farming language.
-
No public score estimates.
-
No private reviewability details in public GitHub output.
-
-
-
Open algorithm, private tuning
-
- LoopOver's review engine is built so the{" "}
- logic is public but the dial settings are not. The deterministic gate, the
- scoring signals, the slop detector, the grounding/RAG context builders, and the comment
- renderer all live in the open source tree — anyone can read exactly how a verdict is
- reached. What stays private is the production tuning: the thresholds,
- guardrail paths, and gate modes an operator runs in production. That separation is what
- keeps a review from being gameable off the public code.
-
-
- Tuning lives in two private, repo-scoped places that sit on top of the open algorithm, and
- neither reveals review direction:
-
-
-
- Per-repo settings — gate modes, score thresholds, and guardrails, stored
- in the operator's database (set through the dashboard/API) or declared as config-as-code
- in a repo's .loopover.yml. Choosing gate.slop.minScore or
- setting settings.hardGuardrailGlobs tightens the gate without telling a
- contributor how to bypass it.
-
-
- Operator feature flags — the LOOPOVER_REVIEW_* family of
- worker environment variables. These switch whole capabilities (safety scanning, CI and
- full-file grounding, RAG context, reputation-based spend control, the unified comment) on
- or off for a deployment.
-
-
-
- Every feature flag ships OFF, and a per-PR capability runs only when its
- own flag is on and the repo is in the LOOPOVER_REVIEW_REPOS allowlist
- — so capabilities stay dormant until an operator explicitly converges a repo, one flag and
- one repo at a time.
-
-
-
- The internal-only controls never surface publicly. Submitter reputation, for example, can
- downgrade a burst or low-reputation submitter to a deterministic-only review — but no
- comment, label, or check ever shows a reputation value. Reputation thresholds are generic
- anti-abuse defaults that reveal no review direction and are not per-repo tunable.
-
-
- Reading the open source tells you how a verdict is computed, never{" "}
- what an operator's production gate will decide. The deciding inputs —
- thresholds, guardrail globs, and which LOOPOVER_REVIEW_* capabilities are live
- — are private runtime settings, so reviews cannot be reverse-engineered or gamed from the
- public code.
-
-
-
Public output rules
-
-
At most one sticky sanitized comment per confirmed-miner PR.
-
At most one configured label per confirmed-miner PR.
-
Public comments are maintainer-friendly and non-shaming.
-
-
- Website copy may discuss private scoreability and risk reasoning, but it's always framed as{" "}
- private MCP/API context. The public web never carries score numbers.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.quickstart.tsx b/apps/loopover-ui/src/routes/docs.quickstart.tsx
index daf809bb29..440d1b525d 100644
--- a/apps/loopover-ui/src/routes/docs.quickstart.tsx
+++ b/apps/loopover-ui/src/routes/docs.quickstart.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/quickstart.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/quickstart")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["quickstart"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Quickstart — LoopOver docs" },
@@ -26,60 +37,13 @@ export const Route = createFileRoute("/docs/quickstart")({
});
function Quickstart() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
1. Install
-
- The MCP is published as @loopover/mcp. You can run it with npx, or
- install it globally.
-
-
-
-
2. Sign in (GitHub Device Flow)
-
- LoopOver never asks for a Personal Access Token. The CLI walks you through GitHub's Device
- Flow and exchanges the result for a LoopOver session token.
-
-
-
- Session tokens are LoopOver tokens backed by GitHub identity, not your
- GitHub PATs. You can log out anytime with loopover-mcp logout.
-
-
-
3. Run your first analysis
-
Analyze the current branch with metadata only. No source ever leaves your machine.
-
-
-
4. Wire it into your coding agent
-
- Print a config snippet for your editor of choice and paste it in. See{" "}
- MCP client setup for the details. For the full miner path
- (plan → preflight → packet) and other roles, see{" "}
- Beta onboarding.
-
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.scoreability.tsx b/apps/loopover-ui/src/routes/docs.scoreability.tsx
index 4c3120a124..80e78ac836 100644
--- a/apps/loopover-ui/src/routes/docs.scoreability.tsx
+++ b/apps/loopover-ui/src/routes/docs.scoreability.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/scoreability.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/scoreability")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["scoreability"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Scoreability — LoopOver docs" },
@@ -26,100 +37,13 @@ export const Route = createFileRoute("/docs/scoreability")({
});
function Scoreability() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
The seven scenarios
-
- Every preview computes scenarioPreviews, an array of exactly seven named
- scenarios, alongside a top-level effectiveEstimatedScore and{" "}
- underlyingPotentialScore for the current state:
-
-
-
- current — what's scoreable right now, given all current gates and
- observed data.
-
- afterPendingMerges — projection assuming your other open PRs on this repo
- merge, relieving open-PR collateral.
-
-
- afterApprovedPrsMerge — projection assuming only your already-approved
- open PRs merge.
-
-
- afterStalePrsClose — projection assuming your stale open PRs close
- instead of merging.
-
-
- linkedIssueFixed — projection assuming the linked issue is validated and
- the standard issue multiplier applies.
-
-
- bestReasonableCase — the best of the above scenarios; the realistic upper
- bound across known cleanups.
-
-
-
- Each scenario carries its own scoreEstimate, gates,{" "}
- effectiveEstimatedScore, underlyingPotentialScore,{" "}
- blockedBy, and a human-readable deltaExplanation.
-
-
-
Language rules
-
- Use scoreability, estimated score,{" "}
- underlying potential, and scoreability status. Never say{" "}
- guaranteed payout, guaranteed reward, or anything implying outcome
- guarantees.
-
-
-
Example shape
-
- Trimmed for readability — the real response also includes laneMath,{" "}
- gates, gateDeltas, and per-scenario detail:
-
-
-
- scoreabilityStatus is one of blocked,{" "}
- conditionally_scoreable, scoreable, or hold (the repo
- itself isn't actively scoreable — unregistered or inactive allocation).
-
-
-
- Scoreability numbers and risk language are private. They appear only in
- MCP/API responses. They are never written to public GitHub surfaces.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-ai-providers.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-ai-providers.tsx
index c116a32455..154ffc2f15 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-ai-providers.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-ai-providers.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-ai-providers.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-ai-providers")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-ai-providers"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host AI providers — LoopOver docs" },
@@ -26,233 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-ai-providers")({
});
function SelfHostingAiProviders() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Provider options
-
-
-
Single provider
-
-ANTHROPIC_AI_MODEL=claude-sonnet-4-6`}
- />
-
- ANTHROPIC_AI_BASE_URL defaults to https://api.anthropic.com — set
- it only to route through a gateway or proxy in front of the real Anthropic API.
-
-
-
OpenAI API
-
- Distinct from OpenAI-compatible below: this is the native OpenAI API path (
- AI_PROVIDER=openai), for when you have an OpenAI account key rather than a
- gateway or local endpoint.
-
- OPENAI_AI_BASE_URL and OPENAI_AI_MODEL already default to the
- values shown — set them only to override the endpoint or model.
-
-
-
OpenAI-compatible endpoint
-
-
-
Ollama (dedicated provider)
-
- AI_PROVIDER=ollama is a separate provider id from routing Ollama through{" "}
- openai-compatible above — use whichever matches how you want
- fallback/dual-review chains to identify it. Defaults to a local Ollama at{" "}
- http://localhost:11434/v1 with no API key.
-
-
-
- Set OLLAMA_AI_BASE_URL to http://ollama:11434/v1 when using the
- compose ollama profile; OLLAMA_AI_API_KEY is normally left blank
- for a local, unauthenticated Ollama instance.
-
-
-
Fallback and dual review
-
- A comma-list is a fallback chain by default. Use this for subscription CLIs when you want
- Codex first and Claude Code only when Codex is unavailable or out of tokens.
-
-
-
- Set AI_DUAL_REVIEW=1 only when you want the first two providers to run as
- independent reviewers on every PR. In dual-review mode, AI_COMBINE controls how
- decisions are combined.
-
-
-
-
-
Claude Code (subscription)
-
- No API key — claude-code runs your existing Claude subscription through the{" "}
- claude CLI. Generate a long-lived token once with{" "}
- claude setup-token and store it as CLAUDE_CODE_OAUTH_TOKEN.
-
-
-CLAUDE_AI_EFFORT=medium`}
- />
-
- Rotating it: when the subscription's usage limit resets or the token needs
- replacing, run claude setup-token again and paste the new value into{" "}
- .env. CLAUDE_CODE_OAUTH_TOKEN is baked into the container at
- creation time, so a plain restart keeps serving the old value — recreate the service
- instead:
-
-
-
- Prefer not pasting the raw token into .env? Write it into{" "}
- secrets/claude_code_oauth_token.txt instead (see{" "}
- Security's secret-file section) and leave{" "}
- CLAUDE_CODE_OAUTH_TOKEN unset in .env — the same recreate step
- above picks it up either way.
-
-
-
Codex (subscription)
-
- Codex has no equivalent of claude setup-token. Instead of an environment
- variable, it stores an OAuth credential file, auth.json. Authenticate against
- the running container so the file lands on the volume the image expects (
- /data/codex, mounted at ~/.codex):
-
-
-
- This also needs the explicit opt-in shown in the fallback example above (
- LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER=1) — see the Subscription CLI safety note
- below for why it defaults to off.
-
-
- Codex reads auth.json fresh on every review — each review spawns a new{" "}
- codex subprocess. Once codex auth succeeds, the very next review
- authenticates correctly; there is no service to recreate and no env var to change.
-
-
-
Recognizing a stale or missing credential
-
- Both subscription CLIs fail loudly in the self-host logs instead of silently degrading the
- review:
-
-
-
-
Subscription CLI safety
-
- Subscription CLIs store credentials on disk. Do not mount a writable or prompt-readable CLI
- home into review execution unless you have isolated it from PR-controlled content. Use an
- API provider or local OpenAI-compatible endpoint when isolation is not clear.
-
-
-
Related context
-
- AI providers produce the review. REES and{" "}
- RAG add context that the reviewer can use.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-backup-scaling.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-backup-scaling.tsx
index af6ba55fda..453c484c7a 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-backup-scaling.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-backup-scaling.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-backup-scaling.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-backup-scaling")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-backup-scaling"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host backup and scaling — LoopOver docs" },
@@ -26,191 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-backup-scaling")({
});
function SelfHostingBackupScaling() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Default: SQLite single node
-
- SQLite is the default because it is operationally simple and good enough for a single
- maintainer instance. The tradeoff is obvious: if the volume is lost, review state is lost.
-
-
- Do not treat the default data volume as a backup. Snapshot it or enable continuous backup.
-
-
-
- The bundled backup profile writes the active app database to the{" "}
- loopover-backups volume. SQLite installs use an online backup; Postgres
- installs use pg_dump. The same run also snapshots Qdrant when it is enabled.
-
-
-
-
Retention: how many backups are kept
-
- Each run keeps the newest BACKUP_RETAIN backups (default 7) —
- applied independently per target: postgres/, sqlite/, and{" "}
- qdrant/ in the loopover-backups volume each retain their own
- newest 7, not 7 combined across all three. Set it in .env to change the window:
-
-
-
- scripts/backup.sh's normalize_backup_retain guards against
- misconfiguration rather than failing the run: a non-numeric or empty value falls back to 7
- with a logged warning, and BACKUP_RETAIN=0 is coerced up to 1 (a retention
- window of zero would delete the backup the script just took, so the script refuses that
- rather than leaving you with nothing).
-
-
- If the SQLite online backup fails verification — the .backup command itself
- fails, the output file is empty, or its PRAGMA integrity_check doesn't come
- back ok — the script deletes the bad output, logs the failure, and — critically
- — skips the retention prune for the sqlite target on that run, so a broken
- backup can never push a known-good one out of the retained window. Postgres and Qdrant
- retention still run normally on that same pass, since only the SQLite leg failed. The run
- still exits non-zero so the failure is loud.
-
-
-
- PgBouncer pools connections between instances and Postgres. Each app instance still
- opens its own connection pool to whatever it's pointed at (PgBouncer or Postgres directly),
- shared by every HTTP handler and queue worker in that instance — set PGPOOL_MAX{" "}
- (default 10) if a single instance needs more headroom than that under real concurrency (many
- registered repos, higher QUEUE_CONCURRENCY). Raise it gradually and watch for{" "}
- LoopOverPostgresConnectionPressure: that alert means you're approaching
- Postgres's own max_connections, a different ceiling than this per-instance pool
- size.
-
-
-
One-time SQLite to Postgres copy
-
- Existing SQLite installs can copy state into a fresh Postgres database with the bundled
- migrator. It dry-runs by default and only commits when --execute is present.
-
- @pgbouncer:5432/loopover
-npm run selfhost:postgres:migrate -- --sqlite /data/loopover.sqlite
-npm run selfhost:postgres:migrate -- --sqlite /data/loopover.sqlite --execute`}
- />
-
-
Restore checks
-
-
Restore to a separate host or volume, never over the live instance first.
-
- Boot the app and confirm /ready returns 200.
-
-
Confirm migrations do not fail or reapply incorrectly.
-
Confirm recent review rows and job state are present.
-
-
-
Verify a backup is restorable
-
- The backup profile ships verify-backup.sh, which checks the newest
- backup without touching the live database: Postgres .dump archives with{" "}
- pg_restore --list, and SQLite .sqlite.gz backups with a gzip and{" "}
- integrity_check pass. Run it against the newest backup, or a specific file:
-
- .dump`}
- />
-
- A healthy run ends with [verify] postgres archive OK: … (N TOC entries) (or{" "}
- [verify] sqlite backup OK), then [verify] complete, and exits 0.
- Corruption, a missing backup, or an empty archive exits non-zero with a{" "}
- [verify] reason.
-
-
- To prove a dump actually restores, opt into a scratch restore into a throwaway{" "}
- database — never the live one:
-
-
-
- The scratch restore runs pg_restore --clean against{" "}
- LOOPOVER_VERIFY_SCRATCH_DATABASE_URL, so point it at a dedicated database you
- can afford to drop. The script refuses to run when that URL equals the live backup source.
-
-
-
Restore drill: what "restore-tested" actually verifies
-
- This exact flow has been run against a real production backup on a live instance: the dump
- was restored into a throwaway, network-isolated scratch database (a separate container,
- never the live one), which the script's own identity check confirmed was distinct from the
- backup source before touching anything. The restore completed cleanly and repopulated the
- application tables with representative production-scale data — not just an empty schema.
- Table and row counts will grow over time; treat restore-drill observations as point-in-time
- results, not invariants.
-
-
- This proves the backup content and the restore path both work end-to-end against real data.
- It deliberately stops short of booting a full app instance against the scratch database and
- polling /ready: that endpoint also gates on live Redis, Qdrant, the configured
- AI provider, Codex auth, and a real GitHub App key (see{" "}
- Operations's health endpoints section) —
- reproducing all of those for a disposable scratch instance would mean copying real
- credentials into new, throwaway infrastructure, which is a bigger risk than the drill is
- worth. This drill proves the dump can be restored and its contents inspected at the database
- layer — it does not exercise the app's own db readiness probe, migration boot
- path, or /ready response. A full disaster-recovery rehearsal still needs to
- verify app readiness on the target infrastructure, using the operator's own real
- credentials.
-
-
-
- After scaling, revisit Operations and{" "}
- Security because network and credential
- boundaries change.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-configuration.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-configuration.tsx
index 74164850a7..d282144c52 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-configuration.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-configuration.tsx
@@ -1,10 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
-import { SELFHOST_ENV_REFERENCE_MARKDOWN } from "@/lib/selfhost-env-reference";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-configuration.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-configuration")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-configuration"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host configuration — LoopOver docs" },
@@ -27,713 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-configuration")({
});
function SelfHostingConfiguration() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- This page is the exhaustive reference. For the short path — the required secrets plus a
- conservative first-boot config — start with .env.selfhost.example in{" "}
- Quickstart instead.
-
-
-
Config layers
-
-
-
Precedence
-
- Where policy for a given repo can live is one question; which layer wins when more than one
- is set is another. Most specific wins, in this order:
-
-
-
- the repo's .loopover.yml (public repo config, or the mounted private
- per-repo config file below if LOOPOVER_REPO_CONFIG_DIR is set), then
-
-
the per-repo database settings (the dashboard), then
-
built-in safe defaults.
-
-
- Within .loopover.yml itself, the typed gate: block is an alias for
- the gate-related fields and wins over the generic settings: block for those
- same fields — so a value written under both gate.duplicates and{" "}
- settings.duplicates resolves to whatever gate.duplicates says. One
- exception to the whole precedence chain: hard path guardrails (
- settings.hardGuardrailGlobs) are config-as-code only — a configured list ADDS
- repo-specific globs on top of a fixed set of built-in invariant guardrails (config-as-code
- files, CI workflows/scripts, and core engine-decision paths) that always apply and can never
- be disabled. Omitted or empty means only those built-in invariants hold, regardless of what
- the database row or defaults would otherwise imply.
-
-
- This page covers the environment layer and the shape of the config file. For the full field
- list — every gate: and settings: key, its default, and what it
- does — see Tuning your reviews, and for copy-paste templates
- see the table below (also shipped inside the self-host image at{" "}
- config/examples/).
-
-
-
Config templates
-
- Start from a template instead of reverse-engineering env flags, private-config precedence,
- and the parser. Every template uses the same schema for a public repo-root{" "}
- .loopover.yml or a container-private LOOPOVER_REPO_CONFIG_DIR{" "}
- mount — only what you put in each file differs.
-
-
-
-
- Keep anti-abuse thresholds, maintainer allowlists, and autonomy dials in the{" "}
- private mount — not in a public .loopover.yml contributors can
- read. config/examples/TEMPLATES.md documents the public-vs-private split and
- how to apply the templates to loopover, awesome-claude, and{" "}
- metagraphed without committing private policy. Lint before deploy:{" "}
- npx tsx scripts/loopover-config-lint.ts path/to/.loopover.yml.
-
-
-
- config/examples/README.md
- {" "}
- — the full private-config layout, precedence chain, and deep-merge semantics, including
- the shared base layer
-
-
-
- Several gate-only fields are documented only in the full template comments — see below for
- the config-as-code blocks with no dashboard equivalent.
-
- Any FOO_FILE is loaded into FOO at startup. Explicit{" "}
- FOO wins over the file variant.
-
-
- Every command example on these docs pages hardcodes :8787 — that's the
- default, not a fixed port. Set PORT to listen on something else; update your
- compose port mapping and any curl/health-check commands to match.
-
-
- LOOPOVER_MCP_TOKEN is a shared, end-user-obtainable CLI credential (the normal
- alternative to loopover-mcp login), so it must not implicitly stage actions
- (merges, closes, approvals) on every repo the App happens to be installed on.{" "}
- MCP_ACTUATION_REPO_ALLOWLIST scopes it to an explicit,
- comma/whitespace-separated owner/repo list —{" "}
- unset denies all actuation for this token. Set it to * or{" "}
- all to opt back into the pre-scoping, any-repo behavior. If you already rely on{" "}
- LOOPOVER_MCP_TOKEN for approval-queue actuation, set this variable after
- upgrading or MCP actuation stops working.
-
-
-
- MCP_READ_REPO_ALLOWLIST is the same fail-closed/wildcard model, kept as a{" "}
- separate allowlist so read-only MCP tools (repo context, issue quality,
- watch subscriptions) can be granted independently of actuation trust. The full{" "}
- */all wildcard additionally unlocks the non-repo-scoped
- contributor/operator tools.
-
-
-
Data paths
-
-
- MIGRATIONS_DIR (default migrations) — where the self-host
- runtime looks for SQL migration files to auto-apply at boot. Only relevant for a custom
- build that ships migrations somewhere other than the default in-image location.
-
-
- REVIEW_AUDIT_DIR — when set, persists visual-review screenshot PNGs to this
- filesystem path so they're served from cache instead of re-rendered on every request.
- Unset means each screenshot is re-rendered on demand. Only relevant when{" "}
- BROWSER_WS_ENDPOINT (see{" "}
- REES enrichment) is also set — visual review is
- fully inert without it.
-
-
- REVIEW_AUDIT_S3_BUCKET / _ENDPOINT / _ACCESS_KEY_ID{" "}
- / _SECRET_ACCESS_KEY — an alternative to REVIEW_AUDIT_DIR:
- persist screenshots in an S3-compatible bucket (your own Cloudflare R2 bucket, or any
- other S3-compatible provider) instead of the local filesystem, and set{" "}
- REVIEW_AUDIT_S3_PUBLIC_URL to that bucket’s own public base URL so
- screenshots link directly at the bucket instead of proxying through this instance. This
- matters if your instance sits behind a private network (a VPN, a firewall, no public DNS)
- — without a public bucket, screenshots embedded in a public PR comment are unreachable by
- GitHub and by anyone viewing the PR who isn’t on that same private network. Takes
- priority over REVIEW_AUDIT_DIR when both are set.
-
-
- CODEX_HOME — do not set this for the app container. The Codex provider
- rejects a container-set CODEX_HOME outright (fails closed with{" "}
- codex_credential_isolation_required) because codex exec reads
- attacker-controlled PR title/body/diff text, and a mounted OAuth home on the same
- filesystem could otherwise leak into review output via prompt injection. This is why the
- Codex subscription path additionally requires the explicit{" "}
- LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER=1 opt-in — see{" "}
- AI providers.
-
-
-
-
GitHub API cache
-
- Redis backs shared caching for stable GitHub GET responses, including repeated installation,
- repo/user metadata, and branch-protection required-status reads. Keys include the caller
- identity and response-shaping headers, and cold misses are single-flighted so concurrent
- jobs do not stampede GitHub.
-
-
-
- GITHUB_CACHE_TTL_SECONDS is the short default for repeated safe GitHub GETs.
- Stable repo/user metadata and branch-protection required-status reads use the per-class TTLs
- above so operators can keep repeated policy reads hot without broadening stale cache risk.
- Live CI status, check-run, check-suite, pull/issue subresources, pull mergeability, token
- minting, rate-limit, and collaborator-permission endpoints are never served from this cache.
- Prometheus exports loopover_github_response_cache_total, and the bundled
- self-host Grafana dashboard includes the hit/miss/coalesced/error breakdown.
-
-
-
Queue cadence and startup
-
- CRON_INTERVAL_MS (default 120000, ~2 minutes) is the tick that
- drives the maintain/sweep and sync cadence — contributor evidence, burden forecasts, RAG
- re-indexing, drift scans, and notifications all fan out from it.{" "}
- QUEUE_BACKGROUND_CONCURRENCY (default 1) caps how many
- low-priority background jobs may occupy a QUEUE_CONCURRENCY slot at once,
- independent of live webhook/review work.
-
-
- QUEUE_STARTUP_JITTER_MIN_JOBS (default 8) sets the pending-job
- count below which the queue skips its startup jitter delay — useful on a small instance
- where you'd rather a handful of jobs start processing immediately after boot than wait out a
- jitter window meant to stagger many instances restarting at once.
-
-
-
Maintenance and installation backpressure
-
- Two independent, opt-out admission checks run at queue-claim time, on top of GitHub
- rate-limit deferral, so background work never starves live PR review or overloads the host.
- Both grew out of real production incidents — an un-jittered cron enqueue and an unbounded
- per-installation background fan-out — and every value below is optional with a sane default.
-
-
-
- Tune MAINTENANCE_ADMISSION_MAX_LIVE_PENDING (default 5),{" "}
- MAINTENANCE_ADMISSION_MAX_LIVE_AGE_MS (default 120000),{" "}
- MAINTENANCE_ADMISSION_MAX_PENDING (default 15),{" "}
- MAINTENANCE_ADMISSION_MAX_HOST_LOAD (default 1.5, a 1-minute
- load-average-per-core ceiling), and{" "}
- MAINTENANCE_ADMISSION_MAX_BACKLOG_CONVERGENCE_PENDING (default 10)
- if you register many repos or run a busy instance and see maintenance sweeps lagging behind
- where you'd like. A denial backs off by MAINTENANCE_ADMISSION_DEFER_MS (default{" "}
- 180000, 3 minutes) before jitter, but two escape hatches stop a deferral from
- becoming a starve: MAINTENANCE_ADMISSION_MAX_DEFER_AGE_MS (default{" "}
- 14400000, 4 hours) force-admits any maintenance job that has waited this long
- regardless of pressure, and the shorter MAINTENANCE_ADMISSION_DRAIN_AGE_MS{" "}
- (default 600000, 10 minutes, clamped to the 4-hour ceiling) specifically drains
- the oldest jobs in a backed-up maintenance_pending_high lane so it can actually
- shrink instead of denying every claim for hours. Set{" "}
- MAINTENANCE_ADMISSION_ENABLED=false to fully disable the policy and return to
- the old always-run behavior.
-
-
- GITHUB_INSTALLATION_CONCURRENCY_LIMIT (default 2) is the per-
- installation ceiling; GITHUB_INSTALLATION_CONCURRENCY_DEFER_MS (default{" "}
- 15000, 15 seconds) is its base backoff before jitter. Raise the limit if a
- single large installation's background work is being throttled and you have GitHub
- rate-limit and host headroom to spare; set{" "}
- GITHUB_INSTALLATION_CONCURRENCY_ENABLED=false to disable the check entirely.
- This check only applies to background jobs that call GitHub — live PR review (
- github-webhook/agent-regate-pr) is never subject to it.
-
-
- Where the two backpressure checks above defer background work, foreground liveness protects
- live PR-review work FROM unbounded rate-limit deferral (its own worst case is up to ~65
- minutes per defer under sustained pressure, e.g. right after a deploy floods a shared REST
- budget). A periodic sweep force-releases any foreground-priority job that has genuinely
- waited past FOREGROUND_LIVENESS_MAX_DEFER_MS (default 600000, 10
- minutes), checked every FOREGROUND_LIVENESS_CHECK_INTERVAL_MS (default{" "}
- 60000, 1 minute — deliberately not the 1-second poll tick, so a job that is
- still genuinely rate-limited waits for the next sweep instead of busy-looping), releasing at
- most FOREGROUND_LIVENESS_MAX_RELEASE_PER_SWEEP jobs per tick (default{" "}
- 25, oldest first, so a large inherited backlog ramps up gradually instead of
- every released job re-tripping the same rate-limit bucket at once). It also runs once at
- boot, so a restart self-heals inherited over-deferral. Set{" "}
- FOREGROUND_LIVENESS_ENABLED=false to disable the sweep.
-
-
-
Tracing and telemetry env
-
- OTEL_EXPORTER_OTLP_ENDPOINT overrides the OpenTelemetry collector target only
- if you're routing to an external collector instead of the bundled one (default{" "}
- http://otel-collector:4318 under the observability profile).{" "}
- OTEL_SERVICE_NAME (default gittensory-selfhost) is the service
- name traces and metrics are tagged with — set a distinct value per instance if you run more
- than one and want to tell them apart in Grafana/Tempo. OTEL_TRACES_SAMPLER{" "}
- (default parentbased_traceidratio) picks the sampling strategy for app
- job/provider traces; pair it with OTEL_TRACES_SAMPLER_ARG (for example{" "}
- 0.05 to sample 5% of root traces).
-
-
-
Generated env reference
-
- This table is generated from process.env.NAME reads in{" "}
- src/selfhost/** and src/server.ts. It intentionally includes names
- and first source references only, never example values.
-
-
-
-
Repo activation — three layers
-
- Self-host docs and logs use "activation" for more than one mechanism. They stack
- independently:
-
-
-
- Preview before flipping: GET /v1/repos/:owner/:repo/activation-preview runs the
- deterministic advisory engine over recent cached PRs (no AI cost) and returns a{" "}
- recommendedAction of enable_advisory when the gate is still off.
-
-
-
Per-PR feature flags
-
- Most review capabilities need both their own flag and the repo in{" "}
- LOOPOVER_REVIEW_REPOS (unless a per-repo features: override says
- otherwise). This gives you a global kill switch and a per-repo rollout switch.
-
-
-
- Empty LOOPOVER_REVIEW_REPOS means no repos run the per-PR feature path,
- regardless of the individual flags.
-
-
-
Private per-repo config
-
- Mount a gitignored directory and point LOOPOVER_REPO_CONFIG_DIR at it. If
- either a per-repo file or the dir-root global default (.loopover.yml at the
- mount root) exists, the public repo .loopover.yml is never fetched for that
- review. With only one of the two present, its contents are used as-is; with both present,
- they are deep-merged — the per-repo file overlaid onto the global default, nested mappings
- merging key by key and arrays replacing wholesale.
-
-
-
-
- The features: block above overrides a deployment-wide{" "}
- LOOPOVER_REVIEW_* flag (rag, reputation, safety) for this one repo, with three
- states per key: true forces the capability on for this repo (still subject to
- the env flag itself being enabled — it can never turn on a capability the operator has fully
- disabled at the deployment level); false forces it off for this repo regardless
- of the env flag; and omitting the key entirely falls back to the{" "}
- LOOPOVER_REVIEW_REPOS allowlist default, i.e. today's behavior for an operator
- who hasn't set anything here. See Tuning your reviews for the
- full LOOPOVER_REVIEW_* flag list this overrides.
-
-
-
Config-as-code blocks with no dashboard equivalent
-
- Everything above has a dashboard row it mirrors. The fields below exist{" "}
- only in .loopover.yml — there is no DB column or dashboard
- toggle for them, so a self-host operator who never reads the example file may not know they
- exist.
-
-
gate.checkMode
-
- Controls only whether/how the required LoopOver Orb Review Agent check-run is
- published — it never affects gate evaluation, comments, labels, audit records, or autonomous
- merge/close, all of which run identically in every mode. Takes precedence over the legacy{" "}
- gate.enabled boolean when both are set.
-
-
-
- Before switching to disabled, remove LoopOver Orb Review Agent{" "}
- from this repo's branch-protection or ruleset required-status-checks list — LoopOver
- cannot do this on your behalf, and leaving it required with nothing to satisfy it means
- GitHub shows a pending status forever. Keep your real CI/Codecov/security checks required;
- this setting only ever affects LoopOver's own check-run.
-
-
- For a repo that has never been configured, the default is disabled; an
- already-configured repo keeps its current effective behavior. Self-hosters running
- high-volume autonomous review should prefer visible or disabled{" "}
- over required — LoopOver's own merge/close decisions never depend on this
- check either way.
-
-
-
Other gate-only fields
-
-
- gate.cla — sub-object for the CLA gate (gate.claMode, documented
- on Tuning your reviews): consentPhrase (a
- case-insensitive substring LoopOver looks for in the PR description),{" "}
- checkRunName (an existing CLA-bot check-run name that also satisfies
- consent), and checkRunAppSlug (the trusted App slug required to have produced
- that check-run, so a contributor-controlled same-name check can't satisfy a blocking
- legal gate). Either detection method is enough; both may be set. All default to{" "}
- null (not configured).
-
-
- gate.expectedCiContexts — CI check/status context names to treat as required
- when GitHub branch protection returns no readable required-status-checks (unconfigured, or
- a 403 from a token lacking administration:read — common for GitHub App
- installations, especially self-host). Merged with branch-protection contexts when both are
- readable; used alone when branch protection is null/empty. Default: not configured, which
- keeps the fold-all fail-closed behavior when branch protection is also unreadable.
-
-
- gate.premergeContentRecheck — when true, a PR touching{" "}
- migrations/** gets a fresh GitHub read of the base branch's current
- migration filenames immediately before an agent-driven merge, catching a different PR that
- merged a same-numbered migration in the meantime. A live collision holds the PR instead of
- merging blind. Default false — costs one extra GitHub API call per
- migrations-touching PR.
-
-
- gate.requireFreshRebaseWindow — when the base branch has advanced within this
- many minutes of the actual merge decision, forces an update_branch + fresh CI
- recheck before merging, instead of trusting a possibly-stale{" "}
- mergeable_state: clean read. A bounded retry cap prevents a fast-moving base
- from live-locking the PR. Default null (never force).
-
-
- gate.dryRun — when true, the posted check conclusion remains the
- real non-enforcing verdict while comments/check text may also show the would-be stricter
- verdict for AI-review blocker mode. It does not disable downstream merge/close planning
- for failures from already-enforced gates. Default false.
-
-
- gate.firstTimeContributorGrace — reserved and currently inert: parsed and
- stored, but the gate does not read it. A first-time contributor with a real blocker is
- one-shot closed the same as a repeat contributor. Kept for potential future use.
-
-
-
-
settings.closeOwnerAuthors and blockedPaths
-
- settings.closeOwnerAuthors — by default, the repo owner's own PRs (and{" "}
- ADMIN_GITHUB_LOGINS fleet-operator PRs) are never auto-closed; they may still
- auto-merge when clean and passing, or fall to a manual hold. Set true to make
- owner/admin-authored PRs eligible for auto-close like a contributor's, still gated by
- the close autonomy class and adverse-signal conditions. Automation-bot PRs stay exempt
- regardless of this setting. Default false.
-
-
- blockedPaths (top-level, alongside wantedPaths) is{" "}
- fully retired (#2974) — the FocusManifest parser no longer reads this key
- at all, it produces zero findings, and it is not enforceable under any{" "}
- gate.manifestPolicy mode. Setting it in a config produces only a migration
- warning from npm run selfhost:config-lint, nothing else.{" "}
- The only mechanism that actually holds a PR for a touched path is{" "}
- settings.hardGuardrailGlobs (config-as-code only, described above) — a
- would-merge PR that touches a configured guardrail glob is held for manual review.
-
-
-
settings anti-abuse block
-
- A cluster of contributor-abuse guardrails, all config-as-code only, all off/unset by
- default:
-
-
-
- Open-item caps — contributorOpenPrCap and{" "}
- contributorOpenIssueCap bound how many PRs/issues a single non-owner/
- non-admin/non-bot contributor may have open at once; a contributor's newest item
- above the cap is closed with a clear reason, their oldest items up to the cap stay open.
- Both are unset (no cap) by default. contributorCapLabel (default{" "}
- over-contributor-limit) is the label applied on close — set it to explicit{" "}
- null to close silently. contributorCapCancelCi cancels in-flight
- CI runs on a cap-triggered close (requires the actions: write App permission;
- degrades gracefully without it) and falls back to the{" "}
- CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var when unset.
-
-
- Review-nag cooldown — reviewNagPolicy (off/
- hold/close, default off) throttles a contributor
- who repeatedly pings @loopover for review on the same PR/issue, once they
- exceed reviewNagMaxPings (default 3) within{" "}
- reviewNagCooldownDays (default 5). reviewNagLabel{" "}
- (default review-nag-cooldown) is applied alongside the hold/close action.{" "}
- reviewNagMonitoredMentions extends the same cooldown to specific maintainer
- logins a contributor keeps tagging directly instead of (or in addition to){" "}
- @loopover.
-
-
- Exemptions and account age — autoCloseExemptLogins is a
- shared, repo-scoped list of logins never throttled or closed by these deterministic
- mechanisms, on top of the standing owner/admin/bot exemption.{" "}
- accountAgeThresholdDays (default null, off) applies{" "}
- newAccountLabel (default new-account) to a PR from a
- below-threshold-age account — friction/visibility only, never an automatic close on
- account age alone, and never for the owner, admins, or bots.
-
-
- Command rate limit — commandRateLimitPolicy (
- off/hold, default off) generalizes the review-nag
- pattern to every @loopover command, not just review-request pings.{" "}
- commandRateLimitMaxPerWindow (default 20) bounds cheap,
- cache-only commands; commandRateLimitAiMaxPerWindow (default 5)
- is the tighter limit for AI-cost-bearing commands (ask/blockers/preflight/etc.);{" "}
- commandRateLimitWindowHours (default 24) is the rolling window
- both limits count against.
-
-
-
-
contentLane
-
- Lets a self-hosted maintainer point LoopOver at their own structured registry (a
- subnet/plugin/package catalog, for example) without a LoopOver code change — reviewing
- additions to a data file the same way it reviews code. Unconfigured by default; uncomment
- and set at least entryFileGlob and collectionField (both required
- — the whole block is ignored with a warning if either is missing).
-
-
-
-
repoDocGeneration
-
- Lets LoopOver open a pull request that refreshes this repo's own AGENTS.md
- /CLAUDE.md (and, additively, a skill file) on a schedule — never a direct
- commit. Disabled by default: an unconfigured repo, or an explicit{" "}
- enabled: false, means no repo-doc refresh ever runs for it.
-
- SELFHOST_DEPLOYMENT_MODE forces write suppression for the whole instance,
- regardless of per-repo autonomy — useful for running a self-host in parallel with the live
- cloud App on the same webhooks, provably posting nothing until an explicit cutover.
-
-
-
-
Next steps
-
- Configure the GitHub integration in{" "}
- GitHub App and Orb, then add optional
- context through AI providers,{" "}
- REES, or{" "}
- RAG. For the full gate-mode and per-repo settings
- reference — including the AI-review combine modes and a complete worked manifest — see{" "}
- Tuning your reviews.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-docs-audit.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-docs-audit.tsx
index bdc24c9aa1..f6c3edb28a 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-docs-audit.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-docs-audit.tsx
@@ -1,15 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock } from "@/components/site/primitives";
-import {
- LOOSE_DOCS_ROWS,
- SELFHOST_DOCS_PAGES,
- SELFHOST_DOCS_VALIDATION_COMMANDS,
- SELFHOST_SOURCE_OF_TRUTH_ROWS,
-} from "@/lib/selfhost-docs-audit";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-docs-audit.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-docs-audit")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-docs-audit"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host docs accuracy audit — LoopOver docs" },
@@ -32,137 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-docs-audit")({
});
function SelfHostingDocsAudit() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- This page is the in-repo paper trail for the self-host docs audit under roadmap{" "}
- #1819. REES analyzer
- metadata generation is tracked separately on the REES roadmap — the analyzer reference page
- covers names and shapes; auto-generated metadata tables are out of scope here.
-
-
-
Website docs navigation
-
- Self-hosting docs live on the website under{" "}
- Self-hosted reviews. Every child page below
- is linked from that index and from the maintainer docs hub.
-
- When you change runtime behavior, update the docs page and extend the drift
- guard test when one exists. Env vars must stay aligned with{" "}
- npm run selfhost:env-reference; observability configs with{" "}
- npm run selfhost:validate-observability.
-
Defaults, optional services, and experimental surfaces
-
-
- Core stack (default): loopover + Redis + SQLite on the mounted data
- volume; SELFHOST_DEPLOYMENT_MODE=dry-run in{" "}
- .env.selfhost.example.
-
-
- Optional profiles: Postgres, REES sidecar, observability (Prometheus,
- Grafana, OTEL, Tempo, Loki), and backup — each documented on its concern page; none are
- required for a first healthy instance.
-
-
- Sentry: opt-in and off by default — set an operator-owned{" "}
- SENTRY_DSN or mount SENTRY_DSN_FILE; see{" "}
- Operations.
-
-
- AI / RAG / REES: off in the starter env until webhook delivery and
- deterministic review are verified; each has explicit enablement switches.
-
-
- Operator-owned paths:loopover-config/,{" "}
- loopover-data, and secrets via .env or *_FILE{" "}
- mounts — never baked into images.
-
-
-
-
Loose docs consolidation
-
- Root-level markdown outside the website must either link to the canonical website page or
- stay intentionally separate (convergence runbooks, contributor notes). Do not duplicate
- self-host operator instructions in a second place that will drift.
-
Run from the repo root before merging docs or cutting an orb release:
-
-
- Spot-check documented shell commands against the current docker-compose.yml{" "}
- profiles and release scripts when you touch operator-facing prose — CI drift guards cover
- the highest-risk surfaces but not every copy-pasted example.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-github-app.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-github-app.tsx
index f347a271b9..b041ef4da9 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-github-app.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-github-app.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-github-app.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-github-app")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-github-app"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host GitHub App and Orb — LoopOver docs" },
@@ -26,313 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-github-app")({
});
function SelfHostingGithubApp() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Choose a connection mode
-
-
- Direct App mode is the public default: it costs LoopOver nothing to support and can't
- overrun a shared rate-limit budget. Brokered mode routes every token mint through LoopOver's
- own infrastructure and GitHub API quota — every external brokered install is LoopOver's
- rate-limit and reliability problem, not just the operator's, so it stays
- private/managed-beta until the safeguards below are in place.
-
-
-
One-click App creation (recommended for a Direct App)
-
- Before the App exists (no GITHUB_APP_ID set yet), the self-host serves a setup
- wizard at GET /setup. It renders a form that POSTs a GitHub App{" "}
- manifest — the exact permission and event set below, pre-filled — to GitHub's own
- App-creation flow. GitHub creates the App with the correct configuration in one step and
- redirects back to exchange credentials automatically; there is no manual permission
- checklist to get right or wrong. The route is disabled once an App is configured, so it
- can't rebind a live install.
-
-
-
-
- Enter SELFHOST_SETUP_TOKEN in the browser form. For scripted setup checks, send
- the token in an x-setup-token header or Authorization: Bearer
- header instead; never place the setup token in the URL.
-
-
- https://reviews.example.com above is a placeholder — it assumes you already
- have a real domain terminating TLS. GitHub delivers webhooks to whatever{" "}
- PUBLIC_API_ORIGIN you set here, so it must be an address GitHub's servers can
- actually reach: the caddy profile (see{" "}
- Security's TLS termination section) is the
- shipped way to get one, or bring your own public reverse proxy. The tailscale{" "}
- profile's private tailnet address does not work here — GitHub cannot
- deliver webhooks to it. A Tailscale-only instance should use brokered pull mode instead (it
- polls for work rather than receiving pushed webhooks) — see "Pull vs. push relay mode"
- below.
-
-
- Manual App creation (below) is still fully supported — for an air-gapped instance, a
- stricter change-review process, or simply a preference for reviewing every permission by
- hand before it exists. Whichever path you take, the resulting App needs the SAME
- permissions: this doc's manual list is kept in sync with the wizard's manifest and checked
- in CI, so the two can't silently drift apart.
-
-
-
Direct App permissions
-
-
Pull requests: write.
-
- Checks: write — the gate posts a check-run; checks: read alone 403s that
- write (silently fails the first review with no obvious cause).
-
-
Issues: write.
-
- Contents: write — required for BOTH merging and the auto-maintain{" "}
- update_branch action. contents: read looks sufficient at
- creation time but silently breaks auto-merge later with no error surfaced in the UI; there
- is no lesser permission that keeps merge/update-branch working.
-
-
Commit statuses: read.
-
Metadata: read.
-
- Actions: write — lets a repo opt into cancelling a closed PR's in-flight CI runs (the{" "}
- contributorCapCancelCi setting). Off by default and never required: a repo
- that doesn't enable it, or an installation that hasn't re-approved this permission on an
- existing App, sees no behavior change — the cancellation attempt is skipped and logged,
- never blocking the close itself.
-
- A future release can widen this permission list (most recently, Actions: write for the
- opt-in CI-cancellation feature). GitHub does not silently grant a new
- permission to an App that's already installed — the operator who owns the App must
- explicitly re-approve it, the same one-time consent step as the original install.
-
-
- Until you re-approve, the self-host keeps working exactly as before: any feature that needs
- the new permission degrades gracefully (skipped and logged, never a hard failure) rather
- than erroring. There's no forced upgrade window.
-
- GitHub shows a diff between the App's currently-granted permissions and what the App
- manifest now requests. Review it, then save — GitHub sends the installation owner a
- request to accept the new grant.
-
-
- Accept the request (as the installation owner, on each installed org/account). The new
- permission takes effect immediately; no App reinstall or webhook resubscription needed.
-
-
-
-
Direct App env
- `}
- />
-
-
Telemetry is separate from token brokerage
-
- These are two independent things people conflate because they're both "Orb": anonymized
- fleet-calibration telemetry export (enabled by default, works in either
- connection mode) and token brokerage (optional, private/managed-beta only,
- lets your self-host get installation tokens from LoopOver instead of holding its own App
- key). Choosing Direct App mode does not opt you out of telemetry, and it's what makes{" "}
- the homepage counters and cross-fleet gate calibration reflect direct
- installs, not just brokered ones.
-
-
-
- Repo/PR identifiers are HMAC-anonymized by default (
- ORB_ANONYMIZE=true), not unconditionally — an operator can set{" "}
- ORB_ANONYMIZE=false to export raw repo/PR names instead. There's no scenario
- where LoopOver's own hosted collector needs raw names; the toggle exists for an operator
- running their own collector (see ORB_COLLECTOR_URL below) who
- wants readable identifiers in their own infrastructure. Leave this at the default unless you
- control the collector end.
-
-
- ORB_COLLECTOR_URL overrides the export endpoint — default LoopOver's hosted
- collector, or point it at your own private collector if you're aggregating telemetry
- yourself instead of sending it to LoopOver. ORB_COLLECTOR_TOKEN is the bearer
- credential for that private collector; leave it unset when using LoopOver's own hosted
- collector, which accepts unauthenticated, rate-limited, aggregate-only exports.
-
-
-
Brokered Orb env
-
-ORB_BROKER_URL=https://api.loopover.ai
-ORB_RELAY_MODE=pull # or omit for push (the default) -- see "Choosing a relay mode" below`}
- />
-
- ORB_APP_ID overrides the seed used to derive this instance's stable,
- anonymous instance_id in telemetry exports — normally derived from{" "}
- GITHUB_APP_ID. A brokered instance holds no App ID of its own (it uses the
- broker's tokens instead), so its identity falls back to the export secret unless you
- set ORB_APP_ID explicitly. Most operators never need to set this; it exists so
- a brokered instance's telemetry identity can be pinned independent of any App ID.
-
-
-
Choosing a relay mode: pull vs. push
-
- Brokered mode still needs a way for GitHub webhook events to reach your self-host through
- the broker. ORB_RELAY_MODE picks how:
-
-
-
- If you're not behind a stable public ingress — a home connection, a NAT without port
- forwarding, a tailnet-only deployment — set ORB_RELAY_MODE=pull. It needs no
- DNS record, TLS certificate, or firewall rule of its own, and tolerates a transient broker
- outage more gracefully (see the release checklist's known-warnings table below). Use push
- only once you already have a stable, publicly reachable HTTPS origin for this instance — the
- Direct App setup wizard, for instance, always requires one anyway, so an operator running
- Direct App today has it available for brokered push mode too. See{" "}
- Security's TLS termination section for how to
- stand one up: the caddy profile for a public domain, or note that{" "}
- tailscale's private tailnet address does not satisfy push mode's
- internet-reachable requirement — pull mode is the right fit for a Tailscale-only instance.
-
-
- Before enabling this for anyone outside a controlled managed-beta cohort, weigh: (1){" "}
- rate-limit blast radius — every brokered install's GitHub API traffic draws
- from token pools LoopOver manages, so one misbehaving or high-volume install can degrade
- every other brokered install; (2) quota management — there is no automatic
- per-install cap on how much of that shared budget one enrollment can consume; (3){" "}
- support burden — a broken brokered install looks like a LoopOver outage to
- its operator, not a self-host misconfiguration, and lands as a support request on LoopOver
- directly; (4) abuse/misconfiguration risk — an enrollment secret that leaks
- or a misconfigured relay can mint tokens or receive webhook traffic for repos the intended
- operator doesn't control.
-
-
-
Minimum broker safeguards before a public rollout
-
- A maintainer go/no-go checklist — do not open brokered enrollment beyond a small, known,
- controlled cohort until every item below is true:
-
-
-
- Enrollment quota — a hard cap on how many brokered installs can be active
- at once, not just an informal agreement.
-
-
- Per-install concurrency limit — one brokered install cannot occupy an
- unbounded share of the token-minting or webhook-relay pipeline.
-
-
- Per-install rate budget — a ceiling on GitHub API calls attributable to a
- single enrollment, independent of the other installs sharing the broker.
-
-
- Revocation path — an enrollment secret can be revoked immediately,
- without waiting for a deploy, when it's compromised or the install is abusive.
-
-
- Metrics broken out by enrollment — token-mint volume, webhook-relay
- volume, and error rate are visible per-enrollment, not only aggregated across every
- brokered install, so one bad actor is identifiable instead of hiding in the average.
-
-
-
- See Troubleshooting for what a degraded
- brokered relay looks like in logs today, and{" "}
- the release checklist's brokered-mode
- scenario for the smoke tests that exercise both relay modes.
-
-
-
Connectivity checks
-
- Confirm you can reach the instance at all before checking GitHub's own webhook delivery:
-
-
-
- reviews.example.com here stands in for whatever you're checking from — the{" "}
- caddy profile's domain, an existing reverse proxy, or (if you're on the same
- tailnet) a Tailscale instance's tailnet address on port 8787. This only confirms{" "}
- you can reach the instance, not that GitHub can — a Tailscale-only
- instance in push mode will pass this check and still never receive a real webhook, since
- GitHub itself cannot reach a private tailnet address (see the callout above on{" "}
- PUBLIC_API_ORIGIN).
-
-
- After installing the App on a test repo, open a small PR and confirm the webhook delivery
- appears in GitHub and a job appears in self-host logs — this is the check that actually
- proves GitHub can reach you. Continue with{" "}
- Operations for log and metric checks.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-operations.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-operations.tsx
index b5cebcec0e..9c960ad694 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-operations.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-operations.tsx
@@ -1,10 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
-import { AmsObservabilityCallout } from "@/components/site/ams-observability-callout";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-operations.mdx via fumadocs-mdx's browser
+// entry (docsClientLoader), through the existing DocsPage/Callout/CodeBlock
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-operations")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-operations"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host operations — LoopOver docs" },
@@ -27,1302 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-operations")({
});
export function SelfHostingOperations() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Health endpoints
-
-
-
Useful commands
-
-
-
Important log events
-
-
-
Observability profile
-
- The observability profile starts Prometheus, Alertmanager, Loki, Promtail, and Grafana with
- dashboards for infra, review activity, and AI usage.
-
-
- Postgres installs also expose database internals through the bundled Postgres exporter:
- connection pressure, lock waits, long transactions, deadlocks, database/table growth, dead
- tuples, autovacuum activity, and backup freshness. Backup freshness appears when the{" "}
- backup profile is active.
-
-
- When OpenTelemetry and Sentry are enabled, job audit logs and Sentry events include
- trace_id/span_id fields so an operator can jump from a failed job or issue to the matching
- trace in Grafana or Tempo.
-
-
-
-
-
Host clock sync (NTP)
-
- GitHub App JWTs are signed with a timestamp from this process's clock, backdated 60 seconds
- for skew tolerance. If the host clock drifts past that margin, GitHub starts rejecting the
- JWT as not-yet-valid — every GitHub App request fails with a generic{" "}
- Bad credentials error, with no obvious link back to the clock. Configure at
- least two independent NTP sources on the host (not just in the container) so a single dead
- source can't silently take the whole clock out from under you.
-
-
- Check sync health with chronyc sources (or ntpq -p on an{" "}
- ntpd host) — every configured source should show a nonzero Reach{" "}
- value; Reach: 0 means that source has never successfully synced. The{" "}
- loopover_clock_skew_seconds gauge on the Clock Sync (NTP) row
- of the main Grafana dashboard tracks the live drift between this process and GitHub's server
- time, sampled from the Date header of the GitHub App's own installation-token
- mint calls — no extra network probe required. The bundled Prometheus rules alert at 60s
- (warning) and 120s (critical) drift, both well under the margin that actually breaks JWT
- auth.
-
-
-
Alerting — required for a 24/7 deployment
-
- Alertmanager ships with a valid but silent default: every alert routes to a
- name-only receiver that discards it, so{" "}
- docker compose --profile observability up -d always starts clean even before
- you've configured anywhere to send notifications. This is intentional — the shipped config
- can't bake in a Slack/Discord/email destination that works for everyone — but it means
- nothing pages anyone until you enable a real receiver. Treat this as a required step, not an
- optional one, for any deployment you expect to run unattended.
-
-
- Don't edit the committed alertmanager/alertmanager.yml in place — deploys
- git pull this repo, so a local edit to a tracked file either blocks the next
- pull or gets silently overwritten by it. Instead, copy it to a gitignored{" "}
- alertmanager/alertmanager.local (matches the existing *.local{" "}
- ignore rule) and make your receiver/route changes there — the fastest verified path is
- uncommenting the discord receiver block and pointing the root route at it,
- using webhook_url_file: /etc/alertmanager/discord_url so the webhook URL itself
- lives in its own gitignored file next to it, never in a file docker-compose.yml or git ever
- tracks. Slack, email, and a generic webhook receiver (for PagerDuty or a custom handler) are
- also ready to uncomment in the same template. Then point Alertmanager at your local copy via{" "}
- docker-compose.override.yml:
-
-
-
- Restart with docker compose up -d --no-deps alertmanager to pick up both files.
- The whole alertmanager/ directory is mounted read-only into the container, so
- any gitignored file you add there (the local config, a secret file it references) shows up
- at the same path with no docker-compose.yml edit required.
-
-
- Until you do, alerts are still visible without any extra setup: open Grafana and check the{" "}
- Alerts row on the main dashboard, which lists every currently-firing alert
- directly from Prometheus, independent of Alertmanager routing. Use this as your fallback
- check if you haven't wired up push notifications yet — it's exactly what the{" "}
- Dead jobs stay at zero routine check below is watching for.
-
-
- Dead-lettered jobs also get one automatic revival attempt every 30 minutes (
- QUEUE_DEAD_LETTER_REVIVE_INTERVAL_MS), as long as the job hasn't already been
- revived more than a small, bounded number of extra times (
- QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS, default 3) — so a job that
- died from a bug that's since been fixed and redeployed recovers on its own within the next
- cycle, without needing direct database access. A job that keeps failing the same way
- eventually exhausts this budget and stays dead, which is exactly what the alert above is
- watching for.
-
-
-
Two different Discord/Slack integrations
-
- Don't confuse these — they're unrelated features that happen to share the same two chat
- platforms:
-
-
-
- DISCORD_WEBHOOK_URL is a global fallback Discord channel for any repo without
- its own webhook. DISCORD_REPO_WEBHOOKS is a per-repo override — a JSON map of{" "}
- owner/repo to a webhook URL — for routing different repos' notifications to
- different channels. Both are unset (no Discord notifications) by default.
-
-
-
- SLACK_WEBHOOK_URL posts the same per-action events (merged/closed/manual) as a
- Block Kit section to one Slack channel. Unlike Discord there is no per-repo map today —
- every repo shares this one webhook. Unset means no Slack notifications.
-
-
-
Resource profiles
-
- Measured rows below come from a real production instance running the full
- profile set (qdrant + redis + observability +{" "}
- backup + postgres + ollama) at steady state —
- docker stats and docker system df snapshots, not a lab benchmark.
- Estimated rows are reasoned from that same baseline plus each
- service's declared deploy.resources.limits and image size in{" "}
- docker-compose.yml — they have not been measured directly and could be off,
- especially for CPU under real load. Treat estimates as a starting point for capacity
- planning, not a guarantee.
-
-
-
-
-
-
Profile
-
CPU (steady state)
-
Memory (steady state)
-
Basis
-
-
-
-
-
- Minimal — app + redis only (no profile flags)
-
-
~3% of one core
-
~400–600MiB
-
- Estimated: app + redis measured in isolation from the full-profile snapshot (app
- 2.6% CPU / 365MiB; redis is idle-light and its 512MiB limit is never approached in
- the full-profile run either).
-
-
-
-
- + --profile postgres
-
-
- +14% of one core (highest single-service CPU consumer)
-
-
+~200MiB
-
- Measured: 14.24% CPU / 196MiB of its 2GiB limit — comfortable headroom on memory,
- but the largest CPU line item in the whole stack.
-
-
-
-
- + --profile qdrant
-
-
Low single-digit %
-
- Well under its 2GiB limit
-
-
- Measured (part of the full-profile snapshot's "everything else" low-CPU, under-limit
- group). Grows with RAG corpus size — expect this to climb on installs with many
- indexed repos.
-
-
-
-
- + --profile observability
-
-
- Low single-digit % per service, except Grafana/Tempo below
-
-
- Grafana ~305MiB (60% of 512MiB); Tempo ~209MiB (20% of 1GiB); Prometheus/Loki/
- Alertmanager/Promtail/otel-collector each well under their limits
-
-
- Measured. Grafana is the closest any service comes to its ceiling in production —
- worth watching if you add many custom dashboards or panels, but not currently a
- problem (40% headroom remains).
-
-
-
-
- + --profile ollama
-
-
- Near-zero idle; spikes hard during inference
-
-
- Model-dependent, up to its 8GiB limit
-
-
- Estimated. Not part of the live production profile mix (that instance uses{" "}
- AI_PROVIDER=codex, not Ollama) — the 8GiB default limit is sized for a
- single loaded 7–8B quantized model per the compose comment, not measured against a
- running model. Idle Ollama with no model pulled is cheap; a loaded model can
- legitimately approach the limit, which is why it has the largest default ceiling in
- the file.
-
-
-
-
- + --profile gpu
-
-
Near-zero
-
- Tens of MiB — a single Go binary shelling out to nvidia-smi
-
-
- Estimated. Adds gpu-exporter (nvidia_gpu_exporter) feeding the{" "}
- gpu Prometheus job and the GPU metrics Grafana dashboard — requires the
- NVIDIA Container Toolkit on the host; a device reservation only takes effect once
- this profile is activated, so a non-GPU host is unaffected either way.
-
-
-
-
- + --profile backup
-
-
- Near-zero except during runs
-
-
- Low, bursts during dump/restore
-
-
- Measured as part of the full-profile snapshot (no dedicated resource limit is set
- for backup/backup-exporter — both are short-lived or
- idle-polling processes, not sustained consumers).
-
-
-
-
- + --profile runners
-
-
- Unbounded by default — can still starve the app under CI load
-
-
- Bounded by RUNNER_MEM_LIMIT (default 2g) per replica
-
-
- Estimated, and explicitly a known risk on the CPU side, not a guess about typical
- usage: the runner service ships with a default memory ceiling (
- RUNNER_MEM_LIMIT, default 2g, added by #3893) but no CPU limit.
- Production experience already documented in{" "}
- docker-compose.override.yml.example found 3 uncapped runner containers
- starving the app for CPU on an 8-vCPU box under real CI load — see that file for the{" "}
- cpu_shares/cpus mitigation before co-locating runners with
- the review stack.
-
-
-
-
- Full profile set (qdrant + redis +{" "}
- observability + backup + postgres +{" "}
- ollama, no active inference, no runners)
-
- No service near its limit except Grafana (~60%)
-
-
- Measured, in full, on a real production instance.
-
-
-
-
-
-
-
Disk
-
- Measured on the same production instance: 48GB of 151GB used on the host root volume (32%)
- at steady state. docker system df breakdown:
-
-
-
- The reclaimable image and build-cache space here is{" "}
- expected steady state, not a leak — this instance runs{" "}
- scripts/deploy-selfhost-prebuilt.sh, which rebuilds the image from the current
- git checkout on every deploy and intentionally keeps prior layers around in the build cache
- for faster rebuilds. The loopover-docker-prune systemd timer (below) already
- runs daily against this exact instance and reclaims it on a schedule, so this is not a
- number to chase down manually.
-
-
-
When a compose default might need to change
-
- Every deploy.resources.limits.memory in docker-compose.yml is
- operator-overridable via .env (see the *_MEM_LIMIT variables in{" "}
- .env.example). Against the measured full-profile data above, none of the
- current defaults look miscalibrated enough to change: nothing sits consistently near its
- limit in a way that risks an OOM kill under normal load (Grafana's ~60% is the closest
- and still has real headroom), and nothing is so oversized relative to plausible usage that
- it should be lowered — including Ollama's comparatively large 8GiB ceiling, which is
- sized for holding one quantized model in memory, not idle overhead. The one real gap is{" "}
- --profile runners's CPU side: the service has a default memory ceiling (
- RUNNER_MEM_LIMIT, default 2g) but ships with no CPU limit at all; that is a
- known, documented tradeoff (see the table above and{" "}
- docker-compose.override.yml.example) rather than an oversight, since the right
- CPU ceiling depends entirely on the host's core count and how many runner replicas you run.
-
-
-
Capacity planning: how much disk for N repos at M PRs/month
-
- The 151GB host above is one measured point, not a formula. It says nothing about how disk
- use grows as you register more repos or review more pull requests — for that you have to
- reason about which tables and volumes actually grow with activity, versus which are fixed
- overhead. Treat every number below as an order-of-magnitude estimate to plan around, not a
- guarantee.
-
-
-
- Putting it together: for a small install (a handful of repos, tens of PRs/month), all of
- this is noise against the ~20GB of fixed Docker/image/volume overhead measured above — you
- will not notice review_audit or webhook_events growth for a long time. The estimate gets
- real at higher volume: an install running hundreds of PRs/month across dozens of repos, left
- unattended for a year or more, is a plausible case where the unbounded tables above (and the
- backups that multiply them) become the dominant long-term disk driver rather than Docker
- images and build cache. There is no first-party tool yet to prune review_audit or
- webhook_events — if you operate at that scale, monitor their row counts directly (
- SELECT count(*) FROM review_audit,{" "}
- SELECT count(*) FROM webhook_events) rather than assuming steady state.
-
-
-
Docker resource hygiene
-
- Every service in docker-compose.yml caps its own container logs (10MB × 3
- rotated files) out of the box, so log growth alone won't fill your disk. Unused Docker
- images and build cache are a separate, larger disk-growth vector on a host that rebuilds or
- pulls images repeatedly over months — Docker does not reclaim either automatically.
-
-
- Install the provided host-level timer to reclaim both on a schedule (anything unused for
- less than 7 days is left alone, so a recent deploy is never at risk):
-
-
-
- Run it manually at any time with docker system df before and after to see what
- it reclaimed: sh scripts/selfhost-docker-prune.sh.
-
-
- This should always prune containers, images, and build cache — never
- volumes. Pruning a volume deletes real application state (the database, backups, vector
- index, or a runner's registration and job data), not disposable build output, so it is
- never part of routine cleanup unless you intentionally want to delete that state.
-
-
-
Self-hosted runner temp storage
-
- If you run --profile runners, keep every runner job's scratch/temp writes
- on the mounted runner-work volume, never the container's plain{" "}
- /tmp. A container's own /tmp lives in Docker's
- overlay/containerd snapshot storage — a CI job that writes high-volume temp data there
- (language toolchain caches, build artifacts, ad hoc mktemp calls) grows the
- host's Docker root storage directly, not the volume, so it is invisible to
- volume-scoped cleanup and can fill the disk out from under the whole stack. The shipped{" "}
- runner service points TMPDIR, TMP, and{" "}
- TEMP at /tmp/runner/tmp (a subdirectory of the mounted{" "}
- runner-work volume) and keeps RUNNER_WORKDIR at{" "}
- /tmp/runner on the same volume. A one-shot runner-tmp-init service
- creates that directory on the volume (and makes it world-writable, matching real{" "}
- /tmp permissions) before the runner container starts, so this works out of the
- box on a fresh volume with no manual steps.
-
-
- Adding a second or third runner service in docker-compose.override.yml for
- higher CI throughput? Each one needs its own runner-work-style volume, its own
- init step, and the same temp env — YAML anchors don't cross separate compose files, so
- repeat the extension block in your override file:
-
-
-
-
Enabling Sentry (your own DSN)
-
- Sentry is opt-in and off by default. Leave SENTRY_DSN unset
- for a complete no-op with negligible overhead — no events leave your box. When you want
- error tracking, point the runtime at a project you control in your own Sentry organization.
-
-
-
- Official release images bake LOOPOVER_VERSION as the default release id;
- override with SENTRY_RELEASE when you tag custom builds. Mount secrets with{" "}
- SENTRY_DSN_FILE instead of inline env when you prefer a file-backed DSN. After
- changing Sentry env, restart the loopover service — there is no hot reload.
-
-
- Community self-hosters should send events only to their own DSN. The shipped stack never
- phones home to a maintainer-owned project unless you configure one.
-
-
-
Browser Sentry (operator UI)
-
- The operator UI (apps/loopover-ui) has its own, separate client-side Sentry
- integration for route errors, unhandled browser exceptions, and failed app-level resource
- loads — independent of the backend's SENTRY_DSN above.{" "}
- Opt-in and off by default: leave VITE_SENTRY_DSN unset for a
- complete no-op — the SDK is never even fetched by the browser. Session Replay is not
- enabled.
-
- `}
- />
-
- Every browser event is scrubbed before it leaves the box: request cookies, headers, and body
- data are stripped outright; secret-shaped keys and values (tokens, bearer headers, JWTs) are
- redacted recursively; local filesystem paths are replaced with a placeholder; and{" "}
- user is always dropped — no PII is ever sent. Tags stay a small,
- low-cardinality set: route (pathname only), release,{" "}
- environment, and app_surface.
-
-
- The UI's production build/deploy runs through Cloudflare's own Workers Build git
- integration, not GitHub Actions, so VITE_SENTRY_DSN/
- VITE_SENTRY_RELEASE are configured as Cloudflare build environment variables,
- not repo secrets. Source maps are never produced by that regular build or served publicly —
- the .github/workflows/ui-sentry-release.yml workflow (behind the same
- maintainer-only release environment gate as the Orb image release) does an
- independent, never-deployed build with source maps enabled and uploads them to Sentry as a
- release artifact whenever apps/loopover-ui changes on main.
-
-
-
Sentry context taxonomy
-
- Self-host Sentry events carry a small, scrubbed taxonomy so operators can filter by
- subsystem without opening raw payloads. Structured error logs forwarded from{" "}
- console.error use the JSON event slug as the issue type; direct
- captures use a kind or review operation tag instead.
-
-
-
- Cron monitor slugs follow{" "}
- gittensory-selfhost-{environment}-{loop} (for example{" "}
- gittensory-selfhost-production-scheduled-loop). Pair monitor alerts with queue
- depth, dead-job counts, and the matching structured log event.
-
-
-
Sentry alert classes and runbook
-
- Tune Sentry alert rules for persistent failure classes, not one-off
- fail-open noise. The table below lists actionable signals, what they usually mean, and the
- first checks an operator should run. Prometheus/Grafana alerts in the observability profile
- cover the same failure modes from a metrics angle — use both when Sentry is enabled.
-
- Page when backup freshness >2× BACKUP_INTERVAL_SECONDS or verify
- script fails twice
-
-
- Inspect docker compose logs backup, disk space, and{" "}
- backup docs; do not delete the
- last good backup after a failed run
-
-
-
-
Scheduled monitor misses
-
- Sentry monitor alert on scheduled-loop, orb-export, or
- other wrapped loops
-
-
- Use Sentry's built-in monitor failure thresholds (2 consecutive misses on most
- loops)
-
-
- Process may still be alive but cron work stopped — check{" "}
- selfhost_cron_error, queue pump logs, and restart the app container if
- the loop crashed without taking down the process
-
-
-
-
-
-
-
Sentry server name
-
- SENTRY_SERVER_NAME sets a clean, human name for this instance in Sentry (for
- example loopover-us-east). Unset defaults to the OS hostname — never the
- public-origin URL. Set this explicitly if you run more than one instance and want to tell
- their Sentry events apart at a glance instead of matching container hostnames.
-
-
-
Sentry tracing
-
- Leave SENTRY_TRACES_SAMPLE_RATE unset or blank to disable trace export, or set
- a positive sample rate such as 0.05 to send sampled review spans to Sentry. The
- custom OpenTelemetry provider installs Sentry hooks for review-stage spans carrying repo,
- PR, operation, outcome, and hashed installation tags.
-
-
Sentry cron monitors
-
- When SENTRY_DSN is set, the self-host runtime emits Sentry monitor check-ins
- for the recurring loops where silent stoppage matters most. Leaving SENTRY_DSN{" "}
- unset keeps monitor reporting off.
-
-
-
- Monitor loop slugs (the {loop} segment in the slug) are{" "}
- scheduled-loop, orb-export, orb-relay-drain,{" "}
- orb-relay-register, and queue-dead-letter-revive. A missed monitor
- means the process may still be alive but the recurring work is not checking in on schedule.
- Pair the monitor with queue depth, dead-job counts, and the structured error log for the
- same subsystem.
-
-
-
Grafana Sentry data source (in-Grafana issue visualization)
-
- Query recent Sentry issues, top issues by event count, and error-volume trend directly in
- Grafana — no more switching tabs to check Sentry, and errors line up in time with the rest
- of the stack's metrics/logs/traces. This is read-only visualization; alert routing to
- Sentry/Discord/Slack is a separate, unrelated concern covered above.
-
-
- The SENTRY_DSN above authenticates event ingestion (sending
- errors to Sentry), not the read/query API this data source needs. You need a separate{" "}
- Sentry Internal Integration token: Sentry → Settings → Developer Settings →
- Custom Integrations → New Internal Integration (requires an Admin/Manager/Owner role in
- Sentry), with Read access on the Project,{" "}
- Issue & Event, and Organization resource scopes.
-
-
- The{" "}
-
- grafana-sentry-datasource
- {" "}
- plugin installs automatically (GF_INSTALL_PLUGINS, same mechanism as the GitHub
- data source below). Add the data source itself after Grafana is up — a backend datasource
- whose token isn't ready at Grafana's own boot time would crash file-based
- provisioning, so this one is added over the API instead, exactly like the GitHub data
- source:
-
-
-SENTRY_ORG_SLUG=
-# SENTRY_API_URL=https://sentry.io # override only for a self-hosted Sentry instance`}
- />
-
-
- The script is idempotent — safe to re-run after rotating the token. Open the{" "}
- Sentry issues dashboard once it succeeds. Same trade-off as the GitHub data
- source: this one is API-managed, so it stays editable via the Grafana UI rather than locked
- read-only like the file-provisioned data sources.
-
-
-
Re-gate sweeps (agent-regate-sweep)
-
- Live PR review is webhook-driven, but open PRs still need periodic re-evaluation — the base
- branch moves, duplicate clusters resolve, settings change, and approved PRs can sit unmerged
- until CI re-runs. A scheduled sweep (agent-regate-sweep, every ~2 minutes on
- the maintenance tick) fans out lightweight agent-regate-pr jobs for the stalest
- open PRs per repo (cap SWEEP_MAX_PRS=3 by default, REST-budget sized).
-
-
-
- Log markers: regate_sweep_throttled (sweep temporarily paused),{" "}
- regate_sweep_trigger_backlog_deferred (prior regate work still draining —
- avoids piling duplicate fan-outs). In metrics, break down deferrals by{" "}
- job_type=agent-regate-pr or agent-regate-sweep when GitHub
- rate-limit pressure spikes.
-
-
-
Routine checks
-
-
Queue pending count is not growing without processing.
-
Dead jobs stay at zero or are investigated promptly.
-
Webhook deliveries are recent and have 2xx responses, with no enqueue failures.
-
AI usage matches expected review volume and model/effort choices.
-
REES and RAG failures are visible and bounded.
-
- Postgres connections, lock waits, slow transactions, dead tuples, and table growth are
- stable.
-
-
Backups are recent and restore-tested.
-
-
-
Updating and rolling back
-
- Day-two operator flow: pull or build a new app image, restart only the loopover{" "}
- service, verify /ready, and confirm the release id. Use{" "}
- Releases and images to pick a tag; use the
- checklists below so updates never overwrite operator-owned secrets, config, or data.
-
-
-
-
-
- .env and any *_FILE secret mounts — deploy scripts only write
- back LOOPOVER_IMAGE (image path) or SENTRY_RELEASE /{" "}
- LOOPOVER_VERSION (source path).
-
- Named data volumes — especially loopover-data (SQLite DB, Codex/Claude auth
- under /data), loopover-pg, qdrant-data,{" "}
- loopover-backups, and Grafana's grafana-data.
-
-
- Optional docker-compose.override.yml — still loaded via{" "}
- SELFHOST_COMPOSE_FILES when set, or automatically when present beside{" "}
- docker-compose.yml.
-
-
-
-
-
-
-
Preflight checklist
-
-
- Read release notes for migration or env changes — migrations are forward-only (see
- Rollback below).
-
-
- Take a fresh backup when the release may change schema — see{" "}
- Backup and scaling.
-
-
- Source path only: confirm git status is clean (no uncommitted local changes
- the build would silently pick up). An ad-hoc snapshot like{" "}
- cp docker-compose.yml docker-compose.yml.bak-notes-20260707 does not count
- against this — the trailing *.bak-*/*.backup-* patterns in{" "}
- .gitignore keep stray manual backups out of git status entirely,
- on top of the narrower loopover-config.backup-*/ and{" "}
- .deploy-backups/ patterns that already covered those specific directories.
- scripts/selfhost-update.sh (below) checks this for you and refuses to
- continue on a dirty tree.
-
-
- Image path only: note the current tag or digest from docker inspect on the
- running loopover container so rollback has a known-good target.
-
-
- Confirm routine health is green before you start —{" "}
- curl http://localhost:8787/ready and a quick docker compose ps.
-
-
-
-
Path 1: pull a published image
-
- scripts/deploy-selfhost-image.sh pulls a tag or digest, restarts only the{" "}
- loopover service, waits for it to report healthy via{" "}
- docker inspect's health status (configurable timeout, default 180s), and
- then persists the resolved image reference back to LOOPOVER_IMAGE in{" "}
- .env so the next plain invocation reuses it.
-
-
-
- ghcr.io/jsonbored/gittensory-selfhost (the pre-rename name) is no longer
- published to, but an existing pin to a specific tag or digest under it keeps resolving.
-
-
- The pull always runs with --policy always, so re-running the script against an
- unchanged tag is safe: if the registry has nothing new, it just restarts the same image and
- the health-check wait passes immediately.
-
-
-
Path 2: build from the current git checkout
-
- scripts/selfhost-update.sh is the recommended entry point for a Git-backed
- source checkout (#1660) — it is the single command that turns git fetch +
- fast-forward + rebuild + verify into one flow, instead of an operator having to remember the
- right order:
-
-
-
- It refuses to continue, with a clear error and no side effects, on any of the three things
- that make a plain git pull unsafe to script blindly: the working tree is not
- clean, the checkout is not on the expected branch (main by default), or local
- history has diverged from origin/main in a way that is not a fast-forward (
- git merge --ff-only — it never rebases, force-merges, or picks a side for you).
- Only once the fast-forward succeeds does it call{" "}
- scripts/deploy-selfhost-prebuilt.sh (below) to rebuild and restart, then{" "}
- scripts/selfhost-post-update-check.sh to verify health — so a normal update is
- one command and a failure at any step stops before the next one runs.
-
-
- None of this touches operator-owned state: .env, the{" "}
- loopover-config/ mount, .deploy-backups/, any *.local{" "}
- or docker-compose.local-*.yml compose override, or Alertmanager file, and every
- named data volume are already gitignored or outside the source tree entirely, so a
- fetch-and-rebuild never touches them. See the{" "}
- Quickstart for the initial clone; this
- script assumes that checkout already exists and already tracks origin/main.
-
-
-
- Want finer control — a pinned SENTRY_RELEASE, a Sentry source-map upload, or to
- fetch and rebuild as separate manual steps? Call the two scripts it wraps directly:
-
-
-
- scripts/deploy-selfhost-prebuilt.sh is the actual rebuild step (this is how{" "}
- LOOPOVER_VERSION ends up as a short git SHA instead of an image tag). It builds
- the bundle inside a Dockerized Node container — the host itself never needs Node or npm
- installed — then restarts only the loopover service the same way as the image
- path. SENTRY_RELEASE defaults to{" "}
- gittensory-selfhost@<short git SHA of the current HEAD> unless you
- override it, so each deploy from a new commit gets a distinct release id automatically. When{" "}
- SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT are
- all configured, the script also injects and uploads Sentry source maps for that release
- before restarting the service (set SELFHOST_SKIP_SENTRY_UPLOAD=1 to skip this
- even when those three are present).
-
-
-
Pre-deploy: preview what's incoming
-
- scripts/selfhost-pre-deploy-summary.sh (#5735) is a read-only preview of what{" "}
- scripts/selfhost-update.sh would pull in — the commit range between the current
- checkout (the last-deployed state) and the remote's tracked branch, plus a flag on any
- incoming commit that touches a path with a history of breaking a deploy on this instance:{" "}
- docker-compose*.yml, grafana/provisioning/**/
- grafana/dashboards/**, migrations/**, Dockerfile*,
- the deploy scripts themselves, and .env.example. It only runs{" "}
- git fetch — never a merge or checkout — so it is safe to run anytime, including
- with a dirty working tree, and takes the same SELFHOST_UPDATE_REMOTE/
- SELFHOST_UPDATE_BRANCH overrides as selfhost-update.sh:
-
-
-
- It is a skim tool, not a gate — it always exits 0 and never blocks{" "}
- selfhost-update.sh from running; a flagged path is a prompt to read the actual
- diff before deploying, not a hard stop.
-
-
-
Post-update checklist
-
- scripts/selfhost-update.sh already runs the health probe below for you unless
- you set SELFHOST_SKIP_POST_UPDATE_CHECK=1. Run it manually after the image
- path, after calling the two wrapped scripts directly, or after any manual{" "}
- docker compose update.
-
-
-
- Wait for the deploy script's health wait to finish (or run the helper below if you
- updated manually with plain docker compose).
-
- Tail logs for selfhost_listening and, on first boot after a schema bump,{" "}
- selfhost_migrations_applied — not selfhost_job_dead.
-
-
- Confirm the release id — neither /health nor /ready exposes a
- version string; check .env and the running container image instead.
-
-
-
-
- If any check fails, see Troubleshooting
- .
-
-
-
Optional: auto-pause on a post-deploy regression
-
- scripts/selfhost-post-update-regression-gate.sh (#5736) goes one step further
- than the post-update checklist above: it verifies the service doesn't just come back
- up, but stays up once real traffic starts flowing. It observes a window of the{" "}
- loopover service's own logs for a dead-job spike (every attempt exhausted
- its retries) and, if the count exceeds a threshold, automatically flips the DB-backed global
- kill-switch (global_agent_controls.frozen) so a bad deploy that starts silently
- failing jobs pauses every agent write action fleet-wide instead of accumulating failures
- until you notice. It never depends on the optional observability profile
- (Prometheus/Grafana/Loki) being enabled -- it reads the service's own logs directly,
- the same way docker compose logs loopover always works regardless of which
- profiles you've opted into. Run it after selfhost-post-update-check.sh{" "}
- passes, once you're ready to let real webhook traffic through -- it blocks for the full
- observation window by design (3 minutes by default):
-
-
-
If it trips, clear the pause once you've confirmed the regression is fixed:
-
-
-
Rollback: no dedicated command
-
- There is no rollback script. Rolling back means re-running one of the two
- scripts above pointed at an older target:
-
-
-
- Image-based: re-run deploy-selfhost-image.sh with the prior tag or digest (
- docker inspect on the running container, or your own deploy log, has the
- digest you were on before the update).
-
-
- Source-based: git checkout the prior commit, then re-run{" "}
- deploy-selfhost-prebuilt.sh.
-
-
-
- This repo has no down-migration convention — scripts/check-migrations.mjs only
- enforces a contiguous, non-colliding numbering, not a reverse path. If a migration has
- already run forward against the live database, rolling back the app code is{" "}
- not safe in general: older code can break against a newer schema (a
- dropped/renamed column, a NOT NULL column it never writes, a changed constraint), even
- though the migration itself succeeded. Before rolling back across a migration boundary,
- check whether everything the newer migration(s) did is purely additive (new nullable column,
- new table, new index) and, specifically, whether the code you're rolling back to actually
- still runs against that schema — additive is usually fine; anything the old code can't
- tolerate is not. Take a fresh backup first regardless — see{" "}
- Backup and scaling — and if in doubt,
- restore that backup to a scratch database and boot the older code against it before doing
- the same on the live instance.
-
-
-
Uninstalling and decommissioning
-
- Tearing an instance down cleanly touches four independent things: the GitHub App
- installation, the data volumes, brokered-mode enrollment, and control-panel access. None of
- this is scripted today — do each step deliberately, in this order, and decide what to keep
- before you delete anything.
-
-
-
1. Revoke the GitHub App installation
-
- Uninstalling stops GitHub from sending any further webhook events and immediately revokes
- the App's installation tokens — nothing on the self-host side needs to be told; there
- is no installationdeleted webhook handler to run first. From the
- repo or org: Settings → Integrations → GitHub Apps → your App → Uninstall. Do this before
- stopping the container so you are not left with a dangling install pointed at a dead webhook
- URL.
-
-
- If you only want to pause reviews without losing the App's configuration (permissions,
- webhook URL, private key), suspend the installation instead of uninstalling it — GitHub
- stops delivering events to a suspended install but keeps everything else intact for a later
- resume.
-
-
-
2. Decide what happens to the data volumes
-
- Stopping the container does not delete anything — docker compose stop or{" "}
- docker compose down (without -v) leaves every named volume (
- loopover-data, loopover-pg, qdrant-data,{" "}
- loopover-backups, grafana-data, and the rest declared in{" "}
- docker-compose.yml) on disk, along with the ./loopover-config host
- directory (a bind mount, not a named volume, so it is never affected by -v{" "}
- either way). Pick one:
-
-
-
- If you have not exported a backup off-host first, docker compose down -v{" "}
- permanently destroys review history, settings, and the vector index with no recovery path —
- the volumes are the only copy. See{" "}
- Backup and scaling before running it on
- an instance you care about.
-
-
-
3. Deregister from the Orb broker (brokered mode only)
-
- If this instance runs in brokered mode (ORB_ENROLLMENT_SECRET is set — see{" "}
- GitHub App and Orb), be aware there is{" "}
- no self-service revocation endpoint today — the "Minimum broker
- safeguards" checklist on that page lists a revocation path as a prerequisite for a
- public brokered rollout that has not shipped yet. An enrollment record (
- orb_enrollments) lives in LoopOver's own central database, not your
- container, and nothing in this codebase writes a revoked_at value to it outside
- of tests. Practical steps until that exists:
-
-
-
- Uninstalling the GitHub App (step 1) stops new webhook traffic and installation-token
- issuance from reaching your instance in practice, even though the enrollment row itself
- stays marked enrolled centrally.
-
-
- Stop the container and let ORB_ENROLLMENT_SECRET go with it — with nothing
- polling or listening, the secret is inert even if it still resolves to a valid enrollment.
-
-
- If the secret may have leaked or you want it invalidated outright rather than just
- orphaned, treat this the same as any other suspected credential compromise: contact the
- Orb operator to have the enrollment revoked centrally, since there is no in-product way to
- do it yourself yet.
-
-
-
-
4. Remove ADMIN_GITHUB_LOGINS access
-
- ADMIN_GITHUB_LOGINS is read fresh from the environment on every control-panel
- request (isAuthorizedGitHubSessionLogin in src/auth/security.ts) —
- it is never cached at startup or baked into an issued session. To remove someone's
- operator access, delete their login from the comma/whitespace-separated list in{" "}
- .env and restart the loopover service so the process picks up the
- new value:
-
-
-
- This takes effect on their very next control-panel request after the restart — no signed-in
- session is grandfathered in, because authorization is re-checked against the current
- allowlist every time, not read from the session itself. If you are decommissioning the whole
- instance rather than removing one operator, this step is moot once the container is stopped.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-quickstart.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-quickstart.tsx
index 611866a37e..39cd5b99ff 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-quickstart.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-quickstart.tsx
@@ -1,9 +1,23 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
-
+import { docsClientLoader } from "@/lib/docs-client-loader";
+
+// SPIKE (#6037): rendered from content/docs/self-hosting-quickstart.mdx via fumadocs-mdx's
+// browser entry (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. The `loader` below runs server-only and
+// resolves nothing but a plain, serializable path string via docs-source.ts's collections/server
+// -- never the live MDX component -- which is what avoids the client-bundle crash that a direct
+// collections/server import from route-module scope hits (see docs-source.ts's comment). Kept at
+// the same route/URL as the pre-migration hand-built page to prove visual output is unchanged.
export const Route = createFileRoute("/docs/self-hosting-quickstart")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-quickstart"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-hosting quickstart — LoopOver docs" },
@@ -26,281 +40,13 @@ export const Route = createFileRoute("/docs/self-hosting-quickstart")({
});
function SelfHostingQuickstart() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
1. Copy the sample env
-
- .env.selfhost.example is the short path: required secrets plus a conservative
- first-boot config, with nothing about the Cloudflare Worker deploy. Copy it and fill in the
- placeholders — keep your real .env out of git and prefer mounted secret files
- for multiline values like the GitHub App private key.
-
-
-
- The webhook secret and static bearer tokens (GITHUB_WEBHOOK_SECRET,{" "}
- LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN,{" "}
- INTERNAL_JOB_TOKEN, SELFHOST_SETUP_TOKEN) ship commented out on
- purpose. Generate a distinct random value for each one (e.g.{" "}
- openssl rand -hex 32) — never reuse the same string across more than one of
- them. The app refuses to boot if any of these is left at a known-placeholder or too-short
- value.
-
-
- .env.selfhost.example already ships a conservative starting config —{" "}
- dry-run mode, a small repo allowlist, unified comments, safety, and grounding,
- with AI, RAG, and REES left off. Switch to live only after webhook delivery, logs, and
- review output match expectations. For every optional env var (observability, backup,
- additional AI providers) see .env.example's self-host section or the{" "}
- generated reference table.
-
-
-
2. Choose your AI provider (optional)
-
- Skip this step for a fully deterministic review (no AI). Otherwise set{" "}
- AI_PROVIDER to one provider or a fallback chain. The self-host image bundles
- both CLIs by default; credentials and provider choice are runtime-only.
-
-
-
-
-
- Set AI_DUAL_REVIEW=1 only when you deliberately want the first two providers to
- run as independent reviewers instead of a fallback chain.
-
-
- Codex stores its OAuth credential in auth.json on the same filesystem that
- prompt-influenced reviews can read, so it requires explicit opt-in (
- LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER=1) and a mounted /data/codex{" "}
- auth volume. Claude Code has no equivalent restriction. See{" "}
- AI providers for the full reference.
-
-
-
3. Boot the stack
-
- Recommended: pull the published image. No local build, no Node toolchain —
- the script pulls, restarts, and waits for the health check to pass.
-
-
-
- Pin a specific release instead of :latest, or point at your own registry:
-
-
-
- ghcr.io/jsonbored/gittensory-selfhost (the pre-rename name) is no longer
- published to, but an existing pin to a specific tag or digest under it keeps resolving.
-
-
- Contributors and anyone customizing the Dockerfile can still build locally —{" "}
- docker compose up -d --build builds the loopover service from the
- checkout instead of pulling a published image. Everything else in this quickstart (env,
- health checks, GitHub App) is identical either way. Two build-args trim the image:{" "}
- --build-arg INSTALL_AI_CLIS=false skips the Claude Code/Codex CLIs (default{" "}
- true), and --build-arg INSTALL_VISUAL_REVIEW=true adds{" "}
- puppeteer-core for visual capture (default false — needs a{" "}
- BROWSER_WS_ENDPOINT at runtime).
-
-
-
-
4. Install or connect the GitHub App
-
- Point your App webhook to https://your-host.example/v1/github/webhook, set the
- same webhook secret in GITHUB_WEBHOOK_SECRET, install the App on one test repo,
- and open a small PR. The direct App and Orb modes are covered in{" "}
- GitHub App and Orb.
-
-
- Set ADMIN_GITHUB_LOGINS to a comma/whitespace-separated list of GitHub logins
- before signing in to the control panel — it's the only allowlist for the operator role
- (operator dashboard, drift status). No login is authorized as operator without it.
-
-
-
5. Watch the first review
-
Look for these logs during boot and the first webhook:
-
-
- A cold first boot on SQLite commonly logs a one-time{" "}
- selfhost_migrations_applied burst and a brief Redis connection retry while the
- sidecar finishes starting — both are expected and stop once the stack is warm. Anything else
- that looks wrong, or a /ready that stays unhealthy past a couple minutes, is
- covered in Troubleshooting.
-
-
- After the deterministic path is stable, continue with{" "}
- Configuration and then layer in AI, REES,
- or RAG deliberately.
-
-
-
6. Activate your first repo
-
- Three separate knobs are easy to conflate — each does something different, and all three
- matter for a smooth first rollout:
-
-
-
- Recommended first-repo path today: add the repo to{" "}
- LOOPOVER_REVIEW_REPOS, seed a private global default, then enable advisory gate
- mode once webhook delivery works.
-
-
-
- Copy the shipped global private default into the compose-mounted config directory (edit your
- copy — never commit real policy to a public repo):
-
-
-
- Sign in to the control panel (ADMIN_GITHUB_LOGINS must include your GitHub
- login), open the repo workspace, preview what LoopOver would have flagged on recent PRs,
- then enable advisory mode in one click — the same patch as:
-
- " \\
- -H "Content-Type: application/json" \\
- -d '{}'`}
- />
-
- That activation endpoint turns on the gate check plus deterministic rules in{" "}
- advisory mode (non-blocking, no auto-merge) — a CodeRabbit-style ramp. AI
- review stays off until you configure it separately. Full semantics in{" "}
- Configuration.
-
-
- If reviews compute but no LoopOver Orb Review Agent check-run appears, open
- your App's permissions page and confirm Checks: write is granted —
- checks: read alone 403s the write silently. New permissions also require a
- one-time re-approval on each installation; see{" "}
- GitHub App and Orb.
-
-
- When output looks right, switch SELFHOST_DEPLOYMENT_MODE from{" "}
- dry-run to unset (live writes). For a shorter future path, see the onboarding
- proposal on Self-hosted reviews.
-
-
-
Defaults at a glance
-
- Nothing below needs a flag to start; everything past the first row needs an explicit{" "}
- --profile (combine freely) or an explicit AI_PROVIDER.
-
-
-
- Orb fleet-calibration telemetry (verdict, outcome, cycle time — never repo names, code, or
- logins) starts automatically once your GitHub App is configured — this is the self-hosting
- contract, not a flag you turn on. The one way to disable it is the explicit air-gap flag:
- set ORB_AIR_GAP=true for an instance that sends nothing.
-
-
- --profile caddy gets you real public HTTPS; --profile tailscale{" "}
- adds private tailnet reachability (it does not remove the default public port on its own —
- see the callout below) — see Security's TLS
- termination section for the full walkthrough of each (Caddyfile setup, DNS prerequisites,
- hardening Tailscale for real isolation, and when to pick one over the other).
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-rag.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-rag.tsx
index d323932e37..a8ddd26d13 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-rag.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-rag.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-rag.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-rag")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-rag"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host RAG indexing — LoopOver docs" },
@@ -26,108 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-rag")({
});
function SelfHostingRag() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Prerequisites
-
-
-
Choosing a vector backend
-
- SQLite vectors are the default and need no extra service — fine for a small instance or
- getting started. Qdrant (QDRANT_URL, --profile qdrant) is the
- preferred dedicated vector store for review context at scale. A third option,{" "}
- PGVECTOR_ENABLED=true, uses the Postgres pgvector table instead — only relevant
- if you're already running the postgres profile and want to avoid standing up a
- separate Qdrant service. Leave it false (the default) when{" "}
- QDRANT_URL is set; Qdrant remains preferred for RAG at scale.
-
-
-
Qdrant and Ollama example
-
-
-
- Use QDRANT_DIM=1024 for 1024-dimensional models such as bge-m3 or{" "}
- mxbai-embed-large. If a Qdrant collection already exists, recreate it before
- changing dimensions.
-
-
- AI_EMBED_API_KEY is the bearer credential for AI_EMBED_BASE_URL,
- if that endpoint requires one — a local Ollama typically doesn't, but a hosted
- OpenAI-compatible embeddings endpoint usually does. Setting AI_EMBED_MODEL{" "}
- alone does nothing without AI_EMBED_BASE_URL also set; unset, embeddings use
- the same provider as the rest of the review chain.
-
-
-
Indexing
-
- RAG needs an index before it can retrieve useful context. A cold or missing index degrades
- to no context; the review still runs.
-
-
-
-
Operational checks
-
-
- Boot logs should include selfhost_embed_provider when an embedding provider
- is configured.
-
-
- Qdrant mode should log selfhost_vectorize with backend qdrant.
-
-
- Empty RAG context usually means the repo is not indexed, the embed model is unavailable,
- or dimensions do not match.
-
-
-
-
- RAG is context, not authority. The AI reviewer still has to verify every claim against the
- diff, grounding, and review rules.
-
-
- Pair RAG with AI providers and optionally{" "}
- REES.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-rees-analyzers.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-rees-analyzers.tsx
index 195dabcbba..48c3738a51 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-rees-analyzers.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-rees-analyzers.tsx
@@ -1,10 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
-import { REES_ANALYZERS, REES_ANALYZER_NAMES, REES_PROFILES } from "@/lib/rees-analyzers";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-rees-analyzers.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-rees-analyzers")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-rees-analyzers"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "REES analyzer reference — LoopOver docs" },
@@ -27,151 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-rees-analyzers")({
});
function SelfHostingReesAnalyzers() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
- REES runs analyzers independently. A failed analyzer is marked degraded, completed analyzers
- still return findings, and an empty result produces no user-facing brief. Use exact analyzer
- names in REES_ANALYZERS. A typo-only analyzer list fails closed with no
- analyzers selected. Leave REES_PROFILE unset for the balanced profile, or set
- fast during incidents to favor local and low-cost registry checks.
-
-
-
- If the REES endpoint is outside your trust boundary, set{" "}
- REES_FORWARD_GITHUB_TOKEN=false. REES will still receive the PR diff/files when
- enabled, but token-aware analyzers will skip GitHub API reads they cannot authenticate.
-
-
-
Analyzer details
-
- {REES_ANALYZERS.map((analyzer) => (
-
-
-
-
{analyzer.title}
-
- {analyzer.docs.summary}
-
-
-
-
- {analyzer.name}
-
-
- {analyzer.cost}
-
-
-
-
-
-
Looks at
-
{analyzer.docs.looksAt}
-
-
-
Reports
-
{analyzer.docs.reports}
-
-
-
Network
-
{analyzer.docs.network}
-
-
-
Operational note
-
{analyzer.docs.notes}
-
-
-
Profiles
-
{analyzer.profiles.join(", ")}
-
-
-
Requirements
-
{analyzer.requires.join(", ")}
-
-
-
- ))}
-
-
-
Back to REES setup
-
- Use REES enrichment for enablement, auth,
- troubleshooting, and where the brief appears in the review result.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-rees.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-rees.tsx
index b23af1c2e1..008c908373 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-rees.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-rees.tsx
@@ -1,10 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
-import { REES_ANALYZER_NAMES } from "@/lib/rees-analyzers";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-rees.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-rees")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-rees"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "REES enrichment — LoopOver docs" },
@@ -27,231 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-rees")({
});
function SelfHostingRees() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Where REES fits
-
- REES fires inside the AI review path. It is not a separate status check, dashboard report,
- or PR attachment. When it returns a non-empty promptSection, the engine folds
- that brief into the AI reviewer prompt. The final result is seen only through the normal AI
- review summary, blockers, risks, nits, and decision.
-
-
- A 200 response with no findings can produce no rendered brief. That is expected: the review
- proceeds as if REES had no useful extra context for that PR.
-
-
-
When it fires
-
-
-
Run REES
-
- REES runs as its own small HTTP service, separate from the engine. The simplest way to run
- it is in-network alongside the engine, using the docker-compose stack in the repo root — no
- separate hosting to manage, and nothing published to the host (the engine reaches it only
- over the compose network):
-
-
-
Point the engine at it and generate a fresh shared secret:
- `}
- />
-
- No SENTRY_* variables are required for a working local REES. Set them only if
- you want REES error reporting — see "Service configuration" below for the variables REES
- reads, and add them for the rees service through a{" "}
- docker-compose.override.yml rather than the root .env: REES reads
- the same SENTRY_DSN name the main engine uses, so forwarding the whole{" "}
- .env file would point REES's error reporting at the engine's Sentry project
- instead of a dedicated one.
-
-
-
Pointing at an external or managed instance instead
-
- If you'd rather run REES elsewhere — a separate host, a managed provider, or one shared
- instance across multiple self-hosted installs — point REES_URL at it directly
- and skip the rees compose profile entirely. Generate a dedicated shared secret
- for that instance; never reuse a secret across two different REES instances you run:
-
- `}
- />
-
-
Common options (either path)
-
-
-
-
Disable cleanly
-
- Set LOOPOVER_REVIEW_ENRICHMENT=false to turn off REES for the whole instance.
- To keep REES configured but prevent a repo from using it, remove that repo from{" "}
- LOOPOVER_REVIEW_REPOS. Token forwarding stays off unless you explicitly set{" "}
- REES_FORWARD_GITHUB_TOKEN=true.
-
-
-
-
Analyzer selection
-
- Leave REES_ANALYZERS unset, all, or * to use the
- selected REES_PROFILE defaults. To run a subset, use exact comma-separated
- analyzer names. Unknown names are ignored with a rees_analyzer_config_invalid{" "}
- warning and the remaining valid analyzers still run. If every configured name is invalid,
- the engine sends an empty analyzer list so the typo fails closed instead of running the full
- registry.
-
-
-
-
- See the REES analyzer reference for each
- analyzer's inputs, network behavior, and finding shape.
-
-
-
Request boundary
-
- When enabled, the engine POSTs the repo name, PR number, head SHA, base SHA when GitHub
- supplies it, title, changed file paths, changed file patches, and review diff to{" "}
- REES_URL. It forwards no GitHub token by default. If{" "}
- REES_FORWARD_GITHUB_TOKEN=true, the engine includes a GitHub read token so
- GitHub API analyzers can read private CODEOWNERS and blob sizes. The engine prefers a
- short-lived installation token and falls back to GITHUB_PUBLIC_TOKEN. Enable
- forwarding only when the REES service is inside your trust boundary.
-
-
- Do not point REES_URL at a service you do not trust with PR diffs. Token
- forwarding is optional, but the diff/files themselves can contain private code.
-
-
-
Service configuration
-
- The REES service must use the matching REES_SHARED_SECRET. Optional Sentry env
- captures analyzer degradations without logging request bodies, tokens, diffs, or review
- content.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-release-checklist.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-release-checklist.tsx
index 992bdec7db..3f2c4d53ea 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-release-checklist.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-release-checklist.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-release-checklist.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-release-checklist")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-release-checklist"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "First release checklist — LoopOver docs" },
@@ -26,670 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-release-checklist")({
});
function SelfHostingReleaseChecklist() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Versioning and release trigger
-
- orb-v0.1.0-beta.1 and orb-v0.1.0-beta.2 already exercised the full
- release pipeline — multi-arch build, provenance, SBOM, Sentry source-map upload and release
- validation, and GitHub Release creation — twice, successfully. Neither moved{" "}
- latest or produced an unmarked GitHub Release, because{" "}
- a prerelease tag never does. The natural next
- step is not a third beta: it is orb-v0.1.0, a plain{" "}
- X.Y.Z tag with no -rc/-beta suffix.
-
-
- The release workflow (.github/workflows/release-selfhost.yml) resolves this
- distinction itself from the tag text, not from a separate flag — pushing{" "}
- orb-v0.1.0 runs through the identical build/provenance/SBOM/Sentry steps the
- two betas already proved out, but the PRERELEASE value it computes flips to{" "}
- false, which (per the release-image-tags guard added for this exact reason) is
- what allows the run to push the latest image tag and create a non-prerelease
- GitHub Release:
-
-
-
- Only a non-prerelease X.Y.Z tag ever moves latest or the
- repo's unmarked "Latest release" — a -rc/-beta tag
- runs the same pipeline but is always excluded from both. Confirm the tag has no prerelease
- suffix before pushing it; there is no undo for latest once an operator has
- pulled it.
-
-
- Going forward, the scheme is ordinary semver under the orb-v prefix:{" "}
- orb-v0.1.1 for a patch, the next minor version for a feature bump, and an{" "}
- -rc.N/-beta.N suffix on any tag that should run the pipeline
- without touching latest or the default GitHub Release. This checklist and the
- smoke matrix below apply to every future cut, not just the first.
-
-
-
First-release checklist
-
- Work through this list once, in order, before pushing the orb-v0.1.0 tag.
-
- && git push origin orb-v0.1.0 — the workflow rejects any commit not reachable from main with "Self-host releases must be cut from a commit reachable from main."',
- },
- {
- title: "7. Release environment approved",
- description:
- "The release job runs under the release GitHub Environment; if reviewer approval is configured, approve the pending run so the build/push/Sentry/notes steps proceed.",
- },
- {
- title: "8. Post-publish verification",
- description:
- "docker pull the published orb-v0.1.0 and latest tags, confirm both resolve to the same digest, and re-run the fresh-install smoke scenario against the pulled (not locally built) image.",
- },
- ]}
- />
-
Smoke-test matrix
-
- Every scenario below shares the same core check — scripts/smoke-selfhost.sh{" "}
- boots one container against a fresh Redis on an isolated network, waits for it to become
- healthy, and asserts on /health, /ready, /metrics,
- and startup log events. What changes per scenario is the env you pass in and which events
- you expect (or forbid).
-
-
-
-
-
-
Scenario
-
Steps
-
Pass criteria
-
-
-
-
-
Direct GitHub App (default)
-
- Run the base smoke command with no ORB_ENROLLMENT_SECRET.
-
-
- /health, /ready ok;{" "}
- selfhost_migrations_applied logged;{" "}
- selfhost_orb_relay_register does NOT appear (relay is brokered-only).
-
-
-
-
Brokered — push mode
-
- Set ORB_ENROLLMENT_SECRET and a real, internet-reachable{" "}
- PUBLIC_API_ORIGIN.
-
-
- selfhost_orb_relay_register logged;{" "}
- selfhost_orb_relay_register_failed does NOT appear (failure here is{" "}
- error-level and release-blocking).
-
-
-
-
Brokered — pull mode
-
- Set ORB_ENROLLMENT_SECRET and ORB_RELAY_MODE=pull, no
- inbound origin needed.
-
-
- selfhost_orb_relay_register logged; a failed announce (
- warn-level) is tolerated since the drain loop keeps retrying.
-
-
-
-
Air-gapped / no telemetry
-
- Set ORB_AIR_GAP=true.
-
-
- No export attempt or export error logged; no outbound request to the collector URL
- at the network level.
-
-
-
-
AI provider (Claude Code / Codex / both)
-
- Set AI_PROVIDER to each supported value with real credentials.
-
-
- selfhost_ai_provider logged; selfhost_ai_cli_missing does
- NOT appear (release-blocking if it does — the image was built without{" "}
- INSTALL_AI_CLIS=true).
-
-
-
-
SQLite (default) / Postgres
-
- Base command covers SQLite; boot a Postgres container and set{" "}
- DATABASE_URL for the Postgres path.
-
-
- Both boot healthy and apply migrations; note in release notes which mode beta
- testers actually validated.
-
-
-
-
Redis (always-on) + optional Qdrant RAG
-
- Base command covers Redis; set QDRANT_URL against a booted Qdrant
- container for the RAG path.
-
-
- selfhost_redis_ready always logged; selfhost_vectorize{" "}
- logged only when QDRANT_URL is set.
-
-
-
-
Fresh install
-
- Pull the published orb-v0.1.0 tag on a clean host (no prior volumes)
- and boot via compose.
-
-
- Container reports healthy; /ready returns 200 without any
- manual migration step.
-
- Only the loopover service restarts (--no-deps);{" "}
- .env, data volumes, and loopover-config/ are untouched;{" "}
- /ready returns 200 after the health-check wait.
-
-
-
-
Rollback to prior tag
-
- Re-run scripts/deploy-selfhost-image.sh pinned to the prior tag/digest
- (e.g. orb-v0.1.0-beta.2).
-
-
- Service restarts healthy on the older image; confirmed safe only when nothing since
- the prior tag added a forward-only migration the older code can't tolerate (see{" "}
- Updating and rolling back).
-
-
-
-
One-service app restart
-
- Re-run either deploy script against the same tag with other profile services
- (Postgres, Redis, Qdrant, Grafana) already up.
-
-
- Only the loopover container recreates; profile-service containers and
- their volumes are never touched.
-
-
-
-
Sentry release validation
-
- Confirm the release workflow's "Validate Sentry release" step passed
- for this tag (source maps uploaded, release finalized, commits attached).
-
-
- review-enrichment/scripts/validate-sentry-release.mjs exits 0 within
- its 5-attempt retry-poll; the Sentry release id matches the baked{" "}
- LOOPOVER_VERSION.
-
-
-
-
Docs links resolve
-
- Follow every link in the release notes template below (setup guide, releases page,
- this checklist) from the published GitHub Release.
-
-
- Every linked docs page loads and matches the version being released.
-
-
-
-
-
-
- The scenario-by-scenario commands below give exact env and expected/forbidden log events for
- each row above.
-
-
-
-
Direct GitHub App mode (default)
-
- No ORB_ENROLLMENT_SECRET — the container uses its own GitHub App private key.
- Telemetry export is always-on in this mode too; a clean run produces no export error.
-
-
-
- selfhost_orb_relay_register must NOT appear here — relay registration is
- brokered-only and silently skips in direct mode (see{" "}
- GitHub App and Orb).
-
-
-
Brokered mode (private / managed-beta only)
-
- ORB_ENROLLMENT_SECRET set — the container gets tokens from the central Orb
- instead of its own App key. Relay mode changes what "working" means: push mode (
- ORB_RELAY_MODE unset, the default) needs a real public{" "}
- PUBLIC_API_ORIGIN and a failed registration is release-blocking (logged at{" "}
- error); pull mode (ORB_RELAY_MODE=pull) needs no inbound endpoint
- at all and tolerates a failed registration (logged at warn) since the drain
- loop keeps retrying regardless. Run BOTH scenarios — they exercise genuinely different code
- paths, not just different env (see{" "}
- choosing a relay mode).
-
-
-
-
Air-gapped / no-telemetry mode
-
- ORB_AIR_GAP=true disables the fleet-calibration export entirely. There is no
- "air-gap confirmed" log event — the export function returns before doing anything, so
- silence (no export error, no export attempt) is the signal. Confirm at the network level
- too: no outbound request to the collector URL.
-
-
-
-
AI provider: Claude Code / Codex / both
-
- Each provider choice must log selfhost_ai_provider and must NOT log{" "}
- selfhost_ai_cli_missing (a CLI-subscription provider whose binary isn't on{" "}
- PATH silently produces no review output — this must be caught here, not in
- production).
-
-
-
- These need real credentials to reach a genuinely healthy /ready (it probes the
- configured AI provider). Where credentials aren't available for a given RC run, at minimum
- confirm selfhost_ai_cli_missing does NOT appear — that alone catches the
- release-blocking case (image built without INSTALL_AI_CLIS=true).
-
-
-
SQLite trial mode / Postgres production mode
-
- SQLite is the default — the base smoke command above already covers it (no{" "}
- DATABASE_URL set). For Postgres, boot a Postgres container on the same network
- first and point DATABASE_URL at it.
-
-
-
- SQLite is the trial/single-node default; recommend Postgres for production in release notes
- whenever this mode is what beta testers actually validated.
-
-
-
Redis cache + optional Qdrant RAG
-
- Redis is always-on in every scenario above (the base script already boots it) — confirm{" "}
- selfhost_redis_ready appears with githubResponseCacheEnabled{" "}
- matching whatever GITHUB_CACHE_TTL_SECONDS you set. For the optional Qdrant RAG
- path, boot Qdrant on the same network and point QDRANT_URL at it.
-
-
-
-
Expected startup events
-
-
-
Known warnings: acceptable in beta vs. release-blocking
-
-
-
Image-contents audit
-
- The runtime-prebuilt target — what the release workflow actually builds and
- pushes (docker/build-push-action is invoked with{" "}
- target: runtime-prebuilt) — copies exactly three things on top of the{" "}
- runtime-base layer: the pre-bundled dist/server.mjs, the{" "}
- migrations/ SQL files, and config/examples/ (generic, safe
- reference templates — shipping them activates nothing, since{" "}
- LOOPOVER_REPO_CONFIG_DIR still points at an operator-mounted{" "}
- /config). Nothing else reaches that stage.
-
-
-
- Net effect of the two .dockerignore/Dockerfile changes audited above (already
- shipped, not part of this checklist itself): the built image measured 754MB
- , down from 942MB before them. Re-verify the size on the actual published{" "}
- orb-v0.1.0 image as part of the checklist:
-
-
-
- This audit is Dockerfile-derived, not a runtime scan. If a future dependency bump adds a
- postinstall step that writes somewhere unexpected, re-check the{" "}
- runtime-prebuilt stage's COPY/RUN steps directly
- rather than assuming this list still holds.
-
-
-
One default image, not full/minimal variants
-
- INSTALL_AI_CLIS is already a Dockerfile build-arg toggle (default{" "}
- true), and INSTALL_VISUAL_REVIEW is a second, independent one
- (default false) — see{" "}
- custom images. That means the
- "minimal" image the requirement asks about is already buildable today by anyone
- who wants it, as a custom build.
-
-
- For this first official release, publish only the one default (
- INSTALL_AI_CLIS=true) image under orb-v0.1.0. Reasons:
-
-
-
- Defer the full/minimal published-variant question, not the build-arg. If real operator
- demand for a smaller published tag shows up post-release, it's a follow-up release-workflow
- change (a second docker/build-push-action invocation with{" "}
- INSTALL_AI_CLIS=false and its own tag suffix), not a blocker for cutting{" "}
- orb-v0.1.0.
-
-
-
GitHub Release notes template
-
- The release workflow's own "GitHub Release" step generates the notes body
- programmatically (see .github/workflows/release-selfhost.yml) — it does not use{" "}
- --generate-notes, specifically to avoid GitHub's 125,000-character
- release-body limit on a large commit history. The template below matches that generated body
- and extends it with the supported/experimental/optional/operator-owned breakdown this
- checklist calls for. Paste it into the release description in addition to (or in place of)
- the workflow-generated block when publishing orb-v0.1.0.
-
-
-
-
- After every applicable scenario passes, continue with the normal{" "}
- upgrade flow to cut the tag and publish the
- image.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-releases.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-releases.tsx
index 61a116ab0f..c87bc05fe4 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-releases.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-releases.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-releases.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-releases")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-releases"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host releases and images — LoopOver docs" },
@@ -26,135 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-releases")({
});
function SelfHostingReleases() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Image tags
-
-
-
- ghcr.io/jsonbored/loopover-selfhost is the only name new releases publish
- under. The pre-rename ghcr.io/jsonbored/gittensory-selfhost is no longer
- published to, but an existing pin to a specific tag or digest under that name keeps
- resolving — pull it and switch to the new name at your convenience.
-
-
-
Prerelease (beta/rc) images
-
- A tag like orb-v0.1.0-rc.1 or orb-v0.1.0-beta.1 runs the identical
- build/provenance/SBOM/Sentry pipeline as a stable release, but is marked prerelease on
- GitHub and is never pushed under latest. External beta testers should pull the
- exact prerelease tag, not latest.
-
-
-
- Stable release behavior is unchanged: a plain X.Y.Z tag still moves{" "}
- latest and publishes an unmarked (non-prerelease) GitHub Release.
-
-
- Before tagging any orb-v* release or prerelease, run the{" "}
- release checklist against the built
- image — CI only smoke-tests the plain SQLite + Redis + direct-App default, not brokered
- mode, air-gapped mode, or any AI provider.
-
-
-
Upgrade flow
-
-
Read release notes for env, migration, or behavior changes.
-
Back up the database or confirm Litestream health.
-
- Pull and restart with scripts/deploy-selfhost-image.sh (or rebuild the
- checkout with scripts/deploy-selfhost-prebuilt.sh) — both restart only the{" "}
- loopover service (--no-deps) and wait for it to report{" "}
- healthy before returning, instead of a bare docker compose up -d{" "}
- that returns as soon as the container starts.
-
-
- Check /ready, logs, queue metrics, and one test PR.
-
-
-
-
- Both scripts pin a version: the image script accepts a tag/digest argument or{" "}
- LOOPOVER_IMAGE; the prebuilt script derives SENTRY_RELEASE/
- LOOPOVER_VERSION from the checked-out commit (
- git rev-parse --short=8 HEAD) unless you set SENTRY_RELEASE{" "}
- yourself. A plain{" "}
- docker compose pull loopover && docker compose up -d loopover still
- works, but skips the health-check wait loop and input validation both scripts provide.
-
-
-
Custom images
-
- Custom builds are useful for testing local changes, including subscription CLIs, or trimming
- the image. They should not contain secrets. INSTALL_AI_CLIS (default{" "}
- true) installs the Claude Code and Codex CLIs; a sibling build-arg,{" "}
- INSTALL_VISUAL_REVIEW (default false), adds{" "}
- puppeteer-core for visual capture.
-
-
-
-
Sentry source maps
-
- Official releases align LOOPOVER_VERSION, Sentry release ids, and uploaded
- source maps. For custom images, leave SENTRY_RELEASE unset unless you uploaded
- source maps for that exact built bundle.
-
-
-
Rollback
-
- There is no dedicated rollback command. Roll back by re-running{" "}
- scripts/deploy-selfhost-image.sh pinned to the prior image tag or digest (or{" "}
- scripts/deploy-selfhost-prebuilt.sh against an older checkout) — the same
- script you upgrade with, pointed backward.
-
-
- This repo has no down-migration convention (scripts/check-migrations.mjs and{" "}
- migrations/ only ever add forward). If a migration already ran forward before
- you need to roll back, reverting the app image does not revert the schema — the rolled-back
- code now runs against a newer schema than it expects. Keep backups and read release notes
- for migration changes before upgrading a live maintainer instance, and treat a
- post-migration rollback as a case that needs a manual schema/data plan, not just an image
- swap.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx
index 1652427069..6ee9e39962 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-security.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-security.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-security")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-security"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host security — LoopOver docs" },
@@ -26,373 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-security")({
});
function SelfHostingSecurity() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Secret handling
-
-
- docker-compose.yml ships native Docker Compose secrets: mounts for
- the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/internal-job
- tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret,
- the PagerDuty routing key, and the Claude Code subscription token) — file-mounted at{" "}
- /run/secrets/<name>, never exposed via docker inspect or{" "}
- docker compose config the way a plain environment:/
- env_file value is. This is purely additive: an inline .env value
- always takes priority if you set both, so you can migrate one secret at a time, or not at
- all. See secrets/README.md for the full file list.
-
- The hardened default above — .env plus Docker Compose secrets: —
- has no rotation, audit trail, or RBAC. If you want secrets-manager-grade rotation, audit
- logging, and access control on top of that default, you can opt into{" "}
-
- Infisical
- {" "}
- — an open-source, self-hostable secrets manager. This is{" "}
- strictly optional and additive: skip this section entirely and the hardened{" "}
- .env/Docker secrets default keeps working unchanged.
-
-
- Infisical wires in at the deploy-script level via its own{" "}
- infisical run -- <command> wrapper, which injects secrets as real process
- environment variables at container launch. Nothing under src/ knows or cares
- whether a given env.SOMETHING value came from Infisical, .env, or
- a Docker secret file.
-
-
-
Setup: cloud or self-hosted
-
-
- Install the{" "}
-
- Infisical CLI
- {" "}
- on the machine that runs the deploy script (not inside the app container).
-
-
- Pick where your secrets live: Infisical Cloud (the default, zero infrastructure of your
- own) or a self-hosted Infisical instance — if you're already self-hosting LoopOver, you
- can self-host Infisical alongside it. Either way, run infisical login once,
- then infisical init from the repo root to link a local{" "}
- .infisical.json to an Infisical project.
-
-
- Create an environment inside that project (e.g. prod) matching how you think
- about this deployment, and add the secrets you want Infisical to manage — same variable
- names your .env/docker-compose.yml already use (
- GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET, provider API
- keys, and so on).
-
-
Opt in when deploying:
-
-
-
- With the flag unset (the default), neither script touches Infisical at all — not even a
- presence check — so an operator who has never heard of Infisical is completely unaffected.
- With it set, the restart step (the one that actually launches the container) runs through{" "}
- infisical run --; a missing infisical binary fails the deploy
- immediately with a clear error rather than silently deploying without the secrets you asked
- for.
-
-
-
Interaction with .env and Docker secrets — do not mix the same variable
-
- infisical run -- injects secrets into its own child process's environment — in
- this case, the docker compose up invocation. Docker Compose only lets a host
- shell variable reach the container for an environment: entry written as{" "}
- {`SOMEVAR: "\${SOMEVAR}"`}. It does not reach a plain{" "}
- env_file: .env block, which reads that file's literal contents at container
- runtime and is never affected by the deploying shell's environment. The GitHub App private
- key, webhook secret, API/MCP tokens, and the rest of the native-secrets list above are wired
- through the _FILE convention and env_file: .env, not through{" "}
- environment: interpolation — an Infisical value for one of those exact names,
- by itself, will not reach the container today. Infisical is the right fit
- for other variables you reference via {`"\${VAR}"`} interpolation in
- your own docker-compose.override.yml (a provider API key you add yourself, for
- example) — not a drop-in override for the pre-wired native-secrets list.
-
-
- The safest rule of thumb: for any given variable, pick one source — Infisical or a
- plain .env/Docker secret file, never both for the same name. Setting the same
- name in both places doesn't error; whichever mechanism the container actually reads for that
- variable (see the callout above) wins silently, which is easy to misdiagnose later.
-
-
-
Private policy
-
- Keep sensitive review thresholds, autonomy, maintainer notes, and repo-specific rules in
- LOOPOVER_REPO_CONFIG_DIR, not in public repo config.
-
-
-
-
Network exposure
-
-
- Expose the webhook endpoint only through TLS — see "TLS termination" below for the two
- shipped ways to get there.
-
-
- Prometheus, Qdrant, Ollama, and the database ports are private by default (bound to{" "}
- 127.0.0.1 or only reachable on the compose network) — but{" "}
- Grafana is the exception. Its compose entry publishes{" "}
- 3000:3000, which binds every interface, not just localhost. Bind it yourself
- (127.0.0.1:3000:3000 in a compose override) — the reliable fix — before
- running the observability profile anywhere it isn't already firewalled.
- Running Tailscale alongside it does not narrow this on its own (see "TLS
- termination" below); combining the two safely still needs the same firewall or{" "}
- tailscale serve step.
-
-
Put an auth layer in front of dashboards and internal admin routes.
-
- Use /ready for orchestrators, not as a public status surface.
-
-
-
- The observability profile also runs a docker-proxy service that
- never appears in any dashboard or metric. It fronts the Docker socket for Promtail's
- container log discovery: a plain :ro bind-mount of{" "}
- /var/run/docker.sock only protects the socket inode, not the Docker API behind
- it, so handing Promtail the raw socket is effectively host root — enumerate every container,
- read each one's environment and secrets, tail every log, or start a privileged container and
- escape to the host. docker-proxy is the only container that touches the socket,
- exposes just the read-only /containers/* and /networks/* endpoints
- Promtail's service discovery needs, denies every mutating call outright, and sits alone on
- its own Docker network shared only with Promtail — publishing no host port isn't enough on
- its own, since the default compose network is reachable by every other service in the stack.
-
-
-
Control-panel access
-
- GitHub sign-in to the control panel (the maintainer/owner dashboard) is gated by{" "}
- ADMIN_GITHUB_LOGINS — a comma- or whitespace-separated, case-insensitive
- allowlist of GitHub logins.
-
-
-
- Unset or empty means NOBODY gets control-panel access — not even the person who just
- finished setup. This is intentional, not a bug: add your own GitHub login here right after
- first-run setup, or you will sign in successfully and see zero privileges with no
- explanation. The same allowlist also exempts these logins from the agent's own-PR auto-close
- rules and lets them bypass per-repo MCP scope (MCP_READ_REPO_ALLOWLIST /{" "}
- MCP_ACTUATION_REPO_ALLOWLIST).
-
-
-
AI credential boundaries
-
- CLI auth files can be readable by the runtime. Do not mount a prompt-readable Claude Code or
- Codex home into review execution unless you have intentionally isolated it. API-key and
- local model providers are easier to reason about operationally.
-
-
-
REES boundary
-
- REES receives PR diff and file metadata. Use a private network URL when possible, require
- REES_SHARED_SECRET, and remember that the engine treats REES output as
- untrusted advisory context.
-
-
-
TLS termination
-
- These are the three shipped ways to get real HTTPS without hand-rolling a reverse proxy —
- but only Caddy and bring-your-own-proxy give you a publicly reachable origin. If
- GitHub itself needs to reach this instance (a direct App in push mode, per{" "}
- GitHub App and Orb), Tailscale's private
- tailnet address does not satisfy that — GitHub's servers can't reach it. Tailscale is the
- right fit when only your own team/CI needs access, or as the transport for a{" "}
- brokered, pull-mode instance that never
- needs to receive an inbound webhook at all.
-
-
-
-
Caddy: automatic HTTPS with Let's Encrypt
-
- The caddy profile runs Caddy 2 in front of the loopover service,
- terminating TLS on 80/443/443/udp (the last for
- HTTP/3) and obtaining a Let's Encrypt certificate automatically for whatever domain you set.
- It needs a real DNS record: point DOMAIN at this host's public IP{" "}
- before starting the profile. The shipped Caddyfile has no fallback TLS directive,
- so if the ACME HTTP-01 challenge fails (DNS not propagated yet, port 80 unreachable), Caddy
- does not silently substitute a self-signed cert for a real domain — it logs
- the failure and retries with backoff, and the site has no working HTTPS until DNS and ACME
- both succeed. (A recognized non-public hostname like localhost, below, is a
- deliberately different case — Caddy issues its own internal-CA cert for those automatically,
- since it can never get a real one.)
-
-
-
- The shipped caddy/Caddyfile reverse-proxies to loopover:8787 on
- the compose network, forwards the real client IP, enables compression, sets standard
- security headers (HSTS, X-Content-Type-Options, X-Frame-Options, a
- strict referrer policy), and logs as JSON to stderr:
-
-
-
- Edit this file directly if you need a different upstream, extra headers, or a second site
- block — Caddy re-reads it on container restart. For local testing without a real domain, set{" "}
- DOMAIN=localhost; Caddy issues a self-signed cert and your browser will warn
- about it, which is expected.
-
-
- The loopover service's compose entry has a direct{" "}
- {`ports: ["\${PORT:-8787}:8787"]`} mapping with a comment marking exactly this:
- remove it once Caddy is your public listener, or the app stays reachable on{" "}
- :8787 with no TLS, bypassing the proxy entirely and defeating the whole point
- of adding it. (This rule is Caddy-specific — the Tailscale profile below needs the{" "}
- opposite treatment; see its own callout.)
-
-
- Prefer certificates you already manage — an internal CA, a wildcard cert issued elsewhere —
- instead of Let's Encrypt? Mount your own cert and key into the container and point the{" "}
- {`{$DOMAIN}`} block at a file-based TLS directive (
- tls /path/to/cert /path/to/key) instead of the automatic-HTTPS default; see{" "}
-
- Caddy's tls directive docs
- {" "}
- for the syntax.
-
-
-
Already run a reverse proxy or load balancer?
-
- Skip the caddy profile entirely. Remove the same direct ports:{" "}
- mapping from the loopover service, keep it on the compose network (or publish{" "}
- 8787 bound to a private interface your existing proxy can reach), and terminate
- TLS the way you already do for everything else — nginx, Traefik, an AWS ALB, a Cloudflare
- Tunnel. Whatever fronts it just needs to forward to port 8787 and preserve the
- client IP the same way the shipped Caddyfile does.
-
-
-
Tailscale: adds tailnet reachability
-
- The tailscale profile joins the stack to your tailnet. It runs with{" "}
- network_mode: host — Tailscale needs host networking to advertise this
- machine's address on the tailnet. On its own, this only adds a reachable address;
- see the callout below before assuming it also removes public reachability.
-
-
-
- Tailscale doesn't replace the loopover service's listener the way Caddy does —
- it adds a new network interface to the host. Docker's default{" "}
- {`ports: ["\${PORT:-8787}:8787"]`} mapping publishes to all of the host's
- interfaces, so once Tailscale is up, that same mapping is what makes port 8787{" "}
- reachable at the host's tailnet IP too —{" "}
-
- removing it, as you would for Caddy, makes the app unreachable everywhere, tailnet
- included.
-
-
-
- The tradeoff: leaving the default 0.0.0.0-bound mapping in place means{" "}
- 8787 is also still reachable from your LAN, and from the public internet if
- this host has a public interface at all — Tailscale doesn't narrow that on its own. If you
- want the instance reachable only via the tailnet, either firewall the host to allow{" "}
- 8787 solely from your tailnet's address range, or bind the app's mapping to{" "}
- 127.0.0.1:8787:8787 and use{" "}
-
- tailscale serve
- {" "}
- inside the tailscale container (it shares the host's loopback under{" "}
- network_mode: host) to proxy that localhost-only port onto the tailnet — check
- the pinned image's tailscale serve --help for the exact current flags. This
- profile is the right choice when the instance only needs to be reachable by your own team or
- CI, and you'd rather not manage a domain or certificate at all.
-
-
-
Public output boundary
-
- Public PR comments and checks must not leak secrets, private policy, provider credentials,
- private scoring context, or maintainer-only notes. For hosted and self-host boundaries, keep
- Privacy and security nearby.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-troubleshooting.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-troubleshooting.tsx
index e88dd432c0..a534a9bb03 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-troubleshooting.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-troubleshooting.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, FeatureRow } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-troubleshooting.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-troubleshooting")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-troubleshooting"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Self-host troubleshooting — LoopOver docs" },
@@ -26,369 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-troubleshooting")({
});
function SelfHostingTroubleshooting() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
First checks
-
-
-
No review appears
-
-
-
AI summary unavailable
-
-
- Confirm AI_PROVIDER is set and supported.
-
-
Confirm the provider key or local endpoint works from inside the container.
-
- Set the matching provider model env, such as ANTHROPIC_AI_MODEL,{" "}
- OPENAI_COMPATIBLE_AI_MODEL, OLLAMA_AI_MODEL,{" "}
- CLAUDE_AI_MODEL, or CODEX_AI_MODEL.
-
-
- Increase the matching provider timeout env, such as CLAUDE_AI_TIMEOUT_MS or{" "}
- CODEX_AI_TIMEOUT_MS, for large subscription-CLI reviews.
-
-
For CLI providers, confirm the CLI binary and credential path are available.
-
-
-
REES is silent
-
- A no-finding REES response can be intentionally invisible. For failures, search logs for
- review_context_fetch_failed with contextType set to{" "}
- enrichment.
-
-
-
- Check REES enrichment for enablement and{" "}
- REES analyzer reference for analyzer
- names, network calls, and token requirements.
-
-
-
RAG returns no context
-
-
- Confirm LOOPOVER_REVIEW_RAG=true, the repo is in{" "}
- LOOPOVER_REVIEW_REPOS (or has features.rag: true in private
- config), and RAG indexing has run — see{" "}
- Configuration for the three activation
- layers (is_registered is only required for Gittensor-scored paths, not RAG on
- a self-host allowlist).
-
-
Confirm Qdrant or the vector backend is reachable from the app container.
-
Confirm the embedding endpoint and model are running.
-
Confirm the repo has been indexed after enabling the feature.
-
-
-
Queue stuck or dead jobs
-
- Watch pending, processed, failed, and dead metrics. A high pending count can be webhook
- replay or maintenance work; dead jobs need direct investigation.
-
-
-
-
GitHub rate-limit responses or admission deferrals
-
- Two independent signals cover this:{" "}
- loopover_github_rest_rate_limit_responses_total counts actual 403/429 responses
- from GitHub, and the loopover_jobs_rate_limit_admission_deferred_total /{" "}
- loopover_jobs_rate_limit_budget_deferred_total /{" "}
- loopover_jobs_rate_limited_by_type_total counters track jobs the queue itself
- held back before making a request, to avoid tripping a limit. All three job-side
- counters carry the same three labels — kind (webhook or{" "}
- background), key_scope (installation,{" "}
- public, global, or other), and job_type{" "}
- (the queue job's type, e.g. agent-regate-pr) — so you can break a spike down to
- exactly which token pool and which job type is under pressure.
-
-
- A short burst of deferrals is expected and self-resolving: the queue is deliberately trading
- a few seconds of delay to avoid a real 429. Treat it as a real problem only once it's
- sustained — which is exactly what{" "}
- LoopoverGitHubRateLimitResponses (real 403/429s observed) and{" "}
- LoopoverQueueRateLimitDeferralsHigh (a sustained deferral rate, not a blip) are
- tuned to alert on, rather than firing on every brief admission hold.
-
-
-
- If a single key_scope=installation pool is consistently the bottleneck, the fix
- is usually spreading load across more installation tokens (fewer repos per installation) or
- raising the GitHub App's own rate-limit tier, not code changes here.
-
-
-
Low GitHub response-cache hit rate
-
- loopover_github_response_cache_total (REST) and{" "}
- loopover_github_graphql_cache_total (GraphQL) both carry a result{" "}
- label — hit, miss, set, coalesced,{" "}
- bypassed, or error — and a class label identifying
- the endpoint family. A healthy cache should show most traffic as hit for
- endpoints that are read repeatedly in one review/maintenance pass (PR reads, check-run
- lookups); a low hit rate on those specific classes, not the overall average, is the useful
- signal.
-
-
-
-
Qdrant / vector-store errors
-
- loopover_qdrant_errors_total carries an op label (
- upsert, query, or delete) so you can tell whether
- indexing or retrieval is failing. LoopoverQdrantErrorRateHigh fires on a
- sustained error ratio, not an isolated blip.
-
-
-
- Confirm QDRANT_URL (e.g. http://qdrant:6333) is reachable from
- the app container and the qdrant Compose profile is running.
-
-
- If Qdrant requires auth, confirm QDRANT_API_KEY is set and matches the Qdrant
- deployment's configuration.
-
-
- A dimension-mismatch error means the existing loopover collection (the fixed
- collection name self-host always uses) was created with a different embedding model than
- the one currently configured (AI_EMBED_MODEL). Recreating it — delete the
- collection and let the next index run recreate it at the current width — is the fix, but
- it temporarily removes ALL indexed RAG context for every repo until re-indexing completes,
- so treat it as a deliberate, disruptive step, not a routine one.
-
-
-
-
-
Orb export or relay problems
-
- For brokered self-host deployments, loopover_orb_events_exported_total and{" "}
- loopover_orb_export_errors_total track the hourly outcome-export loop;{" "}
- LoopoverOrbExportErrorRateHigh fires on a sustained error ratio there. The
- pull-mode relay loop (for installations receiving events outbound from Orb) reports through{" "}
- loopover_orb_relay_drains_total (result=events when it drained
- something, result=empty otherwise) and loopover_orb_webhook_total{" "}
- (event + result labels) for what happened to each relayed event
- once enqueued locally.
-
-
- If exports are failing but the relay itself looks healthy, the export loop's Sentry
- cron monitor (see Self-host operations) is
- the fastest way to confirm whether the loop is even running, before digging into the error
- counters.
-
-
- selfhost_orb_relay_register_failed means the container could not announce
- itself to the broker at boot. What it means depends on ORB_RELAY_MODE (see{" "}
- choosing a relay mode):
-
-
-
- Push mode (default) — logged at error and{" "}
- release-blocking: the container looks healthy but never receives an
- event, since there is no fallback delivery path. Check that PUBLIC_API_ORIGIN{" "}
- is a real, internet-reachable, TLS-terminated URL — the broker rejects a loopback or
- private address at registration time, so a misconfigured or unreachable origin fails here
- every time, not intermittently.
-
-
- Pull mode — logged at warn and non-fatal: the drain loop (
- loopover_orb_relay_drains_total) keeps retrying on its own schedule
- regardless, so a transient failure here recovers on its own once the broker is reachable
- again. A registration failure that never clears across many retries still points at{" "}
- ORB_ENROLLMENT_SECRET being wrong, revoked, or not yet provisioned
- server-side.
-
-
-
- Either mode: confirm ORB_BROKER_URL is reachable from inside the container
- first — a broker that is down or unreachable produces the same registration-failed event
- regardless of relay mode or credential correctness.
-
-
-
AI provider circuit breaker keeps opening
-
- Each AI provider (self-host AI_PROVIDER entries) has its own circuit breaker:
- after 3 consecutive failures it stops attempting real calls to that provider for 60 seconds,
- recorded as loopover_ai_provider_circuit_open_total{'{provider="..."}'}{" "}
- (skipped calls) alongside{" "}
- loopover_ai_provider_failures_total{'{provider="..."}'} (real failures). It
- self-heals automatically — there is no manual reset — but it will reopen immediately if the
- underlying problem is still there.
-
-
-
- Search logs for circuit_open: provider "..." to confirm which provider
- tripped, and selfhost_ai_provider_failed_in_chain for the real error each
- failed attempt hit before the breaker opened.
-
-
- A provider that keeps re-tripping after its cooldown almost always means a persistent
- problem, not a transient blip: an expired/invalid API key, a CLI binary missing from the
- image (see selfhost_ai_cli_missing at boot), or the endpoint being genuinely
- unreachable from the container.
-
-
- LoopoverAiProviderCircuitOpen fires on any circuit-open event in a 15-minute
- window — a single trip during a real but brief outage is expected; a rule that keeps
- firing across multiple windows points at the persistent case above.
-
-
-
-
Grafana metrics/dashboards show no data
-
- The metrics path is the app's own /metrics (scraped directly by Prometheus)
- plus, for Claude Code's OTEL telemetry specifically, app or smoke process → OTEL collector →
- its Prometheus exporter (:8889) → Prometheus.
-
-
-
-
- If the smoke command fails at otel-collector:4318/v1/metrics, the collector
- is not reachable from the app container.
-
-
- If it pushes successfully but cannot read it back from{" "}
- otel-collector:8889/metrics, the collector's Prometheus exporter is unhealthy
- or the pipeline in otel/otel-collector-config.yml is misconfigured.
-
-
- If the app's own /metrics check fails, that is unrelated to the OTEL
- collector — check the app container directly (docker compose logs loopover
- ).
-
-
- If the smoke command passes but a Grafana dashboard panel is still blank, check that
- panel's own PromQL expression against a metric name actually emitted in src/{" "}
- — a renamed or removed metric a dashboard still references renders as a permanent
- zero/no-data, not an error.
-
-
-
-
Grafana traces error or show no data
-
- The trace path is app or smoke process → OTEL collector → Tempo → Grafana. Tempo is only
- started by the observability profile, and app traces are only emitted when{" "}
- OTEL_TRACES_EXPORTER includes otlp.
-
-
-
-
- If the smoke command fails at otel-collector:4318/v1/traces, the collector is
- not reachable from the app container.
-
-
- If it pushes successfully but cannot read{" "}
- tempo:3200/api/traces/<trace_id>, Tempo is unhealthy, not ingesting, or
- not sharing the Compose network.
-
-
- If the smoke command passes but Grafana Explore fails, check the Tempo data source URL. It
- should point at http://tempo:3200, not the OTLP ingest ports.
-
-
- For a temporary live debugging run, set OTEL_TRACES_SAMPLER_ARG=1 so every
- root trace is sampled, then lower it again after diagnosis.
-
-
-
-
Readiness fails
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.self-hosting-unified-ams-orb.tsx b/apps/loopover-ui/src/routes/docs.self-hosting-unified-ams-orb.tsx
index 43af1f6f77..bd6d867df5 100644
--- a/apps/loopover-ui/src/routes/docs.self-hosting-unified-ams-orb.tsx
+++ b/apps/loopover-ui/src/routes/docs.self-hosting-unified-ams-orb.tsx
@@ -1,18 +1,34 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
-
-const DESCRIPTION =
- "Run ORB (self-hosted PR review) and AMS (the autonomous miner) together on one host — shared state, aligned .env, and working AMS Grafana panels in fleet mode.";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/self-hosting-unified-ams-orb.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock/FeatureRow
+// primitives -- not fumadocs-ui's bundled components. See docs-source.ts's comment
+// for why the loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/self-hosting-unified-ams-orb")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["self-hosting-unified-ams-orb"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Unified ORB + AMS self-host — LoopOver docs" },
- { name: "description", content: DESCRIPTION },
+ {
+ name: "description",
+ content:
+ "Run ORB (self-hosted PR review) and AMS (the autonomous miner) together on one host — shared state, aligned .env, and working AMS Grafana panels in fleet mode.",
+ },
{ property: "og:title", content: "Unified ORB + AMS self-host — LoopOver docs" },
- { property: "og:description", content: DESCRIPTION },
+ {
+ property: "og:description",
+ content:
+ "Run ORB (self-hosted PR review) and AMS (the autonomous miner) together on one host — shared state, aligned .env, and working AMS Grafana panels in fleet mode.",
+ },
{ property: "og:url", content: "/docs/self-hosting-unified-ams-orb" },
],
links: [{ rel: "canonical", href: "/docs/self-hosting-unified-ams-orb" }],
@@ -21,135 +37,13 @@ export const Route = createFileRoute("/docs/self-hosting-unified-ams-orb")({
});
function UnifiedAmsOrb() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Two compose files are involved, and they are deliberately separate:
-
-
- Standing both up on one host is just two docker compose invocations. The one
- thing that needs care is where AMS keeps its SQLite state, because the ORB{" "}
- ams-observability exporter reads that state from a host directory — and fleet
- mode does not write there by default.
-
-
-
1. Bring up ORB
-
- Follow the Self-hosting quickstart to
- configure ORB's .env and boot the stack. For this combined setup, enable both
- the observability profile (Prometheus, Alertmanager, Loki, and Grafana) and the{" "}
- ams-observability profile (the ams-reporting-exporter that feeds
- AMS data into Grafana):
-
-
-
- Grafana itself ships under --profile observability; the AMS exporter that
- populates its AMS datasources ships under --profile ams-observability. Enable
- both, or the AMS panels stay empty even though the rest of the observability stack is up.
-
-
-
2. Bring up AMS in fleet mode
-
- Fleet mode reads credentials from an env file and runs the continuous worker loop. Build and
- start it from the repo root:
-
-
-
- On its own this works — but the miner's SQLite ledgers now live in a Docker{" "}
- named volume (miner-data), whose real host path is a
- Docker-managed internal detail. The ORB exporter, meanwhile, reads the ledgers from a host
- directory (default ~/.config/loopover-miner), so the two never line up on their
- own and the Grafana AMS datasources stay silently empty.
-
-
-
3. Bridge the state so AMS panels populate
-
- The AMS package ships an opt-in override that relocates the fleet miner's{" "}
- /data/miner state onto the same host directory the exporter reads —
- using the same LOOPOVER_MINER_CONFIG_DIR variable and default, so there is no{" "}
- docker volume inspect archaeology. Copy the example (it is gitignored) and run
- all three compose files together with both profiles:
-
-
-
- The override bind-mounts /data/miner to{" "}
- ${"{LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}"} — Compose merges by
- container path, so this replaces the base file's miner-data{" "}
- named-volume mount for the same target rather than adding a second one. The exporter's own
- bind is the same source:{" "}
- ${"{LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}"}:/ams-ledgers:ro.
- Both sides now read one location.
-
-
- Leave LOOPOVER_MINER_CONFIG_DIR unset on both to use the shared default, or set
- it once so both the fleet miner and the ORB exporter follow it. If only one side sets it,
- they diverge again and the AMS panels go empty — this is the mismatch the fleet-mode bridge
- exists to close.
-
-
-
4. Verify the AMS panels
-
-
- This is the fleet-mode bridge that #5805 introduced; the AMS-deployment
- reference in packages/loopover-miner/DEPLOYMENT.md ("Running fleet mode
- alongside ORB's ams-observability profile") documents the same override for a
- package-internal audience. Laptop-mode AMS already writes to{" "}
- ~/.config/loopover-miner directly and needs no override — only fleet mode's
- named volume does.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.troubleshooting.tsx b/apps/loopover-ui/src/routes/docs.troubleshooting.tsx
index 30a07d8f5a..ae800bc8ff 100644
--- a/apps/loopover-ui/src/routes/docs.troubleshooting.tsx
+++ b/apps/loopover-ui/src/routes/docs.troubleshooting.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { Callout, CodeBlock } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/troubleshooting.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/troubleshooting")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["troubleshooting"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Troubleshooting — LoopOver docs" },
@@ -26,103 +37,13 @@ export const Route = createFileRoute("/docs/troubleshooting")({
});
function Troubleshooting() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
Health checks
-
-
Or hit the public API endpoint directly to confirm reachability:
-
-
-
Self-host Docker observability
-
- The Docker stack exposes three different operator signals: structured logs from the{" "}
- loopover container, Prometheus metrics at /metrics, and optional
- OpenTelemetry traces through the observability profile. Metrics answer how much{" "}
- work is happening; traces answer where time went inside a review job.
-
-
-
- App traces are off unless OTEL_TRACES_EXPORTER includes otlp. When
- enabled, the self-host runtime exports durable queue-job spans and AI-provider attempt spans
- over OTLP/HTTP; the default collector endpoint is normalized to /v1/traces.
- Span attributes are bounded to operational labels such as job type, queue backend, provider,
- model, and request kind.
-
-
- Do not put request bodies, prompts, diffs, credentials, or private config in trace
- attributes. The built-in self-host spans intentionally avoid those fields.
-
-
-
Common issues
-
Login hangs on device flow
-
- Confirm you can reach github.com/login/device in your browser. Re-run{" "}
- loopover-mcp login and paste the new code.
-
-
-
“Stale fidelity” warning
-
- Upstream Gittensor changed. See Upstream drift for what
- that means and how to interpret cached analysis until fidelity recovers.
-
-
-
MCP not appearing in my editor
-
- Re-run the appropriate init-client command and restart the editor. See{" "}
- MCP client setup for per-editor config locations.
-
-
-
401 Unauthorized from the API
-
- Your LoopOver session expired. Run loopover-mcp login again. Static bearer
- tokens are not user-facing.
-
-
-
API status & offline mode
-
- The site continuously monitors the LoopOver API and surfaces problems through a banner under
- the header and a single deduped toast with a Recheck button.
-
-
You're offline
-
- We detected your browser is offline (navigator.onLine === false). Live API
- actions in the API reference are paused. Reconnect and the site auto-rechecks
- /health within a few seconds.
-
-
API unreachable
-
- /health couldn't be reached at all. This usually means a network problem
- between you and the API edge. Try the Recheck button in the banner, or run{" "}
- curl https://api.loopover.ai/health from your machine to confirm.
-
-
API timing out
-
- /health didn't respond within the 4-second probe window. The API may be slow or
- restarting. Retry — most timeouts resolve within a minute.
-
-
API degraded
-
- /health returned a non-2xx response. Some endpoints may still work; check the
- Roadmap for incident notes or wait for the recheck cycle to clear.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.tuning.tsx b/apps/loopover-ui/src/routes/docs.tuning.tsx
index dd2e5f438d..8b863bce5f 100644
--- a/apps/loopover-ui/src/routes/docs.tuning.tsx
+++ b/apps/loopover-ui/src/routes/docs.tuning.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/tuning.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/tuning")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["tuning"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Tuning your reviews — LoopOver docs" },
@@ -26,561 +37,13 @@ export const Route = createFileRoute("/docs/tuning")({
});
function Tuning() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
How configuration fits together
-
- LoopOver review is the engine that scores, gates, and comments on your pull
- requests. You shape its behavior in two places, and you never have to touch the review
- algorithm itself:
-
-
-
- Per-repo settings — gate modes, score thresholds, guardrails, and which
- surfaces are enabled. Set them in the dashboard, or declare them as config-as-code in a{" "}
- .loopover.yml file in the repo.
-
-
- Feature flags — the LOOPOVER_REVIEW_* family of environment
- variables on the worker. These switch whole capabilities (safety scanning, grounding, RAG
- context, the unified comment, the content lane, observability, self-tuning, and more) on
- or off for the deployment.
-
-
-
- The review algorithm — the deterministic gate, the scoring signals, the slop detector, the
- grounding and RAG context builders, and the comment renderer — is open source. Anyone can
- read exactly how a verdict is reached. The settings above sit on top of that open
- algorithm and never reveal review direction, so a contributor cannot read them and
- game the gate.
-
-
- This page covers those fields in depth, for the cloud service or a self-host alike. If
- you're running your own instance, see{" "}
- Self-host configuration for the
- environment layer (deployment-wide flags, secrets, and where config files can live) that
- sits underneath everything below.
-
-
-
- Every feature flag ships OFF. A repo with no settings and no{" "}
- .loopover.yml falls back to a quiet, non-blocking profile: the gate is{" "}
- off, AI review is off, slop scoring is off, comments
- go only to detected contributors, and no check-run is published. Turning anything on is
- always an explicit opt-in — you roll capabilities forward, and back, one flag and one repo
- at a time.
-
-
-
Precedence
-
Most specific wins:
-
-
- .loopover.yml in the repo, then
-
-
per-repo database settings, then
-
built-in safe defaults.
-
-
- Path holds are explicit config-as-code only: a configured{" "}
- settings.hardGuardrailGlobs ADDS repo-specific globs on top of a fixed set of
- built-in invariant guardrails that always apply and can never be disabled. Omitted or empty
- means only those built-in invariants hold.
-
-
- The friendly gate: block in .loopover.yml is a typed alias for the
- gate-related fields and wins over the generic settings: block for those same
- fields. LoopOver looks for the manifest at the first match of .loopover.yml →{" "}
- .github/loopover.yml → .loopover.json →{" "}
- .github/loopover.json.
-
-
-
Feature flags (LOOPOVER_REVIEW_*)
-
- These are worker environment variables, every one defaulting to OFF.
- "Truthy" means one of 1, true, yes, or{" "}
- on (case-insensitive); anything else — including unset, empty, or{" "}
- false — is OFF. When a flag is OFF its code path is inert: the review behaves
- exactly as if the feature did not exist.
-
-
- One flag is a scope rather than a capability:{" "}
- LOOPOVER_REVIEW_REPOS is a per-repo allowlist that must also pass for
- any per-PR feature to run on a given repo. So a per-PR feature activates only when{" "}
- its own flag is ON and the repo is allowlisted.
-
-
-
- LOOPOVER_REVIEW_REPOS — the per-repo allowlist. Comma-separated{" "}
- owner/repo names that may run the per-PR features (safety, grounding, RAG,
- reputation, unified comment). Empty or unset means no repos — every per-PR feature stays
- dormant for everyone regardless of the global flags. Case-insensitive and trimmed; stray
- commas are ignored. The cron and endpoint flags (ops, self-tune, parity audit, content
- lane, draft) are not scoped by this list.
-
-
- LOOPOVER_REVIEW_SAFETY — safety scan in the review path: it neutralizes
- prompt-injection in untrusted PR title/body/diff before the AI reviewer sees it, and scans
- the diff for leaked secrets, surfacing a secret_leak blocker. Per-PR (also
- needs the repo in the allowlist).
-
-
- LOOPOVER_REVIEW_GROUNDING — grounds the AI reviewer with the PR's{" "}
- finished CI status plus the full post-change content of the changed
- files, so the model verifies claims against reality instead of predicting CI or flagging
- symbols defined just outside the diff hunk. Per-PR.
-
-
- LOOPOVER_REVIEW_E2E_TESTS — master kill-switch for the opt-in,
- maintainer-triggered AI-generated E2E test coverage feature. Off by default; a repo also
- needs its own features.e2eTests: true override in .loopover.yml{" "}
- before the feature is active for it. Per-PR.
-
-
- LOOPOVER_REVIEW_IMPROVEMENT_SIGNAL — master kill-switch for the read-only,
- advisory PR quality-delta signal (the positive-axis counterpart to the slop risk score).
- Off by default; config-as-code activation only for now — no tier reads the resolved value
- yet, so turning this on has no visible effect until a later release wires real behavior
- behind it. Per-PR.
-
-
- LOOPOVER_REVIEW_CONTINUOUS — fleet-wide default AI review re-trigger cadence.
- Off by default (one-shot): AI-generated content (main review, slop advisory, linked-issue
- satisfaction) is produced once per PR and never regenerated automatically afterward — only
- an explicit maintainer retrigger (the PR-panel checkbox, or @loopover review{" "}
- as a maintainer) spends a fresh call. Truthy switches the fleet default to continuous —
- every push/CI-completion/sweep re-runs AI content generation. A repo's own{" "}
- review.auto_review.cadence in .loopover.yml always overrides
- this default, in either direction. Never affects the deterministic gate (CI status,
- mergeability, static-rule blockers), which always re-evaluates regardless.
-
-
- LOOPOVER_REVIEW_RAG — retrieval-augmented context: queries the codebase
- vector index for related code and docs (callers, related modules, existing conventions)
- and appends a "Relevant existing code / docs" section to the reviewer prompt. Additive
- only. Inert until a vector index exists for the repo — a cold or missing index degrades to
- no context. Per-PR.
-
-
- LOOPOVER_REVIEW_IMPACT_MAP — deterministic impact map: from the codebase
- vector index plus the PR's changed exported symbols, computes which other repo files
- plausibly need re-checking, and renders that as a compact section in the unified review
- comment (also feeds it to the AI reviewer as additive reference context). ANDed with the
- per-repo review.impact_map opt-in — neither alone is sufficient. Per-PR.
-
-
- LOOPOVER_REVIEW_CULTURE_PROFILE — appends a "repo quality-culture profile"
- reference block to the reviewer prompt: typical merged-PR size and common accepted labels,
- derived from this repo's own merge history. Additive reference only — never a gate or
- scoring input. Also requires the per-repo review.culture_profile: true opt-in
- in .loopover.yml. Per-PR.
-
-
- LOOPOVER_REVIEW_MEMORY — repeat-false-positive suppression: matches an
- advisory (non-blocking) AI finding against this repo's stored suppression signals (a
- maintainer's own past false-positive dismissals) and demotes or drops it before the
- unified comment renders. A maintainer records a signal with{" "}
- @loopover resolve [finding-code] (or a whole-PR{" "}
- @loopover resolve ack). Advisory-only by construction — never applied to gate
- blockers, so it can never change the merge/close disposition. Also requires the per-repo{" "}
- review.memory: true opt-in in .loopover.yml. Per-PR.
-
-
- LOOPOVER_REVIEW_REPUTATION — submitter-reputation spend control. A new,
- burst, or low-reputation submitter is downgraded to a deterministic-only review; good
- reputation proceeds normally. Never surfaced publicly — no comment, label, or check shows
- reputation. Per-PR.
-
-
- LOOPOVER_REVIEW_ENRICHMENT — runs the review-enrichment analyzer registry
- (duplication, churn hotspots, blame links, approval integrity, undocumented exports, and
- more) and folds their findings into the review context. Per-PR.
-
-
- LOOPOVER_REVIEW_INLINE_COMMENTS — posts AI-review findings as inline
- diff-anchored PR review comments instead of (or alongside) the summary comment. Per-PR.
-
-
- LOOPOVER_REVIEW_FIX_HANDOFF — renders a review finding as a structured,
- machine-readable "apply this fix" block for the contributor's own local agent to consume —
- content only, no server-side write, no execution. Per-PR.
-
-
- LOOPOVER_REVIEW_PLANNER — enables @loopover plan, an on-demand
- structured implementation plan posted to the PR thread. Per-PR.
-
-
- LOOPOVER_REVIEW_SCREENSHOTS — visual capture: renders and attaches
- before/after screenshots for PRs that change UI. Per-PR.
-
-
- LOOPOVER_REVIEW_OPS — observability, read-only. On the cron tick an anomaly
- scan over the gate-block ledger and calibration data emits a structured{" "}
- ops_anomaly log when something drifts, and a bearer-gated{" "}
- GET /v1/internal/ops/stats serves an outcome aggregate. Does not mutate
- config. Global.
-
-
- LOOPOVER_REVIEW_SELFTUNE — the self-improvement loop. On the cron tick it
- computes tuning recommendations from your own outcome data, shadow-soaks any strictly
- tightening recommendation, and auto-promotes it only after the soak passes. It can{" "}
- only ever tighten the gate — a loosening recommendation is never applied.
- Global, and safe to leave on.
-
-
- LOOPOVER_REVIEW_PARITY_AUDIT — parity readiness, shadow record-only. Records
- each finalized gate decision and serves a readiness report at{" "}
- GET /v1/internal/parity. Changes no review behavior. Global.
-
-
- LOOPOVER_REVIEW_CONTENT_LANE — routes content repos (curated lists,
- registries) through the dedicated content lane — duplicate detection, source-evidence
- reachability, security scanning, scope classification, registry grounding — instead of the
- code gate. Global.
-
-
- LOOPOVER_REVIEW_DRAFT — the public draft-submission flow (the{" "}
- /v1/drafts endpoints: contributor draft → GitHub OAuth → fork PR). With the
- flag off every draft endpoint 404s. Requires the{" "}
- DRAFT_TOKEN_ENCRYPTION_SECRET and GITHUB_OAUTH_CLIENT_SECRET{" "}
- secrets. Global.
-
-
- LOOPOVER_REVIEW_STATS_TOKEN — the bearer secret for the stats data endpoint.
- Not an on/off switch; it is the token value. When set, the stats route requires this
- bearer token.
-
-
-
-
- A safe rollout is two flips: turn the capability flag true, then add the repo
- to LOOPOVER_REVIEW_REPOS. Because both must be true, you can leave a capability
- globally enabled while it stays dormant everywhere except the repos you have explicitly
- allowlisted — and you roll a single repo back by removing it from the list without
- disturbing the others.
-
-
-
Gate modes
-
- Per-repo behavior is the effective settings: the database row for the repo,
- overlaid with the repo's .loopover.yml. Most gate dimensions are tri-state:
-
-
-
- off — the dimension is not evaluated.
-
-
- advisory — the finding is surfaced in the comment or context but never
- blocks.
-
-
- block — the finding can become a hard LoopOver Orb Review Agent{" "}
- blocker. A block outcome fails the gate for any author identically —
- confirmed-Gittensor-contributor status doesn't change who can be blocked,
- only the mode chooses which deterministic checks are active. Confirmed status is
- carried through for on-chain scoring, a separate concern from the gate's own
- merge/close decision.
-
-
-
- There is no single gate master switch — each dimension below is independently controlled by
- its own mode field (most default to off or advisory; see each
- dimension's default below). gate.enabled is a legacy, unrelated field: it is
- only a boolean shorthand for gate.checkMode (required /{" "}
- visible / disabled), which controls solely whether the{" "}
- LoopOver Orb Review Agent check-run publishes on GitHub. Neither field turns
- gate evaluation, comments, labels, audit, or autonomous merge/close on or off — set the
- dimension modes below directly, and set gate.checkMode explicitly instead of
- the ambiguous gate.enabled. The main dimensions:
-
-
-
- gate.pack — the policy pack: gittensor (default; registry-aware,
- tracks confirmed-Gittensor-contributor status for scoring) or oss-anti-slop{" "}
- (runs the deterministic rules against any author on any repo, with no
- confirmed-contributor tracking at all).
-
- gate.linkedIssue — what happens when a PR has no linked issue at all
- {". "}Default advisory (surfaced in the review panel, never blocks — issues
- aren't always available). Set block, or turn on the dashboard "Require
- linked issue" toggle, to make a missing issue an explicit opt-in blocker (if the toggle is
- on but this is still off, it is auto-promoted to block). This is
- unrelated to closing a PR that links an ineligible issue (owner-assigned, wrong
- label, etc.) — that is a separate, deterministic rule, not this gate.
-
-
- gate.readiness.mode — the PR-quality / merge-readiness score gate. Default{" "}
- advisory. Pair it with gate.readiness.minScore (0–100; at or
- above this score the quality dimension passes; null uses the engine's default
- band).
-
-
- gate.slop.mode — the deterministic anti-slop signal. Default off{" "}
- (opt-in). advisory surfaces the slop score and warnings; block{" "}
- also hard-blocks at or above gate.slop.minScore (0–100; null{" "}
- uses 60, the "high" band). Set gate.slop.aiAdvisory: true to add
- a free advisory-only ai_slop_advisory finding — it never feeds the slop score
- or the gate.
-
-
- gate.copycat.mode — code containment/similarity gate against prior art
- (earlier open or recently merged PRs on the same repo). Default off.
- Escalating tiers: warn surfaces an advisory finding only; label{" "}
- also applies a label; block also closes the PR and counts toward the
- repeat-offender strikes ledger. Pair it with gate.copycat.minScore (0–100;{" "}
- null uses the engine default, 85). Direction is always by
- submission timestamp, so the earlier (original) author is never flagged.
-
-
- gate.mergeReadiness — composite merge-readiness gate. Default{" "}
- off, no min score.
-
-
- gate.manifestPolicy — when block, the manifest's declared policy
- (required linked issue and test expectations) becomes an enforceable blocker.
- Manual-review path holds use settings.hardGuardrailGlobs instead. Default{" "}
- off.
-
- gate.selfAuthoredLinkedIssue — whether a PR may link an issue opened by the
- same author. Default advisory.
-
-
- gate.linkedIssueSatisfaction — an AI assessment of whether the PR's diff
- actually satisfies its primary linked issue's intent, distinct from{" "}
- gate.linkedIssue (which only checks a link exists). Default off.{" "}
- advisory renders the assessment in the review comment without blocking;{" "}
- block additionally lets a confidence-floor-passing "unaddressed" verdict
- become a blocker.
-
-
- settings.moderationGateMode — whether the moderation-rules engine
- (contributor cap, blacklist, review-nag feeding a shared cross-repo violation tally) runs
- on this repo at all. inherit (default) defers to the instance-wide{" "}
- global_moderation_config.enabled; off/enabled force
- this repo regardless of the global default.
-
-
- gate.aiReview.mode — AI review. Default off.{" "}
- advisory posts AI review notes only; block lets a dual-model
- high-confidence consensus defect become a blocker.
-
-
-
-
Bring your own model (AI review)
-
- The AI-review write-up can optionally use your own frontier model. By default the blocking
- decision runs on a pair of free built-in models and requires agreement; an operator can
- override this per repo with aiReviewCombine (single /{" "}
- consensus / synthesis) — in single mode, one
- reviewer's verdict is the decision. BYOK changes which model writes the advisory text, not
- this combine behavior.
-
-
-
- gate.aiReview.byok — when true and a provider key is configured,
- the advisory write-up uses the maintainer's frontier model. Default false.
-
-
- gate.aiReview.provider — anthropic, openai, or{" "}
- null (use the stored key's own provider). Must match the stored key's
- provider or BYOK is skipped and falls back to the built-in pair.
-
-
- gate.aiReview.model — model override for the BYOK write-up (for example{" "}
- claude-3-5-sonnet-latest); null uses the key record's model,
- else a conservative per-provider default.
-
-
-
- The provider key itself never lives in .loopover.yml. It is held only in the
- encrypted key store and unlocked by the TOKEN_ENCRYPTION_SECRET worker secret —
- absent that secret, BYOK is unavailable and AI review silently falls back to the free
- built-in model pair.
-
-
-
Guardrails and scope
-
- Top-level keys in .loopover.yml declare the repo's focus and validation
- expectations. These feed deterministic findings such as manifest_missing_tests{" "}
- and — when gate.manifestPolicy: block — can become enforceable blockers. Manual
- path holds are configured only through settings.hardGuardrailGlobs.
-
-
-
- wantedPaths — globs for work areas you want; PRs touching these are
- preferred. Default [].
-
-
- preferredLabels — labels you prefer on incoming PRs; a missing one is
- surfaced. Default [].
-
-
- linkedIssuePolicy — required / preferred /{" "}
- optional. How strongly a linked issue is expected. Default{" "}
- optional.
-
-
- testExpectations — test paths expected to change with code; a{" "}
- manifest_missing_tests finding fires when absent. Default [].
-
- maintainerNotes — private review context, never published to any public
- GitHub surface. Default [].
-
-
- publicNotes — notes explicitly opted into public output (public-safe
- filtered; unsafe lines are dropped). Default [].
-
-
-
-
Other repo settings
-
- Anything you can toggle in the dashboard can also be set as code under{" "}
- settings: in .loopover.yml. Common ones, all defaulting to the
- safe values shown:
-
- autoLabelEnabled (default true), gittensorLabel{" "}
- (default gittensor), and createMissingLabel (default{" "}
- true) — the base per-PR context label, shown to the public surface.
-
-
- typeLabelsEnabled (default true) and typeLabels — a
- separate, independent taxonomy label family: internal triage metadata gated by its own
- toggle, not by autoLabelEnabled above. typeLabels is an open{" "}
- category → label name map, not fixed to any specific set — the built-in{" "}
- bug/feature/priority categories default to{" "}
- gittensor:bug/gittensor:feature/gittensor:priority{" "}
- (examples, not required names), and you can add any number of your own categories (e.g.{" "}
- security: area:security) for your own taxonomy. An explicit{" "}
- typeLabels: {"{}"} means zero configured categories for the repo.
-
- A worked manifest: focus and validation up top, a refined gate, BYOK AI review, and a few
- dashboard-equivalent overrides.
-
-
-
-
- Start conservative: enable the gate in advisory before block,
- watch the surfaced findings, and only then tighten. Combined with the tightening-only
- self-tune loop, this keeps the gate from ever blocking a contributor on a setting you have
- not validated.
-
-
-
- For the privacy guarantees behind these surfaces, see{" "}
- Privacy & security. For the maintainer install and
- trust flow, see Install & trust. If you're
- self-hosting, see Self-host configuration{" "}
- for the environment layer these settings sit on top of, plus the config-precedence rules and
- a link to the fully-commented .loopover.yml.example.
-
+
+ Loading…}>
+
+
);
}
diff --git a/apps/loopover-ui/src/routes/docs.upstream-drift.tsx b/apps/loopover-ui/src/routes/docs.upstream-drift.tsx
index 030287cf65..9ac2e85ab4 100644
--- a/apps/loopover-ui/src/routes/docs.upstream-drift.tsx
+++ b/apps/loopover-ui/src/routes/docs.upstream-drift.tsx
@@ -1,9 +1,20 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, notFound } from "@tanstack/react-router";
+import { Suspense } from "react";
import { DocsPage } from "@/components/site/docs-page";
-import { CodeBlock, Callout } from "@/components/site/primitives";
+import { docsClientLoader } from "@/lib/docs-client-loader";
+// Rendered from content/docs/upstream-drift.mdx via fumadocs-mdx's browser entry
+// (docsClientLoader), through the existing DocsPage/Callout/CodeBlock primitives --
+// not fumadocs-ui's bundled components. See docs-source.ts's comment for why the
+// loader below resolves only a plain, serializable path string.
export const Route = createFileRoute("/docs/upstream-drift")({
+ loader: async () => {
+ const { docsSource } = await import("@/lib/docs-source");
+ const page = docsSource.getPage(["upstream-drift"]);
+ if (!page) throw notFound();
+ return { path: page.path, title: page.data.title, description: page.data.description };
+ },
head: () => ({
meta: [
{ title: "Upstream drift — LoopOver docs" },
@@ -26,53 +37,13 @@ export const Route = createFileRoute("/docs/upstream-drift")({
});
function UpstreamDrift() {
+ const { path, title, description } = Route.useLoaderData();
+ const Content = docsClientLoader.getComponent(path);
return (
-
-
How drift works
-
- LoopOver stores versioned snapshots of the Gittensor source and ruleset from{" "}
-
- entrius/gittensor:test
-
- . Semantic payloads are hashed so we can detect scoring, registry, or issue-discovery drift
- without re-deriving the whole world.
-
-
-
- Upstream relationship.entrius/gittensor is the upstream
- project LoopOver analyzes. LoopOver is{" "}
-
- jsonbored/loopover
- {" "}
- — an independent base-agent layer for the Gittensor ecosystem, not affiliated with the
- official subnet.
-
-
-
Signal fidelity vs readiness
-
- The API distinguishes service health from data quality. Readiness can be green while signal
- fidelity is stale, degraded, or blocked. The MCP
- surfaces fidelity in every response so agents don't act on stale assumptions.
-