Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ FACILITY_OAUTH_ISSUER=http://localhost:3400
FACILITY_OAUTH_JWKS=
MCP_PUBLIC_URL=http://localhost:4400/mcp

# Local repositories: directories (":"-separated) whose Git repositories may be registered without
# GitHub. Empty disables local repositories. See apps/docs/docs/self-host/local-mode.md.
FACILITY_LOCAL_REPOSITORY_ROOTS=
# Optional: user ids allowed to own registered repositories (default: the Facility process user).
FACILITY_LOCAL_REPOSITORY_OWNER_UIDS=
FACILITY_LOCAL_GIT_NAME=
FACILITY_LOCAL_GIT_EMAIL=
# The API binds loopback by default; widen only for a deliberately exposed deployment.
FACILITY_LISTEN_HOST=localhost

# GitHub App authentication and full installation capability.
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,7 @@ RUN printf '%s\n' \
# it proves that its production `postgres` dependency resolves.
RUN ["facility", "instance", "bootstrap", "--help"]
EXPOSE 4400
# Inside a container the API must accept the container network; the host decides
# what is published (docker-compose.yml publishes on loopback by default).
ENV FACILITY_LISTEN_HOST=0.0.0.0
CMD ["node", "dist/start.js"]
126 changes: 126 additions & 0 deletions apps/docs/docs/guides/local-repository.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
title: Local repositories
---

# Work on a local repository

Facility can run stories against a Git repository on the machine that runs Facility. No GitHub
account, App, installation, hosted remote, or webhook is involved. Agents work in persistent local
Docker workspaces, you review and revise their commits in Facility, and you import the approved
result into your own repository.

Model calls still go to the cloud provider configured for the project's Claude Code or Codex
engine, and they can include code and prompts. Repositories, workspaces, conversations, and review
stay on your machine.

An operator must enable local repositories first; see [Local mode](../self-host/local-mode.md).

## What Facility imports

Facility imports **committed history from the default branch only**. Uncommitted edits and
untracked files in your checkout are never copied. Facility never writes to your repository: it
reads committed objects into its own staging copy and packages them as a Git bundle for the
workspace. Repository hooks, `core.fsmonitor`, and system or global Git configuration are ignored
while it reads.

Submodules and Git LFS content are not imported yet. Registration and each import report them
explicitly; submodule directories appear empty and LFS files appear as pointer files.

## 1. Register the repository

The repository needs at least one commit and must be inside a directory the operator approved.

From the UI, choose **New project → Use a Git repository on this machine**, then enter a project
name and the repository's absolute path on the Facility host. With the CLI and an API key that has
`repos:write`:

```bash
export FACILITY_API_KEY=fak_...
facility repos add-local ~/code/shop --project=proj_... --alias=shop
```

The CLI lists the uncommitted and untracked paths that will stay behind before it registers
anything. The alias names the repository in `.facility.yml` as `local:<alias>`, so machine paths
never appear in committed configuration. A project uses either GitHub repositories or local
repositories, never both, and one organization owns a given host path.

## 2. Add the starter configuration

Facility proposes starter configuration as a patch rather than a pull request. The UI shows the
patch after registration; the API returns it from
`POST /v1/projects/:projectId/repos/:repoId/local-kickstart`. You can also write the files
directly:

```bash
facility init --local=shop --start="pnpm dev"
```

Both produce `.facility.yml` with `primary: local:shop` and three agents (`architect`, `builder`,
and `reviewer`) that commit to the story branch and never push, open pull requests, or run `gh`.
Review the files, then commit them on the default branch. Facility reads only committed
configuration.

Add `environment.checks` to run named checks against a story's exact commit during review:

```yaml
environment:
start: pnpm dev
checks:
test: pnpm test
lint: pnpm lint
```

## 3. Run a story

Start a story from the UI, MCP, or API as usual. On the first turn Facility resolves the default
branch once, reads `.facility.yml` and the agent catalog at that commit, imports the same commit
into the workspace, and creates the story branch from it. The turn's events record the commit and
configuration hash.

Later turns keep the workspace's history and uncommitted work. Facility never resets a story
branch when your checkout changes.

## 4. Review, revise, and approve

Open **Review and export** on the story page. It shows the commits and files since the imported
source, uncommitted changes, check results for the current head, and the approval state. Opening
it wakes a suspended workspace only for people who can run workspaces.

- **Request changes** records your note and opens the composer so you can ask an agent for the
revision.
- **Run checks** runs every configured check and attaches the results to the tested commit.
- **Approve** records approval of the exact head commit. Uncommitted changes are unfinished work
and must be committed first. Any later commit makes the approval stale.

