diff --git a/web/content/docs/file/api-reference.mdx b/web/content/docs/file/api-reference.mdx
index 9457aee..9c0eb37 100644
--- a/web/content/docs/file/api-reference.mdx
+++ b/web/content/docs/file/api-reference.mdx
@@ -65,12 +65,13 @@ curl -sS \
### Write a file
-`PUT /v1/workspaces/{workspaceId}/fs/file` creates or updates one file.
+`PUT /v1/workspaces/{workspaceId}/fs/file` creates or updates one file. It requires **`If-Match`** as well as the usual `X-Correlation-Id`: without it the request fails `412 precondition_failed`. `If-Match: *` is create-or-overwrite; pass a revision for a conditional write.
```bash
curl -sS -X PUT \
-H "Authorization: Bearer ${RELAYFILE_TOKEN}" \
-H "X-Correlation-Id: ${RELAYFILE_CORRELATION_ID}" \
+ -H "If-Match: *" \
-H "Content-Type: application/json" \
"${RELAYFILE_BASE_URL}/v1/workspaces/${RELAYFILE_WORKSPACE}/fs/file" \
-d '{ "path": "/docs/guide.md", "content": "# agent guide", "contentType": "text/markdown" }' | jq .
diff --git a/web/content/docs/file/cli.mdx b/web/content/docs/file/cli.mdx
index 331b64f..2a87957 100644
--- a/web/content/docs/file/cli.mdx
+++ b/web/content/docs/file/cli.mdx
@@ -56,20 +56,18 @@ The default path delegates to `agent-relay login`. `--api-key` keeps the self-ho
## `relayfile seed`
-Bulk-upload a local directory into a workspace, respecting `.gitignore` and `--exclude` patterns.
+Bulk-upload a local directory into a workspace, respecting `.gitignore`.
```bash
relayfile seed my-workspace ./src
```
-| Flag | Default | Description |
-|---|---|---|
-| `--exclude` | (none) | Glob patterns to exclude (repeatable) |
-| `--dry-run` | `false` | List files that would upload, without uploading |
-| `--batch-size` | `50` | Files per bulk API request |
-
It walks the directory and posts batches to the bulk write endpoint, printing progress.
+
+ Two limits as of `0.10.41`: `seed` takes no flags — `--dry-run`, `--exclude`, and `--batch-size` are rejected as undefined — and it resolves credentials only from `~/.relayfile/credentials.json`, so it does not work from a relay Cloud session (`relayfile login --api-key`, or `--token`, is required). `relayfile ops list` has the same credential limitation and degrades to local-only results with a warning.
+
+
## `relayfile tree`
List a remote workspace path without mounting.
@@ -85,6 +83,55 @@ relayfile tree my-workspace / --depth 2
Prints a compact human-readable tree by default; `--json` is for scripts. `relayfile read ` (alias `relayfile cat`) reads a single file the same way.
+## `relayfile listen`
+
+Stream the workspace event feed, optionally running a command per event.
+
+```bash
+relayfile listen \
+ --path "/linear/issues/by-state/triage/**" \
+ --event file.created \
+ --run "claude --print 'Triage this: {{path}}'"
+```
+
+| Flag | Default | Description |
+|---|---|---|
+| `--provider` | all | Only events from one provider |
+| `--path` | all | Path glob to filter on |
+| `--event` | all | Event type (`file.created`, `file.updated`, `file.deleted`) |
+| `--run` | (none) | Command to execute per event, with placeholder substitution (below) |
+| `--format` | `text` | `text` or `json` (one event object per line) |
+| `--background` | `false` | Detach and keep listening |
+
+`--run` substitutes five placeholders into the command:
+
+| Placeholder | Expands to | Example |
+|---|---|---|
+| `{{path}}` | the changed path | `/runs/pr-59/findings/security.json` |
+| `{{type}}` | the event type | `file.created` |
+| `{{provider}}` | the owning provider | `github` |
+| `{{revision}}` | the file's revision | `rev_2936535` |
+| `{{event}}` | **the whole event**, as space-separated `key:value` pairs | `type:file.created path:/runs/… revision:rev_… eventId:evt_… origin:agent_write …` |
+
+
+ Quote `{{event}}`. It expands to the entire event — a dozen or more space-separated tokens — so an unquoted `--run "my-agent --event {{event}}"` splatters them across `argv`. Write `--run "my-agent --event '{{event}}'"`. The single-value placeholders are passed as one argument each.
+
+
+Events carry `eventId`, `type`, `path`, `revision`, `provider`, `origin`, `correlationId`, and — for small files — the content inlined. See [Events and webhooks](/docs/file/events).
+
+To keep a subscriber running across reboots, `relayfile supervisor install` accepts every `listen` flag and embeds them verbatim into a launchd (macOS) or systemd (Linux) unit that restarts on failure:
+
+```bash
+relayfile supervisor install \
+ --path "/linear/issues/by-state/triage/**" --event file.created \
+ --run "claude --print 'New triage issue at {{path}}. Assign it.'"
+relayfile supervisor status
+```
+
+
+ `listen` is missing from `relayfile --help` and `relayfile help listen` prints the generic help, but `relayfile listen --help` shows its usage. Expect to supervise it: on a busy workspace the stream can end mid-message, and reconnecting immediately earns a `429` on the WebSocket handshake — run it with `--background` or under `relayfile supervisor install`, and back off between reconnects.
+
+
## `relayfile mount`
Mount a workspace to a local directory, syncing changes in real time. This replaces the standalone daemon for end users.
@@ -138,10 +185,19 @@ See [Run locally](/docs/file/run-locally) for the daemon in context and [Local d
| `relayfile export` | Download a snapshot (`--format tar\|json\|patch`) |
| `relayfile status` | Per-provider sync state, lag, conflicts, denials |
| `relayfile integration connect / list / disconnect` | Manage provider integrations after setup |
+| `relayfile integration available / search` | Browse or search the live provider catalog (`--refresh`, `--json`) |
+| `relayfile integration set-metadata` | Replace flat provider metadata (Jira/Confluence `cloudId`) |
+| `relayfile integration bind / unbind` | Route a provider path glob to a channel webhook |
| `relayfile pull` | Force a reconcile of a path or the whole workspace |
-| `relayfile permissions` | Show writable paths and expected schema for a path |
+| `relayfile writeback status / list / retry` | Local pending, failed, and dead-lettered writebacks |
| `relayfile ops list / replay` | Inspect and replay dead-lettered writeback ops |
+| `relayfile restart` / `relayfile supervisor` | Restart a mount, or install it as a launchd/systemd service |
| `relayfile stop` / `relayfile logs` | Control and read a background mount daemon |
+| `relayfile observer` | Open the hosted file observer for a workspace |
+
+
+ There is no `relayfile permissions` command. To find out what a path expects, read the provider's `.adapter.md` and `.schema.json` under `/discovery//…`; to see what was denied, read `.relay/state.json` → `deniedPaths` in the mirror.
+
## Global flags
diff --git a/web/content/docs/file/events.mdx b/web/content/docs/file/events.mdx
index 0ae2bd4..22eab11 100644
--- a/web/content/docs/file/events.mdx
+++ b/web/content/docs/file/events.mdx
@@ -23,16 +23,24 @@ Every change — webhook, sync, or another agent's write — surfaces as the sam
```json
{
- "eventId": "evt_01HQ8K7M2YV3R0XW9F4ZB6T2QA",
- "type": "file.updated",
- "path": "/linear/issues/AGE-16__87389837-62b1-4e1a-a237-59218bab2974.json",
- "revision": "rev_42",
- "provider": "linear",
- "origin": "provider_sync",
- "timestamp": "2026-05-13T14:32:01Z"
+ "eventId": "evt_2507297",
+ "type": "file.created",
+ "path": "/runs/pr-59/findings/security.json",
+ "revision": "rev_2935117",
+ "provider": "runs",
+ "origin": "agent_write",
+ "contentType": "application/json",
+ "contentHash": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
+ "content": "{}",
+ "inlineContent": true,
+ "encoding": "utf-8",
+ "correlationId": "lt-1-1788520629",
+ "timestamp": "2026-09-04T11:17:09.694Z"
}
```
+Small files arrive with their content inlined (`inlineContent: true`), so a handler often needs no follow-up read. `correlationId` carries through from the write that caused the event, which is how you tie an event back to the request that produced it.
+
- **`type`** is one of `file.created`, `file.updated`, `file.deleted`.
- **`path`** is the canonical file that changed. The path itself carries context — you know which provider and which record without parsing a payload.
- **`revision`** monotonically increases per file. Use it to order events and to fetch the prior state for a diff.
@@ -49,13 +57,15 @@ relayfile listen \
--run "claude --print 'Triage this: {{path}}'"
```
+`relayfile listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] [--background]` streams the workspace's event feed. `--run` substitutes `{{path}}`, `{{type}}`, `{{provider}}`, and `{{revision}}` as single values, plus `{{event}}` for the whole event as space-separated `key:value` pairs — quote that one, or its dozen-odd tokens splatter across the command's arguments. `--format json` prints one event object per line, for piping into anything that isn't a shell command. To fan events out to a channel instead of a local process, bind the glob to a webhook with `relayfile integration bind --channel … --webhook … --webhook-token …`.
+
Or from the SDK with `onWrite`, which subscribes over the same WebSocket stream and dispatches by pattern:
```typescript
import { onWrite } from '@relayfile/sdk';
onWrite('/linear/issues/**', async (event) => {
- if (event.source === 'agent') return; // ignore our own writes
+ if (event.origin === 'agent_write') return; // ignore our own writes
await agent.handle(event);
}, { client, workspaceId, operations: ['create', 'update'] });
```
@@ -67,6 +77,7 @@ See [Agents](/docs/file/agents) for the framework helpers built on this.
- **At-least-once.** Events can repeat. Deduplicate on `eventId`; treat handlers as idempotent.
- **Ordering.** Per file, `revision` is the source of truth — wall-clock `timestamp` can be close together under bursty traffic.
- **Catch-up.** A subscriber that connects with a cursor receives the events it missed while disconnected, so a restart doesn't drop changes. If the WebSocket can't open, the SDK degrades to HTTP polling rather than going silent.
+- **Reconnect.** Long-lived subscribers do get dropped — a busy workspace can end a stream mid-message — and a reconnect storm is answered with `429` on the WebSocket handshake. Supervise the subscriber (`relayfile listen --background`, or `relayfile supervisor install`) and back off between reconnects rather than looping immediately.
diff --git a/web/content/docs/file/mount-layout.mdx b/web/content/docs/file/mount-layout.mdx
index 6264e71..acab8fd 100644
--- a/web/content/docs/file/mount-layout.mdx
+++ b/web/content/docs/file/mount-layout.mdx
@@ -1,18 +1,18 @@
---
title: 'Mount layout'
-description: 'Every Relayfile mount is self-describing: LAYOUT.md, per-integration layout files, _index.json, canonical naming, and four alias views.'
+description: 'Every Relayfile mount is self-describing: LAYOUT.md, per-integration layout files, _index.json, canonical naming, and per-adapter alias views.'
---
-Every mount is self-describing. The agent never needs to learn paths from external documentation — `cat mount/LAYOUT.md` lists everything, and per-integration `.layout.md` files document the tree shape for each provider. This is deliberate: an agent oriented by reading the tree itself doesn't carry path knowledge in its prompt or schema.
+Every mount is self-describing. The agent never needs to learn paths from external documentation — `cat mount/LAYOUT.md` lists everything, and per-integration `LAYOUT.md` files document the tree shape for each provider. This is deliberate: an agent oriented by reading the tree itself doesn't carry path knowledge in its prompt or schema.
## The tree
```
mount/
-├── LAYOUT.md # virtual, read-only — top-level guide
+├── LAYOUT.md # top-level guide: every provider root
├── _index.json # root listing
├── linear/
-│ ├── .layout.md # linear-specific tree shape
+│ ├── LAYOUT.md # linear-specific tree shape
│ ├── issues/
│ │ ├── _index.json
│ │ ├── AGE-12__fix-login-bug.json # canonical: __
@@ -20,12 +20,15 @@ mount/
│ │ ├── by-id/AGE-12.json
│ │ └── by-state/in-progress/AGE-12__fix-login-bug.json
│ └── users/by-name/dana.json
-└── github/
- └── repos/
- ├── _index.json
- ├── acme/api/
- │ └── pulls/42__bump-deps/meta.json
- └── by-name/acme__api.json
+├── github/
+│ ├── LAYOUT.md
+│ └── repos/
+│ ├── _index.json
+│ └── acme/api/
+│ ├── pulls/_index.json
+│ └── pulls/42__bump-deps/meta.json # __
+└── github/repos/acme__api/ # flat alias namespace
+ └── pulls/by-id/42.json
```
## Self-describing files
@@ -33,12 +36,12 @@ mount/
Three kinds of metadata files make the tree navigable without prior knowledge:
- **`LAYOUT.md`** at the root is a virtual, read-only guide to the top-level structure. It's the first thing an agent should `cat` after mounting.
-- **`/.layout.md`** documents the tree shape for a single provider — what directories exist under `linear/`, what each holds.
+- **`/LAYOUT.md`** documents the tree shape for a single provider — what directories exist under `linear/`, what each holds, and which paths are artifacts rather than records. Uppercase, no leading dot.
- **`_index.json`** in every directory lists the rows that directory contains, so an agent can read one file to understand a directory rather than walking every entry.
## Canonical naming
-Entity files follow a `__` convention so identifiers are recoverable from any filename. The ID is always the **last** `__`-separated segment:
+Entity files put an identifier and a human-readable name in one filename, separated by `__`, so identifiers are recoverable from a listing. **Which side the id sits on is per-adapter**: Linear writes `__` (id last), GitHub writes `__` (id first). Read the provider's `LAYOUT.md` rather than assuming, and prefer `_index.json` rows for lookup:
```text
inbox/threads/Re_Welcome__01HXYZ.json
@@ -48,19 +51,21 @@ inbox/threads/01HXYZ.json
The first path is the canonical human-readable form. The second is a legacy fallback that remains readable during the naming transition — consumers should accept both the new-style filename and the bare `` basename while producers are updated.
- Because the ID is recoverable from the filename, an agent can read `AGE-12__fix-login-bug.json`, derive the identifier `AGE-12`, and write back to the canonical path without a separate lookup.
+ Take the exact filename from a listing or `_index.json` rather than assembling one. Some adapters require the full `__` directory on reads — for GitHub, `pulls/59/meta.json` returns `404` while `pulls/59__/meta.json` resolves — and the same adapter may still accept a bare id on the write path.
-## The four alias views
+## Alias views
-Each resource type ships four alias views out of the box, so an agent can navigate the same records by whichever key it has in hand:
+Alias views let an agent navigate the same records by whichever key it has in hand — `by-id/`, `by-title/`, `by-state/`, `by-edited/`, `by-assignee/`, `by-creator/`, `by-priority/`, `by-name/`, `by-uuid/`. They are views over the same canonical entities, not copies you have to keep in sync.
-- **`by-title/`** — slug lookups (`AGE-12-fix-login-bug.json`).
-- **`by-id/`** — identifier lookups (`AGE-12.json`).
-- **`by-name/`** — human-readable name lookups (`dana.json`, `acme__api.json`).
-- **`by-state/`** — grouped by issue or PR state (`by-state/in-progress/...`).
+**Which views exist, and where they live, is decided by the adapter and the resource** — there is no fixed set. Two things to check in the provider's `LAYOUT.md` before building a path:
-These are views over the same canonical entities, not copies you have to keep in sync. An agent that knows only a state can `ls by-state/in-progress/`; one that knows only a name can read `by-name/dana.json`.
+- **Location.** Linear puts them under the canonical subtree (`/linear/issues/by-id/AR-100.json`). GitHub puts them in a *flat sibling namespace* keyed `__` (`/github/repos/acme__api/pulls/by-id/42.json`) — there is no `by-name` under `/github/repos/`.
+- **Set.** On a live workspace, GitHub pulls expose `by-creator`, `by-edited`, `by-id`, `by-state`, and `by-title`; GitHub issues expose four of those; Linear issues add `by-assignee`, `by-priority`, and `by-uuid`.
+
+
+ A flat alias directory also has an `_index.json`, and it means something different: it is a manifest of which alias subdirectories exist (`{ "rows": [ { "title": "by-id", "file": "by-id/" } ] }`), not a record index. Only the canonical tree's `_index.json` lists records, as a bare JSON array.
+
## Lazy repo materialization
diff --git a/web/content/docs/file/quickstart.mdx b/web/content/docs/file/quickstart.mdx
index f5606e1..d28ec0f 100644
--- a/web/content/docs/file/quickstart.mdx
+++ b/web/content/docs/file/quickstart.mdx
@@ -50,6 +50,10 @@ grep -rl '"state":"Todo"' ./relayfile-mount/linear/issues/
See [Reads and writes](/docs/file/reads-and-writes) for the full PATCH / CREATE / DELETE model.
+
+ Guide: build a PR review bot — add providers, mount the same workspace in every sandbox, and post the review by writing a file.
+
+
## Next steps
diff --git a/web/content/docs/file/reads-and-writes.mdx b/web/content/docs/file/reads-and-writes.mdx
index 147d8ac..af87e7d 100644
--- a/web/content/docs/file/reads-and-writes.mdx
+++ b/web/content/docs/file/reads-and-writes.mdx
@@ -56,13 +56,16 @@ Writes are durable: they're recorded in Relayfile's writeback queue and processe
## Discovering schemas in-tree
-You don't need an out-of-band schema registry. Per-resource schemas are discoverable in the tree itself at `/.schema.json`:
+You don't need an out-of-band schema registry. Every writable resource advertises a JSON Schema and a create example in the workspace's own `/discovery` tree, whose paths carry literal placeholder segments:
```bash
-cat mount/linear/issues/.schema.json
+cat mount/discovery/github/.adapter.md # operations + ID patterns
+cat mount/discovery/linear/issues/.schema.json # full record schema
+cat mount/discovery/linear/issues/.create.example.json # minimal create payload
+cat "mount/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.schema.json"
```
-An agent that wants to create or patch a record reads the adjacent schema file to learn the expected shape, then writes a conforming JSON file. This keeps the contract co-located with the data — the same self-describing principle as [`LAYOUT.md` and `.layout.md`](/docs/file/mount-layout).
+`.adapter.md` is the per-provider contract: which resources are writable, the ID pattern that decides whether a filename is a canonical record or a create draft, and which operations each resource supports. `.schema.json` is JSON Schema draft 2020-12 for the full synced record; fields marked `readOnly` are server-managed and are rejected on write. Read them before composing a payload — the same self-describing principle as [`LAYOUT.md`](/docs/file/mount-layout).
The exact write semantics and field mapping per provider are defined by the [adapters](/docs/file/adapters-and-providers), which own webhook-to-path mapping and writeback behavior.
diff --git a/web/content/docs/file/review-bot-brief.mdx b/web/content/docs/file/review-bot-brief.mdx
new file mode 100644
index 0000000..4d5460f
--- /dev/null
+++ b/web/content/docs/file/review-bot-brief.mdx
@@ -0,0 +1,193 @@
+---
+title: 'Review bot agent brief'
+description: 'A complete, copy-paste operating document for a Relayfile-backed PR review agent — drop it in as AGENTS.md, CLAUDE.md, or a skill and hand it to the agent.'
+---
+
+
+ **This whole page is the brief.** Everything below is addressed to the agent, not to you. Take it with **Copy page as markdown** (top right) or `curl -o AGENTS.md https://agentrelay.com/docs/file/markdown/review-bot-brief.md`.
+
+ Before handing it over, replace every `` placeholder — `/` and the specialist name are the two you set once, and ``, ``, ``, ``, ``, ``, and `` are filled per run or per finding. Verified against `relayfile` 0.10.53; it pairs with [Build a PR review bot](/docs/file/review-bot).
+
+
+You are a specialist agent in a multi-agent pull request review. Your inputs, your peers' outputs, and the review you publish are all files in a Relayfile workspace. Read and write files; do not call GitHub, Linear, Notion, or Slack APIs directly, and do not ask for provider tokens — you don't have any and don't need any.
+
+## Your environment
+
+| Variable | What it is |
+|---|---|
+| `RELAYFILE_LOCAL_DIR` | The mount root, when you have a mirror. Every path below is relative to it. |
+| `RELAYFILE_WORKSPACE` | The `rw_…` workspace id. Use it verbatim; never substitute another id. |
+| `RELAYFILE_TOKEN` | Your scoped workspace token, already narrowed to the paths you may touch. |
+| `RELAYFILE_BASE_URL` | The API base, for when you have no mirror. |
+
+With a mirror, `ls`, `cat`, `grep`, `find`, and ordinary writes all work. Without one, `relayfile tree ` and `relayfile read ` are the same operations against the server. Provider records are already current — the file is materialized before you're told about it, so never fetch a record from a provider API to "get the current version".
+
+## Orient before you act
+
+Do this first, every run. Do not carry path knowledge between runs and do not guess paths:
+
+```bash
+cat "$RELAYFILE_LOCAL_DIR/LAYOUT.md" # top-level guide: every provider root
+cat "$RELAYFILE_LOCAL_DIR/github/LAYOUT.md" # the GitHub adapter's own contract
+```
+
+The provider layout file is `/LAYOUT.md` — uppercase, no leading dot.
+
+Then read the index rather than walking directories. In the canonical tree, `_index.json` is a **bare JSON array**; pull rows carry `number`, `state`, `labels`, and `headRef`, which is usually enough to choose a PR without opening a record:
+
+```bash
+jq '.[] | select(.state=="open") | {number, title, headRef}' \
+ "$RELAYFILE_LOCAL_DIR/github/repos///pulls/_index.json"
+```
+
+Four naming rules that will otherwise cost you a run:
+
+1. GitHub record directories are **`__`, number first**. Linear uses `__`, id last. There is no single rule — read the provider's `LAYOUT.md`.
+2. **Reads need the full directory name.** `pulls//meta.json` returns 404; only `pulls//meta.json` resolves. Take `` from the index or a listing; never assemble it.
+3. **Records are envelopes.** A record is `{ provider, objectType, objectId, deleted, payload }`. The provider's object is under `payload` — `jq .payload.state`, not `jq .state`.
+4. Alias views live in a flat sibling namespace for GitHub — `/github/repos/__/pulls/by-id/.json`, not under the canonical repo path — and under the canonical subtree for Linear (`/linear/issues/by-id/.json`). Which views exist varies by adapter and resource, so list the alias directory rather than assuming one.
+
+## What you may read and write
+
+- **Read** the provider trees you've been granted — typically `/github/**`, plus `/linear/**` and `/notion/**` when the run needs the ticket and the team's standards.
+- **Write** only under `/runs/**`, and — if you are the publishing agent — the one review path named below.
+- **Never write anything under `.relay/`.** It is daemon state.
+- **Never write `merge.json` or `close.json`** under a pull request. Those are live write resources: they merge or close the PR.
+
+A write outside your grant is rejected, not silently dropped, and the path is recorded in `.relay/state.json` under `deniedPaths`. If that happens, stop and report it — do not route around it.
+
+## Read the pull request
+
+```bash
+MOUNT="$RELAYFILE_LOCAL_DIR"
+R="$MOUNT/github/repos//"
+
+jq '.[] | {number, state, title}' "$R/pulls/_index.json" # pick the PR
+cat "$R/pulls//meta.json" | jq '.payload | {number, title, state}'
+```
+
+`diff.patch` and `files/**` may exist under the PR directory as artifacts — useful to read, but they are not canonical records. Then pull the context a good review needs, when those providers are mounted:
+
+```bash
+cat "$MOUNT/linear/issues/by-id/.json" | jq '.payload.title'
+grep -rl 'review checklist' "$MOUNT/notion/pages/"
+```
+
+`grep` searches every synced record, not a paginated API result. Prefer it over guessing which file holds something.
+
+## The run protocol
+
+One directory per review run. You are one writer among several, so stay inside your own file:
+
+```text
+/runs//
+├── recon.md # written once by the orchestrator; read-only to you
+├── findings/
+│ ├── security.json # one file per specialist — write only your own
+│ ├── quality.json
+│ └── tests.json
+└── review.json # the merged review, written by the publishing agent
+```
+
+`/runs/**` has no adapter behind it, so writes there persist and raise events without queueing any provider call.
+
+- **Read `recon.md` first.** It maps the PR — files touched, what they export, who imports them, the ticket. It was produced once so every specialist doesn't re-derive it. If it's missing, wait rather than duplicating the work.
+- **Write exactly one findings file**, named for your specialty. One file each makes lost writes impossible.
+- **Write it once, when you're done.** The orchestrator publishes each findings file the moment it appears and does not wait for the slowest specialist, so a partial file read as final is a real failure mode.
+
+Use this shape:
+
+```json
+{
+ "specialist": "security",
+ "commit": "",
+ "model": "",
+ "findings": [
+ {
+ "path": "src/auth/session.ts",
+ "line": 42,
+ "severity": "high",
+ "title": "Session token compared with ==",
+ "body": "Timing-unsafe comparison. Use a constant-time compare.",
+ "confidence": "high"
+ }
+ ]
+}
+```
+
+Record the commit you actually reviewed. A review attached to the wrong commit is worse than no review.
+
+## Publish the review
+
+Only the publishing agent does this. Discover the contract first — discovery documents live in their own tree with literal placeholder segments, not beside the records:
+
+```bash
+cat "$MOUNT/discovery/github/.adapter.md"
+D="$MOUNT/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}"
+cat "$D/reviews/.schema.json"
+cat "$D/reviews/.create.example.json"
+```
+
+`.adapter.md` names every writable resource, its ID pattern, and what a draft becomes. For a review the payload requires **`event`, `body`, and `comments`** — omitting `comments` fails validation. `event` is one of `APPROVE`, `REQUEST_CHANGES`, `COMMENT`. Fields marked `readOnly` in the schema are rejected.
+
+Write the payload to a **non-canonical filename** in the resource directory — any name that doesn't match the resource's ID pattern (`^\d+$` for reviews) is treated as a create draft:
+
+```bash
+cat > "$R/pulls//reviews/draft-.json" <<'JSON'
+{ "event": "COMMENT", "body": "…", "comments": [] }
+JSON
+```
+
+**The write path uses the bare pull number**, even though reads need ``. This asymmetry is real; don't derive one path from the other.
+
+Scratch names `partial.json`, `.tmp.json`, `*.tmp.json`, and `*.partial.json` are ignored and never become drafts.
+
+## Verify the write landed
+
+A queued write is not a delivered write. The adapter rewrites your draft into a receipt naming the real record — read the file back until `created` appears:
+
+```bash
+cat "$R/pulls//reviews/draft-.json"
+# { "created": 5112118049, "id": "5112118049", "url": "https://github.com/…#pullrequestreview-5112118049" }
+```
+
+That receipt is the proof, not the fact that the write succeeded locally. For writes made through a mirror you can also check the queue:
+
+```bash
+relayfile writeback status "$RELAYFILE_WORKSPACE" --json
+```
+
+It reports the local mirror's `pending`, `failed`, and `dead-lettered` counts. If an op dead-lettered, read `.relay/dead-letter/.json` for `lastStatus` and `lastBody`, fix the cause — usually a payload that doesn't match the schema — then `relayfile writeback retry --opId `. Do not retry unchanged, repeatedly.
+
+## When something looks wrong
+
+| Symptom | What it means | Do this |
+|---|---|---|
+| `404 not_found` on a record | You assembled a path instead of reading it | Take the exact name from `_index.json` or a listing |
+| `jq` returns null on a field | You read the envelope, not the record | Go through `.payload` |
+| `429 workspace_busy` | The workspace is busy | Retry with backoff; it is not an error in your input |
+| A path from this brief doesn't exist | Adapter layout differs by version | Re-read `LAYOUT.md` and `.adapter.md`; use what the workspace says |
+| A write is rejected | Outside your ACL grant | Stop, report the path, check `.relay/state.json` → `deniedPaths` |
+| Write succeeded, no receipt appears | Writeback queued, failed, or dead-lettered | `relayfile writeback status`, then the dead-letter record |
+| The tree is empty or missing a provider | Mount raced the first sync, or the provider isn't connected | Report it; don't review partial data |
+
+## Rules
+
+1. Orient from `LAYOUT.md` and `_index.json` before touching anything.
+2. Never invent a path, a filename, or a payload shape — discover all three.
+3. Read record fields through `.payload`.
+4. Write only your own findings file; never edit another agent's.
+5. Never write under `.relay/`, `merge.json`, or `close.json`.
+6. Read `recon.md` instead of re-deriving the PR map.
+7. Record the commit you reviewed in your output.
+8. Confirm the receipt before reporting success.
+9. Report blockers instead of working around them.
+
+
+
+ The human-facing walkthrough this brief belongs to.
+
+
+ The PATCH / CREATE / DELETE model the brief's write rules come from.
+
+
diff --git a/web/content/docs/file/review-bot.mdx b/web/content/docs/file/review-bot.mdx
new file mode 100644
index 0000000..1514113
--- /dev/null
+++ b/web/content/docs/file/review-bot.mdx
@@ -0,0 +1,446 @@
+---
+title: 'Build a PR review bot'
+description: 'End-to-end: connect GitHub with one command, add the rest of your context providers, give every sandbox the same workspace, and post the review back by writing a file.'
+---
+
+A PR review bot is the shape Relayfile fits best: several agents, several providers, one shared state. This guide runs the whole flow end to end — from an empty machine to a bot whose orchestrator and specialists all read the same PR, coordinate through files, and post the finished review back to GitHub without any of them holding a provider token.
+
+
+ Every command, path, and payload below was run against a live workspace on `relayfile` **0.10.53**. The review in step 7 was posted by writing the file this guide tells you to write — [`agentrelay.com#59` review 5112118049](https://github.com/AgentWorkforce/agentrelay.com/pull/59#pullrequestreview-5112118049). Where a path or flag is version-dependent, it says so.
+
+
+## What you're building
+
+```text
+ Relayfile workspace (github + linear + notion + slack)
+ │
+ ┌─────────────────────────┼─────────────────────────┐
+ │ │ │
+ orchestrator sandbox security sandbox quality sandbox
+ mount: /workspace mount: /workspace mount: /workspace
+ read /github/** read /github/** read /github/**
+ read /notion/** read /runs/** read /runs/**
+ write /runs/** write /runs/** write /runs/**
+```
+
+Every sandbox works against the *same* workspace. The orchestrator writes its recon notes to `/runs/…`; the specialists read them as ordinary files a second later. Specialists write findings to `/runs/…`; the orchestrator streams them out as each one lands. Nothing in that loop is a queue, a webhook, or a bespoke protocol — it's [real-time sync](/docs/file/realtime-sync) over a shared tree.
+
+The three things this guide buys you:
+
+- **One integration surface.** Adding Linear or Slack context later is `relayfile integration connect`, not another OAuth app, webhook endpoint, and client library.
+- **Context across sandboxes.** Specialists in separate sandboxes see each other's work through the filesystem.
+- **Writeback without provider tokens.** The bot posts its review by writing JSON to a path. Retries, rate limits, and dead-lettering are the writeback workers' problem.
+
+## Where it runs
+
+The integration stack is hosted either way — what changes is where the bot's processes live and how they get credentials.
+
+| | Workstation | Cloud sandboxes | Serverless / CI step |
+|---|---|---|---|
+| Provisioning | `relayfile setup` once, interactively | `relayfile setup` once by a human, **or** headless with `--cloud-token` | same one-time provisioning |
+| Per-run access | long-lived `relayfile mount --background` | `ensureMountedWorkspace` per sandbox ([step 5](#5-give-every-sandbox-the-same-workspace)) | no mount — the HTTP API directly |
+| Credentials in the process | your local relay session | a workspace-scoped JWT from `handle.env()` | `RELAYFILE_TOKEN` in the environment |
+| Good for | building and debugging the bot | the real fleet — one sandbox per specialist | a single review pass with no daemon |
+
+**Provisioning happens once; access happens per run.** A cloud bot doesn't re-do OAuth on every PR — it joins a workspace that's already connected.
+
+
+ A workspace can have exactly **one registered local mirror per machine**. Pointing `relayfile mount` at a second directory fails with *"workspace … is already mirrored at …; refusing to silently re-home it"*. That's a guard, not a bug: `--rehome` **moves** the existing mirror rather than adding one. On a machine that already mounts the workspace for something else, use the API path instead of re-homing someone else's mirror.
+
+
+## 1. Connect GitHub
+
+One command logs you into Cloud, creates the workspace, runs the provider OAuth, waits for the first sync, and mounts the result:
+
+```bash
+relayfile setup \
+ --provider github \
+ --workspace review-bot \
+ --local-dir ./relayfile-mount \
+ --no-open
+```
+
+`--no-open` prints the login and connect URLs instead of launching a browser — always pass it when an agent (or CI) is driving the command, since a headless browser launch burns the OAuth state.
+
+
+ Both URLs are short-lived. A Cloud device code expires in minutes and the Nango connect URL has its own TTL, so complete them while the command is still waiting. If it exits first, re-run the same line — a re-run reuses the workspace and only opens a new connect flow when the provider isn't connected yet.
+
+
+### Provisioning from a machine with no browser
+
+If the bot is provisioned by CI or a deploy job rather than a person at a laptop, skip the browser login with a Cloud token and skip the mount loop — this step only needs to leave a connected workspace behind:
+
+```bash
+RELAYFILE_CLOUD_TOKEN="$CLOUD_TOKEN" relayfile setup \
+ --provider github \
+ --workspace review-bot \
+ --skip-mount \
+ --no-open
+```
+
+On a headless host you can also authorize from a browser on another machine — `agent-relay cloud login --device` prints a URL and a short code, then blocks until you approve it.
+
+The same provisioning from code, when your control plane already holds Cloud tokens:
+
+```ts
+import { RelayfileSetup } from "@relayfile/sdk"
+
+const setup = RelayfileSetup.fromCloudTokens(
+ { accessToken, refreshToken, accessTokenExpiresAt },
+ { cloudApiUrl: "https://agentrelay.com/cloud" },
+)
+
+const workspace = await setup.joinWorkspace("rw_…")
+const client = workspace.client() // bound, auto-refreshing — used throughout this guide
+
+const { connectLink } = await workspace.connectIntegration("github")
+if (connectLink) {
+ await notifyOperator(connectLink) // one-time human step
+ await workspace.waitForConnection("github")
+}
+```
+
+
+ Use the `rw_…` workspace id for every data-plane call. It is not interchangeable with the request-side app UUID, and substituting one for the other fails in ways that look like a permissions problem.
+
+
+## 2. Verify before you write any bot code
+
+```bash
+relayfile status review-bot
+```
+
+The output is per-provider health plus a mirror footer:
+
+```text
+workspace rw_… (review-bot) mode: poll lag: 0s
+auth: agent-relay session ok
+ github healthy queue lag 0s event active; last event 3m12s ago
+
+local mirror: /Users/you/relayfile-mount
+daemon: not running
+
+pending writebacks: 0 conflicts: 0 denied: 0
+```
+
+- **`lag`** and per-provider `healthy` / `lagging` tell you whether reads will be current.
+- **`daemon: not running`** means the mirror is stale until you start it: `relayfile mount review-bot ./relayfile-mount --background`.
+- The footer counts (`pending writebacks`, `conflicts`, `denied`) are **local mirror state**. Dead-letter counts live in a different command — see step 7.
+
+Then let the workspace describe its own shape rather than hard-coding paths from this page:
+
+```bash
+relayfile read review-bot /LAYOUT.md # top-level guide, lists every provider root
+relayfile read review-bot /github/LAYOUT.md # the GitHub adapter's own contract
+```
+
+
+ The provider layout file is `/LAYOUT.md` — uppercase, no leading dot. `/github/LAYOUT.md` is long and worth reading in full: it documents index row shapes, alias views, and which paths are artifacts rather than records.
+
+
+`relayfile tree` and `relayfile read` work against the server with no mount, which is what CI and a laptop checking on a cloud workspace both want:
+
+```bash
+relayfile tree review-bot /github/repos --depth 2
+```
+
+Two things to expect from `tree` on a real workspace: it **paginates** (it prints a `next cursor:` line on large directories), and a busy workspace returns `http 429 workspace_busy`. Retry with backoff, and prefer reading a directory's `_index.json` over walking it.
+
+## 3. Add the rest of the bot's context
+
+A review is better when the reviewer can see the ticket that motivated the PR and the standards it's supposed to follow. Don't guess provider ids — ask the live catalog:
+
+```bash
+relayfile integration available --refresh
+relayfile integration search notion --refresh --json
+```
+
+Then connect what you need. Each provider lands as another subtree under the same workspace:
+
+```bash
+relayfile integration connect linear --workspace review-bot --no-open
+relayfile integration connect notion --workspace review-bot --no-open
+relayfile integration connect slack --workspace review-bot --no-open
+relayfile integration list --workspace review-bot
+```
+
+`integration list` prints one row per connection with `provider / status / lag / last_event_at`, so it doubles as a health check.
+
+Nango is the default backend; request Composio explicitly for toolkits it brokers (`--backend composio`). If a Composio toolkit can't create managed auth automatically, the command says so — a human adds a custom auth config in Composio, then re-runs the identical command.
+
+
+ Jira and Confluence need a follow-up: a single Atlassian grant can cover several sites, so the CLI prompts for one after OAuth and stores its `cloudId`. If the picker was skipped, set it explicitly with `relayfile integration set-metadata jira cloudId=… baseUrl=https://….atlassian.net --workspace review-bot --yes`. The command replaces the whole metadata namespace, so pass every key you want to keep.
+
+
+## 4. Read the PR
+
+Start from the index, not from a guessed filename:
+
+```bash
+relayfile read review-bot /github/repos/AgentWorkforce/agentrelay.com/pulls/_index.json | jq '.[0:3]'
+```
+
+Canonical `_index.json` files are a **bare JSON array**. Pull rows carry the fields you'd otherwise open every record to get:
+
+```json
+{ "id": "59", "title": "docs(relayfile): Guides section…", "updated": "2026-09-04T10:06:10Z",
+ "number": 59, "state": "open", "labels": [], "headRef": "docs/relayfile-review-bot-guide" }
+```
+
+That's enough to pick a PR by state, label, or branch without reading a single record. Then open the record:
+
+```bash
+R=/github/repos/AgentWorkforce/agentrelay.com
+relayfile read review-bot "$R/pulls/59__docs-relayfile-guides-section-pr-review-bot-walkthrough-local-cloud-and-a-copy/meta.json"
+```
+
+Three details that will bite an agent that guesses:
+
+1. **GitHub record directories are `__` — number first.** The generic "the id is the last `__` segment" rule in the root `LAYOUT.md` holds for Linear (`__`), not for GitHub.
+2. **The full directory name is required for reads.** `pulls/59/meta.json` returns `404 not_found`; only `pulls/59__/meta.json` resolves. Take the name from `_index.json` or a directory listing — never assemble it.
+3. **Records are envelopes.** A record is `{ provider, objectType, objectId, deleted, payload }` and the provider's own object is under `payload`. `jq .state` returns null; `jq .payload.state` is what you want.
+
+Alias views live in a flat sibling namespace, `__`, not under the canonical repo path:
+
+```bash
+relayfile tree review-bot /github/repos/AgentWorkforce__agentrelay.com/pulls --depth 1
+# → by-creator/ by-edited/ by-id/ by-state/ by-title/
+```
+
+Linear works the same way with its own keys — `/linear/issues/by-id/AR-100.json` resolves a ticket by its human identifier:
+
+```bash
+relayfile read review-bot /linear/issues/by-id/AR-100.json | jq '.payload.title'
+```
+
+## 5. Give every sandbox the same workspace
+
+This is the step that makes it a fleet instead of a script. From inside an already-authorized sandbox, `ensureMountedWorkspace` mints a scoped mount token, supervises the `relayfile-mount` process, and resolves once the mirror is reachable:
+
+```ts
+import { RelayfileSetup } from "@relayfile/sdk"
+
+const setup = new RelayfileSetup({ accessToken })
+
+const handle = await setup.ensureMountedWorkspace({
+ workspaceId: "rw_…",
+ provider: "github",
+ verifyProvider: true,
+ providerReadyTimeoutMs: 30_000,
+ localDir: "/workspace",
+ scopes: [
+ "relayfile:fs:read:/github/**",
+ "relayfile:fs:read:/runs/**",
+ "relayfile:fs:write:/runs/**",
+ ],
+})
+
+// hand the mount to the specialist process — no provider tokens cross this line
+await sandbox.process.executeCommand("claude --print 'Review /workspace'", {
+ env: handle.env(),
+})
+```
+
+Three things to keep straight when you fan this out:
+
+- **`verifyProvider` gates on readiness.** Without it a specialist can mount before the first sync finishes and review an empty tree. `ProviderNotConnectedError` and `ProviderNotReadyError` are typed so you can tell "never connected" from "still syncing".
+- **Scope each specialist down.** The minted token's scopes are a subset of the caller's grant. Use the path-scoped form — a bare `fs:read` can fall back to a broad grant. See [ACLs](/docs/file/acls).
+- **`handle.env()` is the whole handoff.** It carries `RELAYFILE_BASE_URL`, `RELAYFILE_TOKEN`, `RELAYFILE_WORKSPACE`, and `RELAYFILE_LOCAL_DIR`. The sandbox never sees your Cloud access token or the provider credentials.
+
+Each sandbox is a separate machine, so the one-mirror-per-machine rule doesn't constrain the fleet — it only bites when two things on the *same* box want the same workspace.
+
+### Where the cloud process gets its credentials
+
+`connect()` from `@relayfile/agents` resolves credentials in two steps: environment overrides first — `CLOUD_API_URL`, `CLOUD_API_ACCESS_TOKEN`, `CLOUD_WORKSPACE_ID` — then `~/.agentworkforce/relay/cloud-auth.json`, written by `agent-relay cloud login`. The env path is for a cloud runner; the file path is for a workstation.
+
+```ts
+import { connect, tools } from "@relayfile/agents"
+
+const rf = await connect({
+ agentName: "security-reviewer",
+ scopes: ["relayfile:fs:read:/github/**", "relayfile:fs:write:/runs/**"],
+})
+```
+
+### Or skip the mount entirely
+
+A cloud agent with no writable disk — a Lambda, a Worker, a short-lived job — doesn't need a mirror. The same workspace is reachable over HTTP with the same paths, using the `client` from step 1 (`workspace.client()`, or `rf.client` from `connect()`):
+
+```ts
+const tree = await client.listTree(workspaceId, { path: "/github/repos/acme/api/pulls", depth: 2 })
+const pr = await client.readFile(workspaceId, "/github/repos/acme/api/pulls/42__bump-deps/meta.json")
+```
+
+You lose `grep` over the tree and gain a cold start measured in milliseconds. A useful split: mount in the specialist sandboxes that explore the PR, use the client in the small functions that read one record or post one result.
+
+## 6. Coordinate through the filesystem
+
+Keep the bot's own state on a path no adapter owns — `/runs/**` here. The write still persists and still raises an event, but no provider call is queued. You can see the difference in the write response: a path under a provider returns a real `opId` with `"state":"pending"`, while a path with no adapter behind it returns an empty `opId` and `"state":"succeeded"` immediately.
+
+```json
+// PUT /runs/pr-59/findings/security.json
+{"opId":"","status":"queued","targetRevision":"rev_…","writeback":{"provider":"runs","state":"succeeded"}}
+```
+
+The orchestrator finishes recon and publishes it as a file:
+
+```bash
+cat > $MOUNT/runs/pr-59/recon.md <<'EOF'
+# PR 59 — recon
+Touched: src/auth/session.ts, src/auth/index.ts
+Exported from: src/auth/index.ts (re-exported by src/server.ts)
+Ticket: AR-100
+EOF
+```
+
+Every specialist reads that one file instead of re-deriving the map of the PR. Then each writes its own findings under its own path, so two specialists never contend for one file:
+
+```bash
+echo '{"specialist":"security","findings":[]}' \
+ > $MOUNT/runs/pr-59/findings/security.json
+```
+
+The orchestrator doesn't wait for the slowest specialist. It subscribes to the findings directory and publishes each result the moment it lands:
+
+```ts
+import { readFile } from "node:fs/promises"
+import { join } from "node:path"
+import { onWrite } from "@relayfile/sdk"
+
+onWrite("/runs/pr-59/findings/**", async (event) => {
+ // the file is materialized before the event fires — just read it
+ const finding = JSON.parse(await readFile(join(mountDir, event.path), "utf8"))
+ await publishFinding(finding) // stream it out now, don't wait for the rest
+}, { client, workspaceId, operations: ["create", "update"] })
+```
+
+Delivery is at-least-once and `revision` is the per-file ordering key, so deduplicate on `eventId` and keep the handler idempotent. `event.path` is a workspace path; joining it with the mount root only works when the mount isn't scoped to a subtree with `--remote-path`.
+
+## 7. Post the review back to GitHub
+
+Writeback is a file write, but discover the contract first. Discovery documents live in their own `/discovery` tree with literal placeholder segments — **not** as siblings of the records:
+
+```bash
+D="/discovery/github/repos/{owner}/{repo}/pulls/{pullNumber}"
+relayfile read review-bot /discovery/github/.adapter.md
+relayfile read review-bot "$D/reviews/.schema.json"
+relayfile read review-bot "$D/reviews/.create.example.json"
+```
+
+`.adapter.md` is the authority on which resources are writable, the ID pattern for each, and what a create draft becomes. For pull request reviews it gives the resource as `/github/repos/{owner}/{repo}/pulls/{pullNumber}/reviews/.json` with ID pattern `^\d+$`, and the review schema requires **`event`, `body`, and `comments`** — omitting `comments` fails validation.
+
+Then the three write rules:
+
+- **CREATE** — write a valid payload to a *non-canonical* filename in the resource directory. Any name that doesn't match the resource's ID pattern is a draft.
+- **PATCH** — write only the mutable fields to the canonical `.json`. Fields marked `readOnly` in the schema are rejected.
+- **DELETE** — remove the canonical file, where `.adapter.md` says delete is supported.
+
+```bash
+cat > "$MOUNT/github/repos/AgentWorkforce/agentrelay.com/pulls/59/reviews/draft-security.json" <<'JSON'
+{ "event": "COMMENT", "body": "2 findings from the security pass…", "comments": [] }
+JSON
+```
+
+
+ **Write paths use the bare pull number, reads use `__`.** `pulls/59/reviews/draft-security.json` is the correct write target even though `pulls/59/meta.json` doesn't exist for reading. Don't derive one from the other.
+
+ The same directory accepts `merge.json` and `close.json` as write resources — writing either one merges or closes the pull request. Keep a review bot's write grant scoped so a confused agent can't reach them.
+
+
+The adapter rewrites your draft file into a receipt naming the real record:
+
+```json
+{ "created": 5112118049, "externalId": "5112118049", "id": "5112118049",
+ "path": "/github/repos/AgentWorkforce/agentrelay.com/pulls/59/reviews/draft-security.json",
+ "url": "https://github.com/AgentWorkforce/agentrelay.com/pull/59#pullrequestreview-5112118049" }
+```
+
+Read the file back until `created` appears — that receipt, not the write's HTTP 200, is the proof the review exists. Editor scratch names (`partial.json`, `.tmp.json`, `*.tmp.json`, `*.partial.json`) are ignored and never become drafts.
+
+### Doing it over HTTP
+
+From a function with no mount, the same write is one request — and both headers are **required**, not decoration:
+
+```bash
+curl -sS -X PUT \
+ -H "Authorization: Bearer ${RELAYFILE_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -H "X-Correlation-Id: review-bot-$(date +%s)" \
+ -H "If-Match: *" \
+ "${RELAYFILE_BASE_URL}/v1/workspaces/${RELAYFILE_WORKSPACE}/fs/file" \
+ -d '{"path":"/github/repos/…/pulls/59/reviews/draft.json","content":"{…}","contentType":"application/json"}'
+```
+
+Omit `X-Correlation-Id` and you get `400 bad_request`; omit `If-Match` and you get `412 precondition_failed`. `If-Match: *` is create-or-overwrite; pass a revision to make the write conditional.
+
+### Verifying
+
+```bash
+relayfile writeback status review-bot --json
+```
+
+This reports the **local mirror's** queue — `pending`, `failed`, `dead-lettered` — so it's the check for writes made through a mount. A write made over HTTP is a server-side op instead; verify that one by reading the draft back for its receipt.
+
+If an op dead-letters, its record under `$MOUNT/.relay/dead-letter/.json` carries `lastStatus` and a truncated `lastBody`; fix the cause, then `relayfile writeback retry --opId review-bot`. Denied paths are recorded in `$MOUNT/.relay/state.json` under `deniedPaths` (some versions also write `permissions-denied.log`). Never write anything under `.relay/` yourself.
+
+## 8. Trigger on a new PR without running a webhook server
+
+The bot doesn't need a GitHub App endpoint of its own. A provider webhook is already normalized into a file event on a canonical path, and the file is materialized *before* the event fires — so the handler starts with state on disk, not a payload to parse.
+
+From the CLI, `relayfile listen` streams the feed and runs a command per event:
+
+```bash
+relayfile listen review-bot \
+ --path "/github/repos/acme/api/pulls/**" \
+ --event file.created \
+ --run "review-bot start {{path}}"
+```
+
+Its full surface is `relayfile listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] [--background]`, and `--run` substitutes `{{path}}`, `{{type}}`, `{{provider}}`, `{{revision}}`, and `{{event}}` (the whole event — quote it). A delivered event carries `eventId`, `type`, `path`, `revision`, `provider`, `origin`, `correlationId`, and — for small files — the content inlined, so the handler usually needs no follow-up read:
+
+```json
+{"type":"file.created","path":"/runs/pr-59/findings/security.json","revision":"rev_2935117",
+ "eventId":"evt_2507297","provider":"runs","origin":"agent_write","inlineContent":true,"content":"{}"}
+```
+
+`origin` is what keeps a bot from reacting to itself: `provider_sync` is a webhook from the provider, `agent_write` is another agent's write.
+
+Alternatives: subscribe in-process with the SDK (`connectWebSocket({ onEvent })`, or the glob-filtered `onWrite` from step 6), or fan events out to a channel with `relayfile integration bind --channel … --webhook … --webhook-token …`.
+
+
+ Supervise the subscriber. On a busy workspace the event stream drops within seconds — either mid-message or on a frame EOF — and reconnecting too fast earns a `429` on the WebSocket handshake. Run it with `--background` or under `relayfile supervisor install`, and back off between reconnects. A subscriber that reconnects with a cursor gets the events it missed.
+
+
+Either way the reviewer wakes on provider state changing rather than on a request arriving — see [Events and webhooks](/docs/file/events). In the cloud, keep the subscriber in one long-lived orchestrator that spawns a sandbox per PR.
+
+## Tear down
+
+```bash
+relayfile stop review-bot
+relayfile integration disconnect github --workspace review-bot --yes
+relayfile workspace delete review-bot --yes
+rm -rf ./relayfile-mount
+```
+
+
+ `relayfile workspace join --name ` **renames an existing local entry** for that workspace id rather than adding a second one. If you're scripting workspace setup, check `relayfile workspace list` before and after.
+
+
+
+ Copy-paste agent brief — the same contract written for the agent itself, ready to drop in as `AGENTS.md`.
+
+
+
+
+ Every option on `mountWorkspace` and `ensureMountedWorkspace`.
+
+
+ Scope each specialist to the paths it should read and write.
+
+
+ Why the orchestrator sees a specialist's write on the next read.
+
+
+ The copy-paste operating document to hand the agent.
+
+
diff --git a/web/lib/product-docs-nav.ts b/web/lib/product-docs-nav.ts
index e72a35f..79ad751 100644
--- a/web/lib/product-docs-nav.ts
+++ b/web/lib/product-docs-nav.ts
@@ -37,7 +37,7 @@ export const fileSection: ProductDocSection = {
label: 'Relayfile',
tagline: 'The event layer for AI agents.',
repo: 'AgentWorkforce/relayfile',
- version: '0.10.31',
+ version: '0.10.53',
nav: [
{
title: 'Start',
@@ -47,6 +47,13 @@ export const fileSection: ProductDocSection = {
{ title: 'Why files', slug: 'why-files' },
],
},
+ {
+ title: 'Guides',
+ items: [
+ { title: 'Build a PR review bot', slug: 'review-bot' },
+ { title: 'Review bot agent brief', slug: 'review-bot-brief' },
+ ],
+ },
{
title: 'Concepts',
items: [
diff --git a/web/lib/test/product-docs.test.ts b/web/lib/test/product-docs.test.ts
index ee7a4cf..5502b09 100644
--- a/web/lib/test/product-docs.test.ts
+++ b/web/lib/test/product-docs.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { factorySection, getProductSearchIndex } from '../product-docs';
+import { factorySection, fileSection, getProductSearchIndex } from '../product-docs';
describe('Factory product docs', () => {
it('publishes issue routing in navigation and scoped search', () => {
@@ -22,3 +22,37 @@ describe('Factory product docs', () => {
expect(searchEntry?.body).toContain('safety.requireLabel');
});
});
+
+describe('Relayfile product docs', () => {
+ it('publishes both review-bot guides in a Guides group', () => {
+ const guides = fileSection.nav.find((group) => group.title === 'Guides');
+
+ expect(guides?.items).toEqual([
+ { title: 'Build a PR review bot', slug: 'review-bot' },
+ { title: 'Review bot agent brief', slug: 'review-bot-brief' },
+ ]);
+ });
+
+ it('indexes the review-bot guide, including the cloud path', () => {
+ const searchEntry = getProductSearchIndex(fileSection).find(
+ (entry) => entry.slug === 'review-bot'
+ );
+
+ expect(searchEntry).toMatchObject({ title: 'Build a PR review bot' });
+ expect(searchEntry?.headings).toContain('5. Give every sandbox the same workspace');
+ expect(searchEntry?.headings).toContain('Where it runs');
+ expect(searchEntry?.headings).toContain('Provisioning from a machine with no browser');
+ expect(searchEntry?.headings).toContain('Where the cloud process gets its credentials');
+ expect(searchEntry?.body).toContain('A PR review bot');
+ });
+
+ it('indexes the copy-paste agent brief', () => {
+ const searchEntry = getProductSearchIndex(fileSection).find(
+ (entry) => entry.slug === 'review-bot-brief'
+ );
+
+ expect(searchEntry).toMatchObject({ title: 'Review bot agent brief' });
+ expect(searchEntry?.headings).toContain('The run protocol');
+ expect(searchEntry?.headings).toContain('Publish the review');
+ });
+});