---
name: fwozen
description: Check and control GitHub merge freezes with the fwozen CLI. Use before merging or deploying, and when asked about code freezes, release windows, or why merges are blocked.
---

# fwozen

Fwozen freezes pull-request merging across a GitHub organization during deploys, releases,
incidents, and change windows. The `fwozen` CLI is the same API the dashboard and the GitHub
Action use, so its answer is authoritative. Never guess a freeze state from a branch protection
rule, a Slack message, or a comment in a PR.

## Starting from nothing

Starting with no token, no organisation and no GitHub App installed, this is the whole path. These
are REST calls against `https://api.fwozen.com` rather than CLI commands, because the sign-in and
the App install both come before there is anything for a CLI to talk to.

The CLI itself installs with `curl -fsSL https://fwozen.com/install | sh`. One file, Node 24 or
newer, no package manager. It verifies a published sha256 before it writes anything, and
`https://fwozen.com/cli/release.json` restates that digest. It is **not** published to npm, so
`npx fwozen` and `npm i -g fwozen` do not work today; that installer and the source tree are the
two ways in. The full agent-facing version of this walkthrough, with the exact request bodies, is
at https://fwozen.com/llms.txt.

**Four moments need a human. They are not optional and they are not yours to simulate.**

1. **HUMAN — sign in.** Start a device authorization against Fwozen's own authorization server:
   `POST https://fwozen.com/api/auth/device/code` with the JSON body
   `{"client_id":"fwozen-agent"}`. That client id is public by design, it has no secret, and the
   server validates it against a fixed allowlist. Show the person `verification_uri_complete` and
   `user_code`, then poll `POST https://fwozen.com/api/auth/device/token` with
   `{"grant_type":"urn:ietf:params:oauth:grant-type:device_code","client_id":"fwozen-agent",
   "device_code":"..."}`. `authorization_pending` means keep waiting; `slow_down` means **add 5s
   to your interval** and keep waiting (the server does not raise its own floor, so retrying at
   the same rate is refused forever); `access_denied` and `expired_token` are terminal.

   **Send JSON to both, even though RFC 8628 says form-encoded.** `/device/code` accepts either;
   `/device/token` accepts JSON only and answers `415` to a form body, so a client that follows the
   RFC literally starts a login it can never finish.

   The person opens the page, signs in, enters the code, and sees the application name and the
   requested scopes before anything is granted. The link may prefill the code; it never approves it.
   Never relay a code to a human or ask one to read a code to you. A code that arrives by chat,
   email or phone is somebody else's sign-in, and repeated wrong codes lock that person out of code
   entry.

   What comes back is `access_token`, a **session token**, valid 30 days, that no Fwozen API
   accepts. Exchange it per call window:
   `GET https://fwozen.com/api/auth/token` with `Authorization: Bearer <session token>` returns
   `{ "token": "<jwt>" }`, and that 5-minute RS256 JWT is what goes to `https://api.fwozen.com` as
   `Authorization: Bearer <jwt>`. Cache it until shortly before it expires. There is nothing to
   rotate and nothing that is spent by being used. But the session token IS the long-lived secret,
   so treat it like one.

   To change which organisation the session acts in:
   `POST https://fwozen.com/api/auth/org/set-active` with `{"organizationId":"org_..."}` on the
   session bearer, then fetch a fresh JWT. The API has no organisation header, path segment or
   query parameter. The organisation is the JWT's `org_id` claim and nothing else. To end the
   session, `POST https://fwozen.com/api/auth/sign-out` on the session bearer; deleting your copy
   of a credential leaves it live.
2. `POST /v1/onboarding/bootstrap` creates the organisation. Idempotent.
3. `GET /v1/github/install-url` returns the install URL with a signed `state` nonce. Never build
   that URL yourself.
4. **HUMAN — install the GitHub App** at that URL. It must be a GitHub organisation owner, because
   Fwozen requests `members: read` at the organisation level. If the person is not an owner, GitHub
   files an approval request and the install sits `pending` with no further signal; that is normal,
   not a failure. Poll `GET /v1/installations/status?state=<nonce>`. `installed_claimed` is the
   finish line. `installed_unclaimed` carries no ids and is **not** repaired by
   `POST /v1/installations/claim`. It means the install is parked on an organisation nobody has
   proved they administer, and only a human in a signed-in browser can fix it. They open
   `https://fwozen.com/api/github/claim`, prove on GitHub which organisations they administer, and
   their browser redeems the ticket that comes back. Then call `/api/auth/org/set-active`, fetch a
   fresh JWT and poll again. Bootstrapping first (step 2) avoids that state entirely.
