# 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.fabricemr.com/install.sh | sh      # installs `fabric` to ~/.fabric/bin
fabric login --hub "https://app.fabricemr.com"             # browser device-auth; --hub is saved to config
```

You're reading this at `https://app.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.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.

**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…
```

**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 version-controlled markdown
  in the repo (title, status, assignee, priority, labels, and a body), so its history is in
  the operation log like any file. `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`.
- 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 **`RAILWAY_TOKEN`** repo secret: `fabric railway connect` stores it. The hub keeps it
  fresh, minting a new Railway access token from the refresh token before each deploy, so
  you don't re-run `connect` when a token expires.
- **`RAILWAY_PROJECT_ID`** set (so `railway up` knows which project to deploy).
- Docker available to the hub. The dev hub uses your local daemon (OrbStack); prod uses a
  dedicated Docker-runner service. (Secrets are encrypted at rest, injected into the act
  run, and **redacted from logs**.)

`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 hub does not finish in time
the command prints the last-known states and exits non-zero, so you never block
indefinitely on a slow hub. 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).

## 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.
