# Fabric for Agents

You are an autonomous coding agent about to make a version-controlled change to a
repo hosted on **Fabric**. You probably know Git. This page is the 5-minute mental
model plus the exact commands. Read it once; you'll have everything you need.

> **Fabric in one line:** *Edit like Docs, version like Git.* A repo's canonical
> store is an **append-only operation log** (every edit is an op appended at the
> frontier), not a chain of Git commits. You never rewrite history. You only ever
> add to it.

## Don't have an account yet?

Run `fabric signup`: browser sign-up and CLI auth in one step. It opens this
hub to the account-creation form; create the account, approve the request, and
the CLI receives its token. (`fabric login` is the same flow for returning
users. It lands on sign-in instead.) Your account's single **org** is
provisioned automatically right after sign-up; if it's still being set up you'll
briefly land on a setup screen, then return to finish authorizing. Your single
**repo** is likewise resolved (and provisioned on first use) by the hub (you
never create or name it), so once signed in you drive everything from the CLI
below.

## Get the CLI

The CLI runs on **macOS and Linux only** (the installer is POSIX `sh`, and only
darwin/linux binaries are built). On Windows, run it under WSL (Windows Subsystem for
Linux): open `wsl`, then use the same commands.

```sh
curl -fsSL https://app-staging.fabricemr.com/install.sh | sh      # installs `fabric` to ~/.fabric/bin
fabric login --hub "https://app-staging.fabricemr.com"             # browser device-auth; --hub is saved to config
```

You're reading this at `https://app-staging.fabricemr.com/agents.md`, so that URL is the hub. `login`
saves it, so later commands need no `--hub`. For CI/headless, skip the browser:
`echo "$FABRIC_TOKEN" | fabric login --hub "https://app-staging.fabricemr.com"`. Run `fabric --version` (or
`fabric version`) to confirm which build you have (cite it in bug reports), and
`fabric upgrade` to pull the latest CLI this hub serves.

**More than one account on this machine?** Logins accumulate: each is saved under its
own key (`<hub>|<account>`), and a working directory remembers the account it was cloned
with, so commands inside it need no flags. `fabric account list` prints the keys. Only
`fabric clone` has to be told, and only when several are logged in: pass `--account
<key>`. Without it, clone exits non-zero and prints the accounts, each line being the
exact command to re-run (`--json` gives the same list as data). It never prompts, so it
can't hang you.

**No browser at all?** `signup`/`login` need a human to approve in a browser, but once
you hold ANY valid token you can self-provision more without one:
`fabric token create` mints a fresh token scoped to the same account (`--json | jq -r
.token` to capture it). Bootstrap one machine with `--token`/`FABRIC_TOKEN`, then mint
tokens for other agents or CI from the CLI: no human, no browser.

## The mental model (and how it maps to Git)

Work happens on a **change**: an isolated set of ops forked from a **trunk** at a
point called a **frontier**. You edit, push your ops onto the change, then take it
through **propose → review → accept**. Accepting integrates your change's tip into
the trunk. That's the whole lifecycle.

| Git | Fabric | The difference that bites |
|-----|--------|---------------------------|
| branch | **change** | A change is an op-set forked from a frontier, not a movable ref. |
| `main` | **trunk** | Trunks are **per-subtree**, not one global head. |
| commit | **op** (operation) | Ops are append-only and content-addressed; granularity is per-edit, not per-snapshot. |
| `git push` | `fabric push` | Push appends your working tree to the change as ops. It does **not** touch trunk. |
| pull request | `fabric propose` | Moves the change `draft → proposed` (ready for review). |
| review approval | `fabric approve` | Records an approving review. `--request-changes` records the opposite. |
| merge | `fabric accept` | Integrates the change tip into trunk through a gated pipeline: the hub merges your change onto the current trunk, runs the required checks against that merged result, and advances trunk only if they pass (re-merging and re-checking if trunk moved first). Blocked until the repo's `fabric.yaml` approval policy is met. When the trunk batches accepts, accept returns `queued` and the change lands from the merge queue in the background (see below). |
| commit SHA | **op-id** `actor:seq` | A stable `(actor, sequence)` coordinate, **not** a content hash. |
| HEAD / a ref | **frontier** | The minimal set of op-ids that defines a version. |

**What does NOT exist (do not reach for these):**
- No `rebase`, no `--force`, no history rewrite. The log is **forward-only append**.
  If you'd normally rebase or amend, instead just push more ops.