5. `POST /v1/repos/enable` with an **explicit repository list the human named**, then
   `POST /v1/repos/{repoId}/branches` for each branch to watch. Never enable repositories on your
   own initiative. Private repos are the billable unit.
6. **HUMAN — make the `fwozen` check required.** `GET /v1/enforcement/branches/{branchId}/snippets`
   returns gh CLI, Terraform and ruleset JSON. On an installation that has granted Fwozen repository
   `administration: write`, the person presses one button in the dashboard and Fwozen writes the
   repository ruleset itself; otherwise they save that rule in GitHub's own settings. Enforcement
   setup is **session-only** either way: no `fwz_` key scope reaches it, and
   `POST /v1/enforcement/setup` refuses a key with 403 `credential_type_not_allowed` — so this step
   is always a person's. `POST /v1/enforcement/verify` then reports what GitHub actually says.
   Until that rule is saved, a freeze is recorded but does not block a merge. Say so plainly.
7. `POST /v1/billing/trial` starts the 14-day Business trial: no card, once per organisation **and
   once per person**, and it does **not** auto-convert. At expiry the organisation downgrades to
   Free. A second organisation does not earn a second trial; asking answers 409
   `trial_already_used`.
8. **HUMAN (optional) — pay.** `POST /v1/billing/checkout` returns `{ url }` for Stripe Checkout.
   Hand the URL over and stop. Nothing here ever completes a payment.

Then mint the automation credential: `POST /v1/api-keys` with the **session JWT** (the one from
`/api/auth/token`, not an `fwz_` key). A key can never mint another key, so the device flow above is
how an agent gets its first one. The secret is shown once.

One thing to be honest with yourself about: a device session signs you in **as that person**. The
scopes you asked for are shown to them and recorded, but the JWT carries their role and their
permissions. The scope list is not a ceiling the API enforces on a session. A credential that
genuinely cannot exceed a stated list is an `fwz_` key.

## Authentication

- Humans: `fwozen login` (device flow; opens a browser, and a person approves the code on
  `https://fwozen.com/device`). `fwozen logout` REVOKES the session server-side and then deletes
  the local file; it exits non-zero if the revocation failed, and that is worth reporting rather
  than swallowing.
- CI and agents: set `FWOZEN_TOKEN=fwz_…` (an API key). `FWOZEN_TOKEN` always wins over stored
  credentials. For the full CLI, mint one key with `freeze:read freeze:write repo:read
  schedule:read`. Add `repo:write` if `verify` should refresh the probe rather than read
  last-verified state. Scopes are flat and cannot be widened after creation.

| Command | Scope it needs |
| --- | --- |
| `gate`, `status` (state) | `freeze:read` |
| `status` (org, repos, reconciler), `verify` (read), `whoami`, `org list` | `repo:read` |
| `status` (schedules) | `schedule:read` |
| `verify` (probe) | `repo:write` |
| `freeze`, `thaw` | `freeze:write` |

Never print, log, or echo the token.

## THE RULE

**Before merging a pull request or triggering a deploy, run the gate.**

```
fwozen gate --json
fwozen gate --repo owner/name --branch main --json    # when not inside the repo directory
```

`--repo` defaults to the `origin` remote of the current directory. With no repo at all, the
answer is the whole-org rollup.

Act on the exit code, not on the text:

- **exit 0** — thawed. Proceed.
- **exit 1** — do NOT merge and do NOT deploy. Report `reason` and `thawsAt` to the human.
- **exit 2** — not authenticated (no token, no request was made). Stop and say so.

Exit 1 covers two cases; the JSON tells them apart:

- `"indeterminate": false` — a real freeze is in force.
- `"indeterminate": true` — the API was unreachable, answered non-2xx, or omitted the decision
  header, and the gate failed closed on purpose. Report the outage; do not retry in a loop.

`--fail-open` converts an indeterminate result into exit 0. **Never pass it on your own
initiative.** Only use it when the human has explicitly said they accept deploying blind.

```json
{
  "frozen": true, "indeterminate": false,
  "reason": "Black Friday freeze", "thawsAt": "2026-12-01T09:00:00.000Z",
  "repo": "acme/ledger", "branch": "main", "allowDeployments": false,
  "enforcement": "green", "freezeId": "frz_…", "evaluatedAt": "2026-11-28T14:02:11.000Z"
}
```