## 5. Export and import

**Export approved commits** packages the approved commit range as a Git bundle and a patch. Download
the bundle next to your repository and import it into a new branch:

```bash
git fetch ./sexp_....bundle "refs/heads/facility/<story>:refs/heads/facility-review/<story>-<id>"
git log --oneline main..facility-review/<story>-<id>
git merge facility-review/<story>-<id>
```

Each export imports to its own branch name, so repeating an export never overwrites an earlier
import, and your working tree and default branch are untouched until you merge. Resolve any merge
conflicts as usual. You can instead apply the patch with `git am`.

Facility records the review and the export, not a merge. The workspace stays available, so you
can keep revising the story and export again.

## Pick up changes from your repository

When your default branch moves on, choose **Refresh source from repository**. Facility imports the
new commit as the workspace's source base and records the new revision. The story branch is not
moved. The workspace's copy of the default branch only fast-forwards when it has not diverged, so
an agent can rebase or merge deliberately.

## Limits of the first release

- Facility never runs directly in your checkout and never merges into it.
- Local repositories need the Docker workspace driver.
- Submodules and Git LFS content are reported, not imported.
- Pull requests, issue synchronization, GitHub triggers, and CI mirroring do not apply to local
projects. Manual, UI, MCP, and scheduled starts work.
2 changes: 1 addition & 1 deletion apps/docs/docs/reference/agent-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ catalog at an exact commit of the primary repository.
name: ci-doctor
description: Diagnoses and repairs failing checks on the current story pull request.
engine: codex
model: gpt-5.6-sol
model: gpt-6-luna
options:
reasoning_effort: high
enabled: true
Expand Down
25 changes: 25 additions & 0 deletions apps/docs/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,31 @@ at creation; store it as a secret.
Repository connections are organization- and installation-bound. A project-scoped key requesting
another project receives 404 rather than a distinguishable authorization error.

### Local repositories

- `GET /v1/local-repositories/status` reports whether local repositories are enabled and the
approved roots (`repos:write`).
- `POST /v1/projects/:projectId/repos/local` registers `{ path, alias?, defaultBranch? }`
(`repos:write`). The path must resolve inside an approved root, be owned by a trusted user, and
be a repository's top level with at least one commit. Errors include
`local_repositories_disabled`, `local_repository_outside_roots`, `local_repository_path_invalid`,
`local_repository_empty`, `local_repository_exists`, `local_repository_claimed` (another
organization registered the path), and `repository_sources_mixed`.
- `POST /v1/projects/:projectId/repos/:repoId/local-kickstart` returns starter configuration as a
`git apply` patch (`projects:kickstart`). It never writes to the repository.
- `/v1/projects/:projectId/workspace-stories/:storyId/local-review` returns commits, changed files,
uncommitted work, checks for the head commit, approval, and exports (`stories:read`; it wakes a
suspended workspace only for callers with `workspaces:execute`). Its sub-routes are `approve`
and `request-changes` (`stories:write`); `checks`, `refresh-source`, and `exports`
(`workspaces:execute`); and `exports/:exportId/bundle` and `exports/:exportId/patch`
(`stories:read`).

Approval names one commit: `review_commit_mismatch` rejects any other, `uncommitted_changes`
rejects a dirty workspace, and exporting requires an approval of the current head
(`approval_required`, `approval_stale`). Review actions return `turn_active` while a turn is
queued or running. A local registered path that later resolves elsewhere returns
`local_repository_path_changed` or `local_repository_outside_roots` on every use.

### Disconnect a repository

`DELETE /v1/projects/:projectId/repos/:repoId` requires `repos:write` and returns
Expand Down
23 changes: 22 additions & 1 deletion apps/docs/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ values without prompts.
| `--yes`, `-y` | Run without interactive confirmation. |
| `--force` | Overwrite the seven Facility-owned targets. |
| `--repo=<owner/name>` | Set the primary GitHub repository. |
| `--local[=<alias>]` | Configure a [local repository](../guides/local-repository.md) as `local:<alias>` (default: the directory name) with the local `architect`, `builder`, and `reviewer` agents. |
| `--provision=<command>` | Set optional `environment.setup`. |
| `--start=<command>` | Set required `environment.start`. |
| `--preview-readiness-command=<command>` | Set optional `environment.ready`. |
Expand All @@ -56,9 +57,29 @@ Init writes only `.facility.yml` and these manifests:
It preserves each existing file independently unless `--force` is explicit. Review before using
force: these files are project-owned configuration, not disposable generated output.

## `facility repos add-local`