- No detached commits you can lose. Your work lives on the change until accepted.
- No merge-marker editing on push: trunk only changes when a change is **accepted**.

**Why fork a change instead of editing trunk directly?** Isolation is a guarantee:
trunk only changes through `propose → review → accept`, so a half-finished change
cannot corrupt what others build on. Whether a change needs approval before it
lands, and from whom, is set in the repo's `fabric.yaml` `approval` policy, which
`fabric accept` enforces.

## The full loop (copy-paste)

Fabric assumes a **single repo per account**, so no command names a repo: the hub
resolves (and lazily provisions) it for you.

```sh
fabric change create "fix login"  # fork a new draft change (now the most recent)
fabric clone                       # materialize the most recent change → a working dir
cd <dir>
#   …edit files normally with your editor/tools…
fabric status                     # local diff vs the cloned base (no network)
fabric push                       # append the working tree to the change as ops (idempotent)
fabric propose                    # draft → proposed (ready for review)
fabric approve                    # record an approval (if the repo's policy requires one)
fabric accept                     # integrate the change tip into trunk (enforces the approval policy)
#   …or `fabric abandon` to discard the change…
```

**State your intent for the reviewer.** Every change carries a freeform markdown
**description**, rendered above the diff on its review page. Set it when you create
(`fabric change create "<name>" --description "<markdown>"`), when you propose
(`fabric propose -m "<markdown>"`, alias `--description`), or any time with
`fabric change describe "<markdown>"` (empty string clears it). Always write one:
it is how a human reviewer learns why your change exists.

**Link the change to its ticket.** If the work has a ticket (`fabric ticket`), link the
change to it: `fabric change create "<name>" --ticket TIX-N`, or any time with
`fabric change ticket TIX-N` (empty string clears it). The review page then points
straight at the ticket, so intent lives on the ticket and the change references it
instead of repeating it in a `TIX-N:` name prefix.

**Give reviewers something to click.** A change can carry labeled **links**, shown on
its review page. If your workflow deploys a preview (Railway, staging, anything with a
URL), attach it so a reviewer can try the change, not just read it:
`fabric change link add <url> --label "Preview"`. Only `http`/`https` URLs are
accepted; the label defaults to the URL's host. Re-adding the same label + url is a
no-op (the existing link is returned), so a re-run workflow never piles up duplicates.
`fabric change link list` shows ids; `fabric change link remove <id>` takes one down.

**Attach the conversation that produced the change.** A change can carry **session
transcripts**: the dialogue (yours) that led to the edits, rendered on the review page
as collapsible role-badged messages so the reviewer reads the reasoning behind the
diff, not just its result. There is no CLI command for this yet; POST the transcript
to the hub with your token. Create a session on the change, then append messages:

```sh
# create a session (change id from `fabric change create --json` or `fabric changes --json`)
curl -sS -X POST "https://app-staging.fabricemr.com/v1/vcs/changes/<change-id>/sessions" \
  -H "Authorization: Bearer $FABRIC_TOKEN" -H "Content-Type: application/json" \
  -d '{"source":"claude-code","title":"Fix login redirect","external_ref":"<optional source-side session id or URL>"}'
#   -> {"session":{"id":"<session-id>", ...}}

# append messages, in one or more batches (up to 500 per call)
curl -sS -X POST "https://app-staging.fabricemr.com/v1/vcs/sessions/<session-id>/messages" \
  -H "Authorization: Bearer $FABRIC_TOKEN" -H "Content-Type: application/json" \
  -d '{"messages":[
        {"seq":0,"role":"user","body":"Fix the login redirect loop"},
        {"seq":1,"role":"assistant","body":"Traced it to a stale cookie check; patching `auth.go`."}
      ]}'
```

Roles are `user`, `assistant`, `system`, or `tool`; `seq` (an integer >= 0) orders the
transcript, and each body is markdown up to 64 KB. Appends are **idempotent per
(session, seq)**: re-POSTing a seq that already exists returns the stored message
(`created: false`) instead of duplicating it, so a retried upload is safe. A change can
carry several sessions (one per work session); read them back with
`GET /v1/vcs/changes/<change-id>/sessions`.

**Two ways to get files on disk (pick one, they are not sequential steps):**
- **To edit** (the loop above): `fabric change create` then `fabric clone`. This
  materializes the change to a working dir you can push from.