The gate decides on the **deploy** signal. A freeze created with `blocksDeploys: false` stops
merges without stopping deploys. If you need the merge answer, read `fwozen status --json` and
check `state`.

## Reading state (safe, read-only)

`fwozen status --json` — org-wide picture: what is frozen, what is scheduled, how healthy
enforcement is.

```json
{
  "org": { "name": "Acme", "slug": "acme", "githubOrgs": 2, "tier": "team" },
  "repos": { "enabled": 48, "private": 44, "public": 4, "total": 61, "watchedBranches": 61 },
  "state": "thawed",
  "freezes": { "active": 0, "frozenBranches": 0 },
  "prs": { "open": 37, "blocked": 0 },
  "enforcement": "green",
  "schedules": [{ "name": "weekend-window", "nextAt": "…", "scope": "all", "recurrenceLabel": "…" }],
  "reconciler": { "lastRunAt": "…", "lagSeconds": 12 },
  "banner": "none"
}
```

`fwozen verify --json` — two independent readings per branch: what GitHub REQUIRES on it
(`configuration`), and what GitHub DISCLOSED about who can bypass that requirement
(`bypassAssurance`). It is not proof that a freeze would hold. `bypassAssurance` is `unknown` for
most branches, because GitHub rarely discloses bypass actors to the app reading a ruleset, and an
empty `bypass` list is never evidence that nobody can bypass. Use this when someone asks "would
a freeze even work here?" or when a freeze appears not to be holding.

```json
{
  "summary": { "green": 55, "yellow": 4, "red": 2, "unknown": 0 },
  "level": "yellow",
  "reconciler": { "lastRunAt": "…", "lagSeconds": 12 },
  "branches": [
    { "repo": "acme/spike", "branch": "main", "enforcement": "red",
      "required": false, "pinned": false, "bypass": [] }
  ]
}
```

`enforcement: "red"` means a freeze on that branch will **not** block a merge. Say that plainly.
`fwozen verify --strict` exits 3 when any branch is red.

`fwozen whoami --json` and `fwozen org list --json` answer "who am I / which org am I in".

## Consequential commands — explicit human instruction only

```
fwozen freeze all --reason "incident 4821: payments degraded"
fwozen freeze "acme/ledger" --branch main --reason "release 2026.11 cut" --duration 4h
fwozen thaw --reason "incident resolved"
```

- **Never freeze or thaw on your own judgement.** These block every engineer in the org.
- `--reason` is required and must be at least 8 characters. Write a real reason, not "freeze".
- `freeze all` freezes the **default branch of every enabled repo**. That is the whole company.
  Prefer a repo or glob scope unless the human asked for org-wide.
- `--dry-run` resolves the scope and writes nothing. Use it to show the blast radius first.
- An `all`/glob freeze resolves its repo count from the server before it is sent, and that number is
  recorded in the audit trail. If the count cannot be resolved the command REFUSES; only pass
  `--accept-blast-radius` when a human has explicitly told you to freeze without it.
- `--duration 4h` / `--until <iso>` set an automatic thaw; without either, the freeze holds until
  someone lifts it.
- `fwozen thaw` with no id thaws the single active freeze; with several active it lists them and
  exits 1 rather than guessing.

## Exit codes

| Code | Meaning |
| --- | --- |
| 0 | Success; `gate` thawed |
| 1 | `gate` frozen or indeterminate; or the operation failed or was refused |
| 2 | `gate` only: no token, so nothing was sent |
| 3 | `verify --strict` found branches where enforcement is red |
| 4 | Not logged in, or the session expired or was revoked. Run `fwozen login` |
| 64 | Bad usage (unknown flag, missing `--reason`, malformed duration) |

## Errors

Every command accepts `--json`. On failure, `--json` writes to stdout:

```json
{ "error": { "code": "insufficient_scope", "message": "…", "requestId": "req_…" } }
```

Always pass `requestId` through to the human when reporting a failure. Support looks it up.

## Troubleshooting

- **exit 4** — the sign-in session has expired or been revoked. Sessions last 30 days and do not
  slide, and a person can revoke one from their account settings at any time. Tell the human to run
  `fwozen login`; do not try to work around it. In CI, set `FWOZEN_TOKEN`.
- **`insufficient_scope`** — the API key is missing the scope named in the message. Reissue the key
  with it; scopes cannot be widened after creation.
- **`repository_not_found`** — the repo is not enrolled in Fwozen. It is not "thawed", it is
  unknown; the gate treats that as frozen.
- Progress and errors go to stderr, command output to stdout. Parse stdout only.