Registers a Git repository on the machine running Facility with an existing project:

```bash
FACILITY_API_KEY=fak_... facility repos add-local ~/code/shop --project=proj_... --alias=shop
```

| Flag | Purpose |
| --- | --- |
| `--project=<id>` | Project to register with; defaults to `FACILITY_PROJECT_ID`. |
| `--alias=<name>` | Name used as `local:<name>` in `.facility.yml`; defaults to the directory name. |
| `--branch=<name>` | Default branch; defaults to the branch checked out in the repository. |
| `--api=<url>` | Facility API origin; defaults to `FACILITY_API_URL` or `http://localhost:4400`. |
| `--json` | Print the API response or error as JSON. |

The API key is read only from `FACILITY_API_KEY`, never from a flag. Before registering, the command
lists uncommitted and untracked paths, which Facility never imports.

## `facility doctor`

Doctor checks the local seven-file kickstart contract and exits non-zero when it finds a problem.
Doctor checks the local kickstart contract and exits non-zero when it finds a problem. For a
`local:<alias>` manifest it expects the local `architect`, `builder`, and `reviewer` agents.
Use `--dir=<path>` to inspect another checkout and `--json` for machine-readable results:

```bash
Expand Down
9 changes: 8 additions & 1 deletion apps/docs/docs/reference/project-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,15 @@ environment:

| Field | Required | Contract |
| --- | --- | --- |
| `primary` | Yes | `github.com/owner/repository`; an HTTPS prefix and `.git` suffix are accepted and normalized. |
| `primary` | Yes | `github.com/owner/repository` (an HTTPS prefix and `.git` suffix are accepted and normalized), or `local:<alias>` for a [local repository](../guides/local-repository.md). |
| `related` | No | Array in the same format; defaults to `[]`. |

A `local:<alias>` reference names a repository by the alias it was registered under, so host paths
never appear in the manifest. Aliases use letters, digits, `.`, `_`, and `-`, and cannot end in
`.git`. A project's repositories are all GitHub or all local. Facility imports local repositories
under `repos/_local/<alias>`, from the commit it read the manifest at, and does not fetch them
again on later turns.

The primary repository owns `.facility.yml`, `.agents/`, and the story branch. Facility checks out
each connected repository under `repos/<owner>/<repository>`, fetches updates, and configures a
workspace Git identity. The primary checkout is switched to the durable story branch. Related
Expand All @@ -75,6 +81,7 @@ connected to the Facility project and available through its GitHub App installat
| `stop` | No | Accepted stop command. The story lifecycle suspends provider compute and does not invoke it automatically. |
| `seed` | No | Shell command run after `setup` when setup is due. |
| `browser_test` | No | Shell command used by the browser-test operation. |
| `checks` | No | Map of lowercase check names to shell commands, at most 20. Local review runs them in the primary repository and records each result against the tested commit. |
| `secrets` | No | Array of declared secret names; defaults to `[]`. |
| `variables` | No | Array of declared non-secret operator-value names; defaults to `[]`. |
| `services` | No | Named preview services; defaults to `{}`. |
Expand Down
135 changes: 135 additions & 0 deletions apps/docs/docs/self-host/local-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
title: Local mode
---

# Run Facility for local repositories

Local mode runs Facility on your own machine against Git repositories on that machine. It needs
Docker and PostgreSQL. It needs no GitHub App, OAuth application, webhook, or hosted repository.
Users follow [Work on a local repository](../guides/local-repository.md) once it is running.

## Install

1. Install Docker and Node.js 24 with pnpm 11.20.0, and clone Facility.
2. Build the workspace image: `docker build -f runner/Dockerfile -t facility-runner:dev .`
3. Copy `.env.example` to `.env` and set `SECRET_MASTER_KEY` (`openssl rand -base64 32`).
4. Leave every `GITHUB_*` value empty.
5. Approve the directories Facility may read:

```bash
FACILITY_LOCAL_REPOSITORY_ROOTS=/home/you/code
```

Separate several roots with `:`. Facility refuses paths outside them, symlinks that resolve
outside them, and worktrees whose Git directory lives outside them.
6. Run `pnpm dev`, open `http://localhost:3400`, and choose **continue locally**.

`pnpm dev` starts PostgreSQL on `localhost:5461`, applies migrations, and runs the API, worker, and
UI. The API and worker both read local repositories, so both need the same roots.

## Settings