- **To only look** (read-only, no change): `fabric clone --at <ref>`, where `<ref>` is
  `op:<actor>:<seq>` for a past frontier or `trunk` for the current trunk. Such a checkout
  tracks no change, so `push`/`pull` refuse it. Use it to inspect, not to work.

Notes that save you a round-trip:
- `fabric clone` materializes the **most recent** change. Create the change first.
  (A bare clone forks one off trunk when none exists, and prefers trunk HEAD over a
  change left behind by a trunk advance like an import, so an `import` then `clone`
  won't hand you a stale pre-import change.) You can pass the target dir positionally
  (`fabric clone ./work`) or with `--dir`, matching `fabric import <folder>`; `--dir .`
  populates the current directory.
- `fabric clone` **refuses a non-empty target and writes nothing**, so it never clobbers
  files already on disk. A leftover `.fabric/` from a prior clone does not count, so
  re-cloning an otherwise-empty dir works. Pass `--force` to write the checkout over the
  existing contents (same-path files overwritten, unrelated files kept). Under `--json` a
  refusal is `{error: "clone_target_not_empty", message, suggestion, dir}` with a non-zero
  exit: don't wipe the dir to work around it, pick an empty dir or pass `--force`.
- `fabric push` is **idempotent** (safe to re-run); it appends only what's new.
- `fabric pull` re-materializes the tracked change's current tip (pick up ops others pushed).
  If the change you cloned is no longer live (accepted into trunk, abandoned, or deleted),
  pull stops and tells you which, plus the next step: `fabric clone --at trunk` to read the
  current trunk, or `fabric change create` then `fabric clone` to keep editing. Under `--json`
  the reason rides in `{error, message, suggestion}` (`error` is `change_accepted`,
  `change_abandoned`, or `change_gone`). Don't keep retrying a dead pointer: re-clone.
- `fabric accept` may return `queued` instead of landing inline. When the trunk batches accepts
  (its `queue.toml` sets `batch_size` or `concurrency_window` above one), your change enters a
  **merge queue**: it merges into a staged candidate frontier, its checks run there, and trunk
  advances only once they pass. Watch it with `fabric queue` (positions, staged cut, per-candidate
  status, terminal outcome; `--json` for a machine read), or wait on the event stream for
  `change.accepted` / `change.rejected`. Do NOT re-run `accept` on a queued change; it is already
  in flight. A repo on the default serial path never queues (accept lands inline).
- `fabric status` / `fabric diff` are **local** (no network). Use them freely.
- **Read and write review comments** through `fabric comments`. Comments live on the hub,
  not in the clone (a clone carries only files), so this is the ONLY way you see or answer
  them: never parse files looking for comments.
  - `fabric comments list` shows the comments on the files you cloned, resolved to your
    working copy's line numbers (`--path <p>` filters to one file); `fabric comments list
    --change <id>` shows a change's review thread instead. Each item carries an id, anchor,
    body, author, thread `parent_id`, `resolved`, `outdated`, and reactions. A comment whose
    anchor moved or vanished is flagged `(outdated)`. The hub resolves anchors, so no local
    guessing.
  - `fabric comments add --path <p> --lines A-B "<body>"` leaves one; `fabric comments reply
    <id> "<body>"` replies to a thread; `fabric comments resolve <id>` /
    `fabric comments unresolve <id>` flip its resolved state; `fabric comments react <id>
    --emoji 👍` toggles a reaction (`--off` removes).
  - **When to pull comments:** your change is proposed or in review, you were `@`-mentioned
    (a `mention` event), or you're about to edit a file that has open comments. Read them
    before you touch the code they're about.
  - **Resolution convention (do this, it keeps the trail honest):** resolve a comment ONLY
    after the push that actually addresses it has landed, and `reply` first with what
    changed, so the fix is traceable from the thread. Don't resolve a comment you haven't
    fixed.
- **Track work as tickets** through `fabric ticket`. A ticket is a version-controlled record
  (title, status, assignee, priority, labels, and a body), so its history is in the operation
  log; its fields and body merge independently, so a concurrent status change and a body edit
  both land. `fabric ticket list` (filter with `--status` / `--assignee`)
  and `fabric ticket show <key>` read them; `fabric ticket create "<title>"` mints a `TIX-N`
  key; `fabric ticket edit <key> --status done` changes only the flags you pass. Read keys from
  `fabric ticket list --json`.
- **Give a ticket structure.** The body is markdown, and the ticket page renders a few `##`
  sections specially, so write them: `## Intent` (why, before you start), `## Acceptance
  criteria` (a `- [ ]` task list of what "done" means), `## What changed` (fill in at propose
  time), `## Learnings` (findings worth keeping), `## Open questions` (a `- [ ]` task list; check
  an item off when it is resolved). Unrecognized headings still render, so the convention is
  additive. This is why intent belongs on the ticket, not repeated in the change description.
- **Hit a Fabric bug? Report it upstream** with `fabric report "<title>"`. `fabric ticket`
  files into this repo's own tracker; `fabric report` sends a bug or feature request about
  Fabric itself (a broken runner, a CLI or engine error, a missing feature) to the Fabric team.
  It attaches an auto-captured context envelope (fabric version, hub URL, repo id, tracked
  change id, OS/arch) so the report is reproducible by construction. Set the lane with
  `--category runner|cli|engine|feature-request`, add the last failed check run with
  `--attach-logs`, or build the whole report from the command that just failed with
  `fabric report --from-last-error`. Secrets in attached logs are scrubbed on your machine, the
  exact payload is previewed, and sending needs a confirm; under `--json` pass `--yes` (nothing
  is sent silently). Poll the Fabric team's response with `fabric report status <id>`.
- Every command takes `--json` for machine-readable output. Use it. Op refs are
  `actor:seq`, **not** the content hash.
- Every command that touches a change ends with a `View: <url>` line linking to it on
  the hub. Hand that URL to the human verbatim (it's `web_url` under `--json`); do **not**
  guess a link yourself, the hub gives you the correct one.
- Onboarding an existing git project? `fabric import <folder>` replaces the repo with
  that project's **full history** (it runs `git fast-export` for you), or `--into <path>`
  nests it under a subfolder. Diversion to object storage is **by size only**: files over
  the cap (a few MB), text or binary, become content-addressed pointers that round-trip on
  clone. A **small binary** (an image, a PDF, a short log) imports **inline** and round-trips
  byte-for-byte, no object storage. Nothing is dropped. Pick a branch/tag with `--ref
  <branch>` (default HEAD).
- **Single repo, and `import` REPLACES it wholesale.** Run `fabric repo status` first to
  see what's in trunk (empty, or a file count + last change). If trunk holds work, `import`
  asks you to confirm; in `--json` / non-interactive mode pass `--force` to opt in (no
  hidden clobber). An empty repo imports with no prompt.
- No repo setup needed: see `fabric import --help`. `import` **blocks until the import
  finishes** (printing the final commits, files-across-history, and object-storage summary;
  exit 0 on success / non-zero
  on failure) and relabels the repo from the source folder name. It advances **trunk**;
  to put the imported tree on disk afterward run `fabric clone --at trunk` (read-only), or
  `fabric change create "<name>"` then `fabric clone` to edit it. `import` prints these
  exact next steps on success.

## Deploying (Railway): zero CLI at deploy time

Fabric can deploy your repo to Railway **on accept**, with no GitHub and no deploy
command. The hub runs your repo's `deploy.yml` itself (inside its own embedded
[`act`](https://github.com/nektos/act) Docker container) the moment a change integrates
into trunk, and that workflow's `railway up` ships it.

One-time wiring:

```sh
fabric railway connect            # broker a Railway login; store the token as this repo's
                                  #   RAILWAY_TOKEN secret (reuses an existing `railway`
                                  #   session if you're already logged in, no prompt)
```

The connection is also visible in the web UI under **repo Settings → Railway**: a status
card showing connected vs. not, the credential type (long-lived token vs. auto-refreshing
OAuth) and its expiry, with **Connect / Reconnect / Disconnect** affordances. Connecting via
`fabric railway connect` shows up there too. The value itself is never displayed.

Then deploys are automatic: every `fabric accept` fires the deploy:

```sh
fabric login
fabric import <folder> --ref main   # onboard your project (full history; binaries → LFS)
fabric railway connect              # one-time: store RAILWAY_TOKEN (see above)
fabric change create "ship it"      # create the change BEFORE you clone
fabric clone --dir work && cd work
#   …edit…
fabric push && fabric propose && fabric accept   # accept → hub runs deploy.yml → railway up
fabric checks                       # watch the hub run the deploy (polls to terminal)
```

How the deploy fires: a change has no Git branches, so its lifecycle maps onto GitHub
Actions events: **accept → `push`**, **propose → `pull_request`**. So a workflow gated
`on: push` runs only on **accept** (the "push to the default branch" equivalent). Your
deploy workflow is therefore a normal `.github/workflows/deploy.yml`:

```yaml
on:
  push:
    branches: [main]
# …a job that installs the Railway CLI and runs `railway up`,
#   with RAILWAY_PROJECT_ID set so it targets the right project.
```

What you need for deploys to work:
- A `deploy.yml` (or any workflow) gated `on: push: branches: [main]`.
- A stored Railway credential: `fabric railway connect` writes it as the repo's
  `RAILWAY_TOKEN` secret. The hub keeps an OAuth credential fresh (minting a new access
  token from the refresh token before each deploy) and, at deploy time, injects the
  credential into the run's environment under the name the Railway CLI expects for its
  scope: a **project token as `RAILWAY_TOKEN`**, an **account login as
  `RAILWAY_API_TOKEN`** (the CLI rejects account tokens sent as `RAILWAY_TOKEN`). The
  injection is automatic, so do NOT map `${{ secrets.RAILWAY_TOKEN }}` into your
  workflow's `env:` yourself; a hand-mapped account token under the wrong name is
  exactly what makes `railway up` fail with "Invalid RAILWAY_TOKEN".
- **`RAILWAY_PROJECT_ID`** declared in your workflow or job `env:` (so `railway up`
  knows which project to deploy; a project token already encodes it, but declaring it is
  always safe).
- Secrets are encrypted at rest, injected into the act run, and **redacted from logs**.

Where your jobs run: the dev hub runs each job in a Docker container via your local
daemon (OrbStack). In production, jobs run **directly on a dedicated runner image**
(Ubuntu 24.04) with `bash`, `git`, `curl`, `unzip`, `xz`, Node.js 22 + `npm`, and the
Railway CLI preinstalled; anything else must be installed by a workflow step. This
host-mode runner supports `run:` steps and JS/composite `uses:` actions only, not
`services:` containers or Docker-based actions. The runner's own environment is scrubbed
from your jobs: apart from the synthesized `GITHUB_*`/`CI` vars, injected secrets, and a
small OS baseline (`PATH`, `HOME`, locale, proxy settings), host variables reach your
steps as empty strings, and any variable your workflow or job `env:` declares is always
yours.

`fabric checks` reports the hub-side run (including the deploy job) and, by default, polls
until it reaches a terminal state, re-rendering the per-job table as jobs change so an
agent can block on the deploy and follow progress. Pass `--no-wait` (alias `--once`) for a
single non-blocking snapshot of the current states (`--json` returns that one snapshot).
Checks only run once a change is proposed, so `fabric checks` on a **draft** tells you to
`fabric propose` first instead of polling forever; and if the checks make **no progress**
for 30 minutes (any job status change resets the clock; tune with `--timeout`, `0` waits
indefinitely) the command prints the last-known states and exits non-zero, so you never
block indefinitely on a stuck run. A failing job's summary names the failing **step** (e.g.
`Step 'Run linter' failed`); to read the full output, run `fabric checks --logs <job>`
(the job id, its `workflow / job` label, or its check-run id), which downloads that one
job's complete log so a failure is debuggable from the CLI alone. The `--json` payload
also carries each check's `web_url` and `log_url`. (Reminder: a `pull_request`-only
suite's results show after **propose**; a `push`/deploy run's results show after
**accept**.)

To pre-flight a change before proposing, `fabric check` runs its workflows locally in
Docker and prints per-job pass/fail. It is fully local (nothing is sent to the hub) so it
works offline. Add `--report` to also record that local run on the hub, where it shows in
the runs list tagged **local**. Reporting is best-effort (an unreachable hub never fails
the check), and a local run is informational only: it never counts toward the
required-checks gate `fabric accept` enforces.

**Build artifacts.** A workflow step running `actions/upload-artifact@v4` uploads named
files during a run, exactly as on GitHub. The hub captures each uploaded artifact and keeps
it against the run. Fetch them from the API: `GET /v1/vcs/runs/<run-id>/artifacts` lists a
run's artifacts (`id`, `name`, `size`, `download_url`), and `GET /v1/vcs/artifacts/<id>/download`
streams one back as a zip. The `run-id` is the run-group (batch) id; both are API-key
scoped to your org. They also appear with a download link on the web run-detail page.

## The event stream (wait on anything)

Every change-lifecycle event the hub records lands in one durable, append-only stream,
so you can watch a change instead of polling each surface. Read it at
`GET /v1/vcs/events` (API-key auth, your org's scope):

- `?since=<cursor>` returns every event past that cursor, oldest first (`0` starts at the
  beginning). Each event carries a monotonic `cursor`; remember the highest you saw and
  pass it back as `since` to resume with no gaps or dupes. The response also hands you a
  `next_cursor` to use verbatim on your next read.
- `?change=<change-id>` narrows the stream to one change.

Every event shares one envelope: `cursor`, `type` (the dotted vocabulary below), `change`
(the change it's about, empty for repo-level events), `actor` (who or what caused it),
`frontier` (the frontier hash it anchors to, when relevant), `summary` (a human/LLM
readable line), and `data` (a JSON object whose shape depends on `type`). The event also
fans out over the hub's SSE relay as a signal, so a live consumer is woken the instant a
new event lands and reads the durable rows back by cursor.

Rather than poll, the CLI wraps this in two poll-free commands over a long-lived SSE
connection (`GET /v1/vcs/events/stream`, same durable log, same auth):

- **`fabric wait`** blocks for one outcome and exits, for a foreground step.
  `fabric wait --change <id> --on '<selectors>'` returns the first event matching a selector
  glob (e.g. `check.*`, `comment.*,review.*`, `*`), then exits 0. `fabric wait --change <id>
  --until accepted` blocks until the change reaches a terminal outcome and encodes it in the
  exit code (accepted `0`, rejected `2`, abandoned `3`). `--background` detaches the wait so a
  harness can watch the child pid and wake a dormant agent when it exits.
- **`fabric events`** streams every event continuously (`--json` for one object per line),
  for a supervising daemon that tails the whole feed.

Both resume from the highest `cursor` this machine last delivered (saved per change), so an
event that lands between connections is replayed rather than missed. A first-ever wait starts
live at the current tip; `--since <cursor>` or `--from-start` override the resume point.

The `type` vocabulary and each `data` shape:

- **`status.proposed`**: a change went draft → proposed. `data`: `change_id`,
  `change_name`, `repo_id`.
- **`status.abandoned`**: a change was abandoned. `data`: `change_id`, `change_name`,
  `repo_id`.
- **`review.submitted`**: a reviewer recorded a verdict. `data`: `change_id`, `repo_id`,
  `reviewer`, `verdict` (`approve` | `request_changes`), `note`.
- **`comment.created`**: a comment (or reply) was posted. `data`: `change_id`, `repo_id`,
  `comment_id`, `anchor_type` (`is_reply` on a web reply, `parent_id` on a `fabric comments
  reply`); a trunk-file comment carries `path` and no `change`.
- **`comment.resolved`** / **`comment.unresolved`** / **`comment.deleted`**: a comment's
  state changed. `data`: `change_id`, `repo_id`, `comment_id`.
- **`mention`**: a comment @-mentioned org members. `data`: `change_id` (when on a
  change), `comment_id`, `mentioned` (the tagged user ids).
- **`check.completed`**: a change's checks reached a terminal conclusion. `data`:
  `change_id`, `repo_id`, `conclusion` (`success` | `failure`), `failed_check` (the first
  failing job, empty on success), `trigger_event` (`push` | `pull_request`).
- **`check.flake_detected`**: a check disagreed with itself on the identical frontier hash
  (red on one run, green on a re-run), so the earlier red was a flake and the change was not
  ejected for it. `data`: `change_id`, `change_name`, `repo_id`, `check`, `frontier_hash`,
  `run_index` (the re-run that came back green).
- **`conflict.detected`**: an accept found the merge would leave conflict markers, so it
  was refused. `data`: `change_id`, `change_name`, `repo_id`, `files` (the conflicted
  paths).
- **`trunk.advanced`**: the trunk frontier moved to a new checked state. `data`:
  `change_id`, `repo_id`, `frontier_hash`, `accept_seq` (the integration-order sequence);
  a promotion from the merge queue also carries `via: "merge_queue"` and `promoted` (how many
  changes landed in the batch).
- **`change.accepted`**: a change integrated into trunk. `data`: `change_id`,
  `change_name`, `repo_id`, `accept_seq`; `via: "merge_queue"` when it landed from the queue.
- **`change.rejected`**: an accept refused to land. `data`: `change_id`, `change_name`,
  `repo_id`, `reason` (`check_failed` | `conflict` | `cas_lost` | `check_timeout`), `check`
  (the failing check, for `check_failed` / `check_timeout`), `logs` (a pointer to its logs),
  `retry_count` (how many flake re-runs were spent before ejecting).
- **`channel.message`**: a message was posted in a channel. `data`: `channel_id`,
  `message_id`, `thread_root_id` (the message's own id for a top-level post, or the
  root message id for a reply), `author_kind` (`user` | `agent`), `author_id`.
- **`channel.agent_mention`**: a posted message @-mentioned an agent. One event per
  mentioned agent. `data`: `channel_id`, `message_id`, `thread_root_id`,
  `agent_id`, `author_kind`, `author_id`.

## Channel API

Agents interact with org channels through the `/v1/vcs/channels` endpoints
(API-key auth, same as the rest of the VCS API). This lets an agent monitor
conversations, respond to mentions, and collaborate with humans in channels.

**List channels:**
```
GET /v1/vcs/channels
```
Returns `{"channels": [{"id", "name", "topic", "created_by", "created_at"}]}`.

**Read messages** (cursor-paginated, newest first):
```
GET /v1/vcs/channels/<channel-id>/messages
    ?cursor=<timestamp>_<id>     # resume from a prior page
    &thread_root=<message-id>    # filter to one thread (includes the root)
```
Returns `{"messages": [...], "next_cursor": "<timestamp>_<id>"}`.
Each message carries `id`, `channel_id`, `thread_root_id` (null for top-level),
`author_kind`, `author_id`, `kind`, `body`, `metadata`, `created_at`.

**Post a message** (agent-linked keys only; plain keys get 403):
```
POST /v1/vcs/channels/<channel-id>/messages
Content-Type: application/json

{
  "body": "message text",
  "thread_root_id": "<optional, reply to this thread>",
  "kind": "text",             // or "activity"
  "metadata": {}              // optional
}
```
Returns the created message. The post fires the same SSE broadcast and
universal-stream events as a web post, so the message appears live for all
viewers. If the body @-mentions another agent, a `channel.agent_mention` event
is emitted for that agent in the same transaction.

**Reacting to mentions:** subscribe to the event stream filtered on
`channel.agent_mention` (`GET /v1/vcs/events/stream?on=channel.agent_mention`).
When an event arrives, read the thread (using the `thread_root_id` from the
event payload), and post a reply to that thread.

## Agent bridge (`fabric agent serve`)

The bridge is the local daemon that closes the agent loop. It runs on a user's
machine (or in a container), authenticates with the agent's API key, follows the
event stream, and when someone @-mentions the agent in a channel, it opens a
session for that thread, forwards messages to a harness, and posts replies back
through the channel API.

```sh
fabric agent serve --agent-key <key>
# or:
FABRIC_AGENT_KEY=<key> fabric agent serve --json
```

At startup the bridge calls `/v1/vcs/whoami` and verifies the key is linked to
an agent principal. It persists its event cursor under
`~/.config/fabric/agents/<agent-name>/state.json`, so a restart resumes without
reprocessing. Sessions idle-close after `--idle-timeout` (default 30m); a new
mention on the same thread reopens them. Graceful shutdown on SIGINT/SIGTERM.

The bridge spawns an ACP adapter process per session (default:
`npx @zed-industries/claude-code-acp`). Each session gets a workspace directory,
the `fabric` CLI on PATH, and the agent's credentials. Text chunks accumulate and
post as text messages; tool calls post as activity messages that update in place.
Override the adapter with `--adapter-cmd` / `--adapter-args`, or pass `--stub`
for the built-in echo responder (testing only).

## Operating rules

- This is a **non-interactive** client: pass args/flags directly, add `--json`,
  never expect a prompt. `fabric --help` and `fabric <cmd> --help` are always
  accurate. Consult them rather than guessing.
- One change = one reviewable unit of work. Keep it scoped; open another change for
  unrelated edits rather than piling everything onto one.
- If `push` reports nothing to do, your working tree already matches the change tip
  that's success, not an error.
- There is **one repo per account**: you never name it; the hub resolves it. The
  whole loop above is CLI-driven (no web UI needed).

That's it. Create → clone → edit → push → propose → accept. Append-only,
forward-only, reviewed-before-it-lands.