| Variable | Default | Purpose |
| --- | --- | --- |
| `FACILITY_LOCAL_REPOSITORY_ROOTS` | empty (disabled) | Directories whose repositories may be registered. |
| `FACILITY_LOCAL_REPOSITORY_OWNER_UIDS` | the Facility process user | Comma-separated user ids allowed to own registered repositories. |
| `FACILITY_LOCAL_SNAPSHOT_MAX_BYTES` | 512 MiB | Largest repository bundle Facility imports. |
| `FACILITY_LOCAL_GIT_NAME`, `FACILITY_LOCAL_GIT_EMAIL` | `Facility Agent`, `facility-agent@localhost` | Author identity for agent commits. |
| `FACILITY_LISTEN_HOST` | `localhost` | Interface the API binds. |

## Model providers

Local mode uses the existing Claude Code and Codex engines with cloud credentials. Configure them
per project exactly as for GitHub projects: declare the names under `environment.secrets` in
`.facility.yml`, then provide the values as project environment variables in **Settings** or as
`FACILITY_PROJECT_<PROJECT_ID>_ANTHROPIC_API_KEY` / `..._OPENAI_API_KEY` in `.env`. Existing
secret redaction, usage accounting, and budgets apply unchanged.

## Network exposure and authentication

Facility binds the API to loopback by default. Docker Compose publishes the API and UI on
`127.0.0.1` unless `FACILITY_BIND_ADDRESS` says otherwise. Remote access is a separate deployment
mode: configure GitHub or OIDC sign-in and follow [production](production.md) before widening
either setting.

The **continue locally** login (`FACILITY_INSECURE_DEV=1`) signs in as the local owner without a
password. It is supported for local mode only under these conditions, which Facility enforces:

- it is refused when `NODE_ENV=production`;
- `PUBLIC_URL` and `WEB_URL` must be loopback URLs;
- the connection must come from a loopback address; and
- the browser-facing host must be `localhost`, `127.0.0.1`, or `[::1]`. This defeats
DNS-rebinding pages and other machines on the network.

Because the proxy that serves the UI must connect from loopback, the shortcut is for the
`pnpm dev` setup. When Facility runs in containers, use GitHub or OIDC sign-in instead. Every API
call still passes the normal role, project-scope, and organization checks.

## Containers

To run the Compose stack in local mode, mount each approved root at the same path in the `api` and
`worker` services, list it in `FACILITY_LOCAL_REPOSITORY_ROOTS`, and set
`FACILITY_LOCAL_REPOSITORY_OWNER_UIDS` to the user id that owns the repositories on the host. Use
read-only mounts: Facility only reads source repositories.

```yaml
services:
api:
volumes:
- /home/you/code:/home/you/code:ro
worker:
volumes:
- /home/you/code:/home/you/code:ro
```

## Back up and restore

A local installation holds state in four places. Back up all of them together, while no turn is
running:

1. **Database.** Stories, conversations, review decisions, check results, imported source revisions,
and export bundles live in PostgreSQL:

```bash
pg_dump --format=custom --file=facility.dump "$DATABASE_URL"
```

2. **Workspace volumes.** Each story's files, story branch, and agent sessions live in a Docker
volume named `facility-ws-volume-<id>`:

```bash
for volume in $(docker volume ls -q --filter label=facility.workload.kind=workspace-v2); do
docker volume inspect --format '{{ index .Labels "facility.workspace.id" }}' "$volume" \
> "$volume.workspace-id"
docker run --rm -v "$volume:/workspace:ro" -v "$PWD:/backup" alpine \
tar -C /workspace -czf "/backup/$volume.tar.gz" .
done
```

3. **Source repositories.** Your repositories are not copied into Facility, except inside workspace
volumes. Back them up as you normally would. After a restore they must be at the same paths,
because Facility stores each repository's canonical path.
4. **Secrets.** Keep `SECRET_MASTER_KEY` and the project credentials in `.env`. Without the same
master key, restored encrypted project variables cannot be read.

To restore:

1. Recreate the database: `pg_restore --clean --dbname="$DATABASE_URL" facility.dump`.
2. Recreate each volume with the labels Facility uses to recognize its own workspaces, then extract
its archive:

```bash
for archive in facility-ws-volume-*.tar.gz; do
volume="${archive%.tar.gz}"
docker volume create \
--label "facility.workspace.id=$(cat "$volume.workspace-id")" \
--label facility.workload.kind=workspace-v2 "$volume"
docker run --rm -v "$volume:/workspace" -v "$PWD:/backup:ro" alpine \
tar -C /workspace -xzf "/backup/$archive"
done
```

3. Restore `.env` with the same `SECRET_MASTER_KEY`, and put source repositories back at their
registered paths.
4. Start Facility. Each workspace's compute is recreated from its volume the next time its story
runs.
Loading