# Railway Source: https://docs.fabro.sh/administration/deploy-railway Deploy Fabro to Railway from the prebuilt GHCR image, with a persistent Volume for state [Railway](https://railway.com) can host the Fabro server by pulling the pre-built image published to GHCR. A single deploy button spins up a container, and a Railway Volume keeps your runs, checkpoints, and sessions across redeploys. ## One-click deploy [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/UcEy5m?referralCode=E5TucU\&utm_medium=integration\&utm_source=template\&utm_campaign=generic) The button launches Railway's template flow, which deploys the pre-built `ghcr.io/fabro-sh/fabro:nightly` image directly from GHCR — no Rust compilation on Railway's builder, so deploys complete in seconds. ## First-deploy checklist The template covers the build; a few pieces still need to be wired up after the first deploy. ### 1. Attach a Volume for `/storage` Fabro writes all persistent state — run history, checkpoints, sessions, the default token, and JWT keys — under `/storage`. Railway containers have ephemeral filesystems, so without a Volume that directory is wiped on every redeploy. In the Railway dashboard: **Service → Settings → Volumes → New Volume**, mount path `/storage`. 10 GB is a reasonable starting size; grow as needed. ### 2. Confirm the target port Railway sets `$PORT` automatically and expects the container to bind to it. The Fabro image honors `$PORT` and falls back to `32276`, and it exposes `32276`, so Railway's default HTTP routing works without any manual port configuration. If you override the port in **Service → Settings → Networking → Target Port**, match the value to `$PORT`. ### 3. Set bootstrap environment variables Add variables in **Service → Variables** as needed. The [Server Configuration](/administration/server-configuration) reference has the full list. Railway process env is for bootstrap values only: | Variable | Purpose | | - | - | | `FABRO_DEV_TOKEN` | Optional — pre-set the dev token instead of reading the one written to `/storage` on first boot | | `SESSION_SECRET` | 64-character hex string; required when the web UI is enabled | | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | Optional static S3 object-store credentials | Do not put optional integration secrets in Railway variables for server runtime. After the server is running, add LLM provider keys, Slack, Daytona, Brave Search, Venice Search, `GITHUB_TOKEN`, and GitHub App secrets to the server vault with `fabro secret set`, `fabro provider login`, or `fabro install`. No `.env` file is auto-loaded inside the container; bootstrap variables come from Railway's environment. ## Accessing your Fabro server Once the deploy is healthy, Railway exposes a `*.up.railway.app` URL (or your custom domain). Two things to grab: 1. **The dev token** — on first boot, Fabro writes one to `/var/fabro/dev-token` and logs it. Find it in the Railway deploy logs or run `railway run cat /var/fabro/dev-token` from the Railway CLI. 2. **Point your local CLI at the server** — add the Railway URL to `~/.fabro/settings.toml`: ```toml title="~/.fabro/settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.target] type = "http" url = "https://.up.railway.app/api/v1" ``` Then commands like `fabro model list --server ` will hit your Railway instance. See [Server Operations](/reference/server-operations) for the full auth and CLI-pointing story. ## Redeploys and updates Railway re-pulls the GHCR image on every deploy. The template uses the `:nightly` tag by default, so redeploying picks up the latest nightly automatically. To pin a specific version, change the image in **Service → Settings → Source** to `ghcr.io/fabro-sh/fabro:` and redeploy. The `/storage` Volume survives redeploys, so runs and checkpoints persist. ## Caveats * **Volume is load-bearing.** Without a Volume mounted at `/storage`, a redeploy silently wipes all state — including the dev token and JWT signing keys. Attach it before submitting any real runs. * **Single replica.** Fabro's server currently assumes one process owns `/storage`. Don't scale the service to multiple replicas. * **Architecture.** Railway runs x86\_64 (amd64) containers by default. The `:nightly` tag is multi-arch, but the arm64 variant is not currently usable — stay on amd64, which is Railway's default. ## Next steps Auth, dev tokens, submitting runs, and pointing the CLI at your deployment. Full `settings.toml` reference — reverse-proxy TLS, auth methods, concurrency, and more. # Deployment Source: https://docs.fabro.sh/administration/deployment Choose where the Fabro server runs: on your laptop or self-hosted on a server Fabro runs as a server. The CLI authenticates and communicates with that server, whether the server is running on your laptop or on a remote host. Choose where the server runs based on how you use Fabro. ## Two deployment modes | | Local | Self-hosted | | - | - | - | | **Where the server runs** | Your laptop | A host you operate (Docker container) | | **How to start it** | `fabro server start` | `docker compose up -d` | | **Best for** | Solo use, getting started, learning | Teams, production, 24/7 workflows | | **Trade-off** | Workflows pause when your laptop sleeps or shuts off | You operate the host | Both modes use the same image, the same workflow engine, and the same CLI. The only difference is where the server process lives. ## Local Run `fabro server start` on your machine. State persists under `~/.fabro/`. The CLI talks to it over a Unix socket by default. This is the right mode for solo use and getting started — no deployment required. When your laptop sleeps or shuts off, in-flight workflows pause until the laptop wakes again. For workflows that need to run 24/7 or for teams sharing a single instance, self-host. See [Server Operations](/reference/server-operations) for starting the server, the install wizard, authentication, and pointing the CLI at it. ## Self-hosted For team use, production workflows, or running 24/7, self-host the server as a Docker container. The recommended approach is `docker compose` for a single host, or any cloud container service (ECS, Cloud Run, Kubernetes) using the same image with the same requirements. Use a public reverse proxy such as Caddy when the server must be reachable from the public internet. Use Tailscale Services when the server should stay private to your tailnet; Fabro still serves plain HTTP locally, and Tailscale provides the HTTPS endpoint. Compose-first walkthrough, including Caddy and Tailscale Services options. One-click managed shortcut for the same Docker image. # Security Source: https://docs.fabro.sh/administration/security Security model and best practices Fabro is currently a research preview. It has not yet been subject to third-party penetration testing. There may be vulnerabilities. We strongly recommend only deploying Fabro on private, self-hosted resources within a controlled networking environment. ## Security controls not in Fabro Fabro is single-tenant software designed for small, trusted teams. The following controls are **not implemented** — plan your deployment accordingly. | Control | Status | | - | - | | **Multi-tenancy** | Not supported. Fabro is single-tenant only — there is no organization or tenant isolation. | | **Roles or ACLs** | Not supported. All authenticated users have identical, full access to every run, workflow, and API endpoint. | | **Inbound source-IP allowlisting** | Not implemented in Fabro. Restrict source networks with a reverse proxy, firewall, VPN, Tailscale, platform ingress policy, or another deployment-layer control. | | **Rate limiting** | Not implemented. The API server does not throttle requests. | | **Custom security header policy** | Not configurable. Fabro emits default security headers, including enforced `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, and `Strict-Transport-Security` when HTTPS is detected. | ## Security recommendations ### Network * **Deploy the web app on a private network.** Use a Tailscale tailnet, VPN, or internal network to keep the web UI off the public internet. * **Deploy the API on a private network.** Only allow access from expected hosts (the web app, CI runners, developer machines). The API binds to `127.0.0.1` by default — do not expose it with `--host 0.0.0.0` unless it is behind a firewall or private network. * **Enforce source-IP restrictions upstream.** Fabro does not provide inbound source-IP allowlisting. Use your reverse proxy, firewall, VPN, Tailscale ACLs, cloud load balancer, Kubernetes ingress, or platform policy to limit who can connect. * **Use Daytona sandboxes with network controls.** When running untrusted or generated code, use the Daytona sandbox provider with `network = "block"` or a CIDR-based allow list to restrict agent egress. ### Authentication * **Enable authentication.** Fabro supports `dev-token` and GitHub OAuth. Do not disable auth outside of local development or controlled demos. * **Configure a username allowlist for GitHub OAuth.** `[server.auth.github].allowed_usernames` should contain the exact GitHub users allowed to log in. An empty list rejects everyone. * **Configure the session secret used by the web flow.** `SESSION_SECRET` should be provisioned with a strong value on long-lived deployments. * **Terminate HTTPS or mTLS upstream when needed.** Fabro's listener is plain HTTP/Unix only. If CI, scripts, or a browser must connect over HTTPS, terminate TLS at a reverse proxy or load balancer and keep the Fabro listener on a private network. ### Secrets * **Keep API keys out of sandboxes.** The local sandbox strips environment variables ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL`, but Docker and Daytona sandboxes provide stronger isolation — only explicitly configured variables are passed through. * **Use the server vault for optional integration credentials.** For server-backed workflows, persist LLM provider keys, Slack, Daytona, Brave Search, Venice Search, GitHub token, and GitHub App secrets with `fabro provider login`, `fabro secret set`, or `fabro install`. Process env and `server.env` are reserved for bootstrap secrets such as `SESSION_SECRET`, `FABRO_DEV_TOKEN`, and object-store credentials. Do not commit secrets to version control. * **Rotate the session secret.** The `SESSION_SECRET` environment variable encrypts web app sessions. Rotate it periodically and use a strong random value. ### Execution * **Use Docker or Daytona for untrusted workflows.** The `local` sandbox offers no filesystem or network isolation — agents have the same access as the host. Use `docker` or `daytona` when running code you haven't reviewed. * **Restrict agent permissions.** Use `--permissions read-only` or `--permissions read-write` to limit which tools agents can invoke without approval. In non-interactive mode (`--auto-approve`), tools outside the permission level are denied outright. ## Security reporting If you discover a security vulnerability in Fabro, please report it responsibly: 1. **Email** [security@fabro.dev](mailto:security@fabro.dev) with a description of the vulnerability, steps to reproduce, and any relevant details. 2. **Do not** disclose the vulnerability publicly until we have had a chance to investigate and release a fix. 3. We will acknowledge receipt within 48 hours and aim to provide an initial assessment within 5 business days. 4. We will work with you to understand the issue and coordinate disclosure timing. We appreciate the security research community's efforts in helping keep Fabro and its users safe. # Self-host with Docker Source: https://docs.fabro.sh/administration/self-host-docker Run the Fabro server as a Docker container with docker compose, ECS, or any cloud container service The supported deployment artifact is the official Fabro image at `ghcr.io/fabro-sh/fabro`. Everything else — `docker compose`, ECS, Cloud Run, Kubernetes, Railway — is just running this image somewhere with the right requirements. ## Requirements | Requirement | Value | | - | - | | **Image** | `ghcr.io/fabro-sh/fabro:nightly` (multi-arch; pin a version for production) | | **Persistent volume** | Mount at `/storage`. Stores run history, checkpoints, sessions, the dev token, and JWT keys. | | **Port** | The container binds to `$PORT` (default `32276`). Expose it. | | **LLM provider key** | Add at least one provider key during the install wizard or later with `fabro secret set` / `fabro provider login`. | | **Replicas** | One. The server expects exclusive ownership of `/storage`. | ## Quickstart with docker compose The repo ships a `docker-compose.yaml` at the root. Clone the repo (or copy the file), create a `.env` for bootstrap settings if needed, and start it: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} git clone https://github.com/fabro-sh/fabro.git cd fabro cp .env.example .env # edit .env for bootstrap values such as SESSION_SECRET or FABRO_DEV_TOKEN if needed docker compose up -d ``` The compose file: * Pulls `ghcr.io/fabro-sh/fabro:nightly` * Creates a named volume `fabro-storage` mounted at `/storage` * Mounts `/var/run/docker.sock` so Fabro can spawn sandbox containers on the host daemon * Exposes port `32276` * Loads environment from `.env` if present Mounting `/var/run/docker.sock` gives the container host-root-equivalent access. Only use the bundled compose service in trusted, single-tenant deployments. See [Sandboxing](/administration/sandboxing) for the threat model. After the container is healthy, finish setup in your browser following the [install wizard](/reference/server-operations#first-run-web-install-wizard). ### Adding a reverse proxy with TLS For a production deployment exposed to the internet, layer the `docker-compose.prod.yaml` overlay on top. It adds a [Caddy](https://caddyserver.com) reverse proxy that terminates TLS (auto-provisioning a Let's Encrypt certificate) and forwards to Fabro: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} # In .env FABRO_DOMAIN=fabro.example.com # Then bring the stack up docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d ``` Leave `FABRO_DOMAIN` unset to serve plain HTTP on `localhost`. ### Private access with Tailscale Services For a private tailnet deployment, use [Tailscale Services](https://tailscale.com/docs/features/tailscale-services) instead of Caddy. Tailscale terminates HTTPS on the tailnet DNS name and forwards to Fabro over host loopback. In this mode there is no Caddy container, no Let's Encrypt certificate, and no public DNS record for the Fabro app. Configure the canonical external origin in `.env`: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} FABRO_WEB_URL=https://fabro-testing.example.ts.net ``` `FABRO_WEB_URL` must be the Tailscale Service HTTPS origin with no trailing slash. Fabro uses it for install-mode links, browser auth, API links, and run URLs. Start the loopback-only compose stack: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} docker compose -f docker-compose.tailscale.yaml up -d ``` Then publish the service from the host: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} tailscale serve --service=svc:fabro-testing --https=443 http://127.0.0.1:${FABRO_PORT:-32276} tailscale serve status --json ``` The host must be joined to the tailnet with a device identity allowed to advertise the selected service. Depending on the tailnet policy, a tailnet admin may need to approve the service before the HTTPS name is reachable. Tailscale Services is private tailnet ingress. GitHub webhook deliveries from github.com cannot reach it. If your Fabro deployment needs GitHub webhooks, use `server_url` with a public webhook endpoint, `tailscale_funnel`, or another public relay for the webhook path. ## Bootstrap environment variables For the web UI you need a session secret unless install mode is generating the initial local configuration: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} SESSION_SECRET=<64-character hex string> ``` Generate one with `openssl rand -hex 32`. `server.env` and container process env are for bootstrap values only: | Variable | Purpose | | - | - | | `SESSION_SECRET` | Session encryption secret | | `FABRO_DEV_TOKEN` | Optional — pre-set the dev token instead of reading the one written to `/storage` on first boot | | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | Optional static S3 object-store credentials | Do not put optional integration secrets in `.env` for server runtime. Configure LLM provider keys, Slack, Daytona, Brave Search, Venice Search, `GITHUB_TOKEN`, and GitHub App secrets in the vault with `fabro secret set`, `fabro provider login`, or `fabro install`. Optional: | Variable | Purpose | | - | - | | `FABRO_DOMAIN` | Public hostname when using the Caddy reverse-proxy overlay | | `FABRO_WEB_URL` | Canonical external web origin when TLS is terminated by a platform layer such as Tailscale Services | See [Server Configuration](/administration/server-configuration) for the full settings reference, and [`.env.example`](https://github.com/fabro-sh/fabro/blob/main/.env.example) for the complete list. ## Cloud container services The same image works on any container orchestrator that supports the requirements above. Common patterns: * **AWS ECS / Fargate** — Task definition referencing `ghcr.io/fabro-sh/fabro:nightly`, EFS volume mounted at `/storage`, port `32276` published, environment variables for bootstrap values, and vault-backed optional integration secrets. * **Google Cloud Run** — Cloud Run with a backed volume mount at `/storage`. Pin minimum instances to 1; scale-to-zero interrupts running workflows. * **Kubernetes** — One-replica `StatefulSet` (not Deployment) with a `PersistentVolumeClaim` mounted at `/storage`. Expose via Service + Ingress. In all cases: single replica, persistent `/storage`, expose `$PORT`, and configure optional integration secrets in the vault. ## Pinning a version `docker-compose.yaml` uses `:nightly` by default, so `docker compose pull && docker compose up -d` picks up the latest nightly. To pin a specific version, change the `image:` line to `ghcr.io/fabro-sh/fabro:`. Release artifacts ship with [SLSA Build Provenance](/reference/verifying-releases) attestations you can verify with `gh attestation verify`. ## Pointing the CLI at your server Once the container is running, install the CLI on your local machine and point it at the server: ```toml title="~/.fabro/settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.target] type = "http" url = "https://fabro.example.com/api/v1" ``` For dev-token auth, save the token in the CLI auth store: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro auth login --server https://fabro.example.com/api/v1 --dev-token fabro_dev_... ``` See [Server Operations](/reference/server-operations#pointing-the-cli-at-a-server) for the full CLI-target options. ## Caveats * **Volume is load-bearing.** Without a persistent volume at `/storage`, redeploys silently wipe all state — including the dev token and JWT signing keys. * **Single replica.** The server expects exclusive ownership of `/storage`. Don't scale to multiple replicas. * **Architecture.** The `:nightly` tag is multi-arch. The amd64 variant is the most heavily tested. ## Next steps Install wizard, web UI, authentication, demo mode, and pointing the CLI at the server. Full settings.toml reference — auth, reverse-proxy TLS, run defaults, and more. One-click managed shortcut for the same Docker image. The Docker sandbox provider's security model and trust assumptions. # Server Configuration Source: https://docs.fabro.sh/administration/server-configuration Server-owned settings.toml sections, CLI overrides, and environment variables ## Config file `fabro server start` reads `~/.fabro/settings.toml` by default. This is the same file schema used by the CLI. On a same-machine setup, the CLI and server share one `settings.toml`. On a remote deployment, the server machine has its own `settings.toml`, and the client machine keeps a separate local `settings.toml` for CLI-only values such as `[cli.target]`. Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.toml` filenames are no longer part of the supported config surface. ### Which sections are server-owned | Scope | Examples | | - | - | | Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.sandbox]`, `[server.storage]`, `[server.artifacts]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` | | Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.environment]`, `[environments.]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` | The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing. Fabro does not terminate inbound TLS directly. Bind `[server.listen]` to a Unix socket or plain TCP port, and terminate HTTPS or mTLS at a reverse proxy, load balancer, Tailscale Service, or platform ingress in front of Fabro. Use `[server.api].url` and `[server.web].url` for those external HTTPS URLs. ### Full reference ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [server.listen] type = "tcp" address = "0.0.0.0:3000" [server.api] url = "https://fabro.example.com/api/v1" [server.web] enabled = true url = "https://fabro.example.com" [server.auth] methods = ["dev-token", "github"] [server.auth.github] allowed_usernames = ["alice", "bob"] [server.sandbox.providers.local] enabled = true [server.sandbox.providers.docker] enabled = true [server.sandbox.providers.daytona] enabled = true [server.integrations.github] app_id = "123456" client_id = "Iv1.abc123" [server.integrations.github.webhooks] strategy = "tailscale_funnel" [server.storage] root = "/var/lib/fabro" [server.artifacts] provider = "s3" prefix = "artifacts" [server.artifacts.s3] bucket = "my-fabro-data" region = "us-east-1" [server.scheduler] max_concurrent_runs = 8 [server.logging] level = "info" # Run defaults — applied to every run unless overridden by workflow/project config [run.model] name = "claude-sonnet-4-5" provider = "anthropic" [run.model.fallbacks] "claude-sonnet-4-5" = ["gemini", "openai"] [[run.prepare.steps]] script = "npm install" [run.environment] id = "cloud" [environments.cloud] provider = "daytona" [environments.cloud.lifecycle] auto_stop = "60m" [environments.cloud.labels] team = "platform" [run.checkpoint] exclude_globs = ["**/node_modules/**", "**/.cache/**"] skip_git_hooks = false commit_timeout = "30s" [run.inputs] default_branch = "main" [run.git.author] name = "fabro-bot" email = "fabro-bot@company.com" ``` ### CLI overrides Several `settings.toml` settings can be overridden via `fabro server start` flags: | Flag | Default | Description | | - | - | - | | `--bind` | Resolved `[server.listen]`, falling back to `~/.fabro/fabro.sock` when `[server.listen]` is absent | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket | | `--web` | enabled | Enable the embedded web UI, browser auth routes, and web-only helper endpoints | | `--no-web` | disabled | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | | `--foreground` | — | Run in the foreground instead of daemonizing | | `--model` | — | Override default LLM model | | `--provider` | — | Override default LLM provider | | `--environment` | — | Override default environment slug | | `--max-concurrent-runs` | `5` | Maximum concurrent run executions | | `--config` | `~/.fabro/settings.toml` | Path to server config file | CLI flags take precedence over `settings.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order. ### `[server.web]` section Control the embedded SPA and browser-oriented routes. | Key | Description | Default | | - | - | - | | `enabled` | Serve the embedded SPA, `/auth/*`, and the web-only helper endpoints under `/api/v1` | `true` | | `url` | Required single canonical origin for the server. The browser UI and `/auth/*` routes use this origin, and it must be an absolute `http(s)` URL. | `http://localhost:3000` | When `enabled = false`, the server still exposes the machine API and `/health`, but `/`, `/auth/*`, SPA client routes, `/api/v1/auth/me`, and `/api/v1/setup/*` all return `404`. `server.web.url` is not a secondary web host. Fabro supports a single public origin for API and web traffic. In local development that can be plain HTTP such as `http://localhost:3000` or `http://127.0.0.1:3000`. In production, operators are responsible for terminating HTTPS upstream. When the external origin is assigned at deployment time, set `FABRO_WEB_URL` in the server process environment. It overrides `server.web.url` for the canonical browser/API origin and is the recommended control for Tailscale Services deployments. ### `[server.auth]` section Configure how users authenticate with the server. | Key | Description | Default | | - | - | - | | `methods` | Ordered list of enabled bootstrap methods: `dev-token`, `github` | `["dev-token"]` | When `"dev-token"` is enabled, the API accepts `Authorization: Bearer fabro_dev_...` and the login page can authenticate with the dev token directly. When `"github"` is enabled, browser users can sign in with GitHub OAuth and receive a session cookie. ### `[server.auth.github]` section GitHub-specific auth policy. | Key | Description | | - | - | | `allowed_usernames` | GitHub usernames allowed to complete OAuth login | The GitHub OAuth client ID still lives under `[server.integrations.github].client_id`. ### `[server.sandbox.providers]` section Controls which sandbox providers the server may launch, keyed by provider kind. The bundled providers `local`, `docker`, and `daytona` run inside the server and default to `enabled = true` when their entry is missing. Disabling a provider rejects new runs whose effective provider is disabled; dry-run Docker/Daytona runs use the local provider and are governed by `server.sandbox.providers.local.enabled`. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.sandbox.providers.local] enabled = true [server.sandbox.providers.docker] enabled = true [server.sandbox.providers.daytona] enabled = true ``` The built-in `local`, `docker`, and `daytona` providers run in process. `local` selects Petri's Host provider. Built-in entries reject `path`, `sha256`, `dev`, `args`, `env`, and `inherit_env`; no plugin executable or checksum is needed. The legacy built-in `PETRI_SANDBOX_{HOST,DOCKER,DAYTONA}_{PLUGIN,SHA256}` variables are ignored. Any other key names a [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugin: an executable that speaks the sandbox-driver JSON-RPC protocol on stdin and stdout. The kind must be lowercase ASCII letters, digits, and interior hyphens. The plugin starts with a scrubbed environment: only `env` and the ambient variables listed in `inherit_env` reach it. Bundled providers reject these plugin keys. The server launches the plugin to reach a sandbox after the fact (the sandbox tab, files, terminal, Ask Fabro). Runs execute only on the built-in providers for now, so a run's worker never launches a plugin and receives none of these settings. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.sandbox.providers.e2b] enabled = true path = "/opt/fabro/plugins/sandbox-driver-e2b" # default: `sandbox-driver-` on PATH sha256 = "0123…cdef" # pin the executable; `dev = true` skips it args = [] inherit_env = ["PATH"] [server.sandbox.providers.e2b.env] E2B_API_URL = "https://api.e2b.example" ``` | Key | Description | Default | | - | - | - | | `enabled` | Whether runs may select this provider | `true` | | `path` | Plugin executable path | `sandbox-driver-` on `PATH` | | `sha256` | Pinned SHA-256 of the executable, hex | none | | `dev` | Allow launching without a checksum | `false` | | `args` | Arguments passed to the executable | `[]` | | `env` | Complete environment for the plugin, apart from `inherit_env` | `{}` | | `inherit_env` | Ambient variables forwarded from the server process | `[]` | ### Removed: `[server.slatedb]` Earlier releases kept an embedded SlateDB key-value store beside the artifact store. Run history and content-addressed blobs now live in SQLite, so the section has no store behind it. A `settings.toml` that still carries `[server.slatedb]` is rewritten once at startup: the section is removed, a backup is written beside the file, and a warning names both. Delete the section yourself to avoid the rewrite. The browser install wizard's `Object store` step manages `[server.artifacts]`. `Local disk` uses the detected local object-store root, defaulting to `/objects`, with the fixed prefix `artifacts`. `AWS S3` writes one bucket with the same fixed prefix. ```toml title="Local disk object store" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.artifacts] provider = "local" prefix = "artifacts" [server.artifacts.local] root = "/var/lib/fabro/objects" ``` The wizard only covers AWS S3 bucket/region plus one of: * runtime credentials already supplied by the deployment environment * manually-entered `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` Advanced S3-compatible settings such as custom `endpoint` or `path_style` remain a manual configuration path. If you need MinIO, R2, or another S3-compatible backend, configure `[server.artifacts]` directly in `settings.toml`. The runtime still honors those hand-edited values even though the browser wizard does not manage them. ### SQLite state and migration backups Shared relational state, including run records and current run rows, content-addressed blobs, vault entries, server-managed definitions, and CLI auth sessions, lives at `/db/fabro.sqlite3`. CLI auth sessions are stored as an `auth_sessions` row per signed-in CLI, with the rotating refresh tokens for that session in `refresh_tokens`. Pending browser-to-CLI handoffs live briefly in `oauth_authorization_codes`; the table contains a SHA-256 hash of each one-time code, never the raw bearer value. Revoking a session from **Settings → Sessions**, or with `DELETE /api/v1/auth/sessions/{id}`, deletes the session row and its tokens together. Before applying pending SQLite migrations, Fabro creates `/db/fabro.sqlite3.pre-migration.bak` with SQLite's `VACUUM INTO`. Each migration run replaces the previous snapshot, so only the most recent pre-migration backup is retained. On the first compatible startup after upgrading from the file-backed vault, Fabro imports `/vaults/default/secrets.json` into SQLite. Existing SQLite rows win on name conflicts. After a successful import, the source file is renamed to `secrets.json.imported-.bak`. This temporary compatibility importer is scheduled for removal after 2026-10-11. Vault values and their migration backups retain Fabro's plaintext-at-rest behavior. Protect the storage volume and backup file with the same access controls as the secrets they contain. ### Run defaults The `[run.*]` sections in `settings.toml` act as defaults for every run. Run creation uses the registered workflow version's `workflow.toml` above the server defaults, with explicit `RunIntent` arguments at highest precedence. The same rules apply to local and remote clients. Project and user config are not submitted to the create endpoint. Manifest-based preflight, validation, and graph previews still accept their bundled configuration layers. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, and `[server.scheduler]` come from the server machine's `settings.toml` or `fabro server start` flags. Environments and MCP server references resolve against the server's catalogs. Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, explicit intent inputs merge per key at highest precedence, environment `env` and `labels` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging. ### `[server.logging]` section Configure the server log level and destination. | Key | Description | Default | | - | - | - | | `level` | Log level: `error`, `warn`, `info`, `debug`, `trace` | `"info"` | | `destination` | Where server logs are written: `file` (rotated daily under `/logs/`) or `stdout` | Daemon: `"file"`; foreground: `"stdout"` | Level precedence: `FABRO_LOG` env var > `--debug` flag > `[server.logging].level` > `"info"`. Destination precedence: `FABRO_LOG_DESTINATION` env var > `[server.logging].destination` > command-mode default. `stdout` is incompatible with daemon mode — use `fabro server start --foreground` (which is what container images do). Default install-generated `settings.toml` omits `destination` so foreground mode streams logs to stdout without extra config. The CLI has its own `[cli.logging]` section. ### `[run.git.author]` section Override the Git author and committer identity for every commit a run creates: Fabro's own checkpoint and metadata commits, and any `git commit` a prepare step, command stage, or agent tool runs inside the sandbox. Fabro resolves one identity per run and injects it as `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL` into every workflow command, so the primary checkout, additional clones, and repositories a workflow clones itself all commit as the same identity. It does not write to any Git configuration file. When a field is not set, Fabro derives it from the run's GitHub credential: | Credential | Name | Email | | - | - | - | | GitHub App (`strategy = "app"`) | `[bot]` | `+[bot]@users.noreply.github.com` | | Token (`strategy = "token"`) | the token's user login | `+@users.noreply.github.com` | | None | `Fabro` | `noreply@fabro.sh` | Setting both `name` and `email` skips the credential lookup. Setting one field overlays it on the derived identity. A lookup failure for the selected credential fails the run at setup; Fabro never silently switches to another author. The resolved identity is recorded in the run's event stream as `git.identity.resolved` and in the run state as `git_identity`. | Key | Description | Default | | - | - | - | | `name` | Git author and committer name | derived from the run's GitHub credential | | `email` | Git author and committer email | derived from the run's GitHub credential | ### `[server.integrations.github]` section Configure GitHub integration auth. `strategy = "token"` is the default and uses a stored `GITHUB_TOKEN` from the vault. `strategy = "app"` enables the GitHub App flow and browser OAuth; webhook delivery is configured separately under `[server.integrations.github.webhooks]`. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.integrations.github] strategy = "token" ``` For GitHub App mode, set `strategy = "app"` and include `app_id`, `client_id`, and `slug`. Webhook delivery is configured under `[server.integrations.github.webhooks]`: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.integrations.github] strategy = "app" app_id = "123456" client_id = "Iv1.abc123" slug = "fabro-app" [server.api] url = "https://fabro-api.example.com" [server.integrations.github.webhooks] strategy = "server_url" ``` Fabro always serves the GitHub webhook handler at `POST /api/v1/webhooks/github` when `GITHUB_APP_WEBHOOK_SECRET` is configured. The `strategy` field controls how Fabro exposes that route and whether it mutates the GitHub App webhook URL on startup: * `strategy = "server_url"`: recommended for production or any deployment with a stable public API URL. Fabro sets the GitHub App webhook URL to `/api/v1/webhooks/github` on startup. Requires `server.api.url` and `GITHUB_APP_WEBHOOK_SECRET`. * `strategy = "tailscale_funnel"`: opt-in for Tailscale-hosted machines without a stable public URL. Fabro runs `tailscale funnel `, exposes the main server on that Funnel URL, and best-effort updates the GitHub App webhook URL to `/api/v1/webhooks/github`. Requires a TCP listener and `GITHUB_APP_WEBHOOK_SECRET`. * `strategy` unset: Fabro still accepts signed webhook deliveries on `/api/v1/webhooks/github` when the secret is present, but it does not run `tailscale funnel` and does not update the GitHub App webhook URL. Tailscale Services and Tailscale Funnel are different ingress features. Services are private to the tailnet and work well for Fabro's web UI and CLI API access, but github.com cannot deliver webhooks to a private Service URL. Use `tailscale_funnel`, `server_url`, or another public relay when GitHub webhook delivery is required. Incoming webhooks are authenticated only by GitHub's `X-Hub-Signature-256` HMAC signature, not by Fabro's bearer/session auth. ### `[run.checkpoint]` section Configure checkpoint behavior for all runs. | Key | Description | | - | - | | `exclude_globs` | Glob patterns for files to exclude from checkpoint commits (for example, `["**/node_modules/**"]`) | | `skip_git_hooks` | When `true`, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks. Defaults to `false`. Does not affect Fabro workflow `[[run.hooks]]` or metadata-branch snapshots. | | `commit_timeout` | Max duration for the per-node run-branch checkpoint commit (e.g. `"30s"`, `"10m"`). This commit runs repository commit hooks unless `skip_git_hooks` is `true`. Defaults to `"30s"`. | `exclude_globs` replaces across layers — the highest-precedence layer wins wholesale. `skip_git_hooks` and `commit_timeout` use normal override semantics. See [Run Configuration — Checkpoint](/execution/run-configuration#runcheckpoint) for per-run configuration. ## Secrets and environment variables Fabro splits server-runtime secrets into two scopes: * Bootstrap secrets live in process env or `/server.env` and resolve with precedence `process env -> server.env`. * Optional integration secrets live in the server vault in the shared SQLite database. Anything stored in the vault may be used by workflows. `server.env` is only for bootstrap/runtime values the server may need before optional integrations are loaded: * `SESSION_SECRET` when the web UI is enabled * `FABRO_DEV_TOKEN` when `"dev-token"` auth is enabled * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` when a manual config uses static S3 object-store credentials `server.env` is not used for Slack, Daytona, Brave Search, Venice Search, LLM provider keys, `GITHUB_TOKEN`, or GitHub App private key/client secret/webhook secret. Configure those optional integrations with `fabro secret set`, `fabro provider login`, or `fabro install`. Startup does not import optional integration secrets from process env or `server.env`, or rewrite old `credential` / `environment` vault entries. Provision these secrets with the commands above before upgrading an installation that still uses those retired sources or formats. Fabro no longer auto-loads `.env` files. Provider API keys are required for the models you want to use; everything else is optional. ### LLM provider keys Server-backed workflows resolve built-in provider credentials from exact-name vault tokens or OAuth entries. Add them with `fabro provider login` or `fabro secret set`: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set ANTHROPIC_API_KEY sk-ant-... fabro secret set OPENAI_API_KEY sk-... fabro secret set GEMINI_API_KEY AI... ``` `fabro exec` and direct library usage can opt into env-backed credential sources explicitly. Runs cannot: the Fabro server reads provider keys from the vault after the temporary startup migration, and workers start from a cleared environment that does not inherit provider keys. | Variable | Provider | | - | - | | `ANTHROPIC_API_KEY` | Anthropic (Claude) | | `OPENAI_API_KEY` | OpenAI (GPT) | | `GEMINI_API_KEY` | Google (Gemini) | | `MOONSHOT_API_KEY` or `KIMI_API_KEY` | Moonshot AI; `MOONSHOT_API_KEY` takes precedence | | `ZAI_API_KEY` | Zai (GLM) | | `MINIMAX_API_KEY` | Minimax | | `INCEPTION_API_KEY` | Inception (Mercury) | | `POOLSIDE_API_KEY` | Poolside (Laguna) | | `DEEPSEEK_API_KEY` | DeepSeek | | `OPENROUTER_API_KEY` | OpenRouter (when enabled) | | `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` | Modal (when enabled) | | `FIREWORKS_API_KEY` | Fireworks AI (when enabled) | Modal requires both vault tokens. Its provider definition resolves them into the `Modal-Key` and `Modal-Secret` request headers. ### Sandbox and tools These optional server integrations are vault-only: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set DAYTONA_API_KEY dtn_... fabro secret set BRAVE_SEARCH_API_KEY BSA... fabro secret set VENICE_API_KEY venice-... ``` The built-in [`web_search`](/agents/tools#web_search) tool selects its backend from these credentials. It uses direct Brave Search when `BRAVE_SEARCH_API_KEY` exists. Otherwise it uses Venice Search when `VENICE_API_KEY` exists. When neither exists, the tool is not registered. | Variable | Description | | - | - | | `DAYTONA_API_KEY` | Daytona cloud sandbox API key | | `BRAVE_SEARCH_API_KEY` | Brave Search API key; the preferred `web_search` backend when present | | `VENICE_API_KEY` | Venice API key; used by the Venice LLM provider and by `web_search` when no Brave key exists | ### Server authentication Fabro resolves these from `process env -> server.env`. | Variable | Description | | - | - | | `SESSION_SECRET` | Session encryption secret (64-character hex string) | | `FABRO_DEV_TOKEN` | Optional fixed development auth token | ### Object store runtime secrets (optional) Fabro resolves these from `process env -> server.env`. | Variable | Description | | - | - | | `AWS_ACCESS_KEY_ID` | Static AWS access key ID for an S3-backed `[server.artifacts]` | | `AWS_SECRET_ACCESS_KEY` | Matching static AWS secret access key | | `AWS_SESSION_TOKEN` | Optional matching AWS session token for temporary static credentials | The browser install wizard can write these into `server.env` for the AWS S3 manual-credential path. It does not support manual STS/session-token input; use runtime credentials instead for ECS, EC2 instance profiles, IRSA, or web-identity flows. For the narrowest production policy, scope access to one bucket and the `artifacts/` prefix with `s3:ListBucket` plus `s3:GetObject`, `s3:PutObject`, and `s3:DeleteObject`. Prefer a dedicated IAM user or role for Fabro instead of reusing broad AWS credentials. If `POST /install/finish` fails after mutating `server.env`, the error response may include `leftover_env_keys` or `removed_env_keys`. For AWS object-store keys, treat `removed_env_keys` as informational: the prior managed key path was already cleared, so complete the retry before restart. If `leftover_env_keys` includes `AWS_ACCESS_KEY_ID` or `AWS_SECRET_ACCESS_KEY`, retry the install or remove the managed object-store lines from `server.env` before abandoning the host. Rotation is not required solely because finish failed after an atomic `0600` rewrite, but it remains the fallback if you no longer trust the host boundary. ### GitHub integration (optional) GitHub token mode is vault-only: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set GITHUB_TOKEN ghp_... ``` | Variable | Description | | - | - | | `GITHUB_TOKEN` | GitHub personal access token, stored by `fabro install` when `strategy = "token"` | ### GitHub App extras (optional) GitHub App mode stores these secrets in the vault. `fabro install` writes them automatically when it registers an app; do not put them in `server.env`. | Variable | Description | | - | - | | `GITHUB_APP_CLIENT_SECRET` | GitHub App client secret | | `GITHUB_APP_WEBHOOK_SECRET` | GitHub App webhook secret | | `GITHUB_APP_PRIVATE_KEY` | GitHub App private key (base64-encoded) | ### Slack integration (optional) Slack credentials are server-level secrets. Add `[server.integrations.slack]` to enable one Slack connection that is shared by human interview prompts and run lifecycle notifications. `server.integrations.slack.default_channel` is an optional literal channel name used only as the default destination for interview prompts; it does not interpolate. Lifecycle notifications use `[run.notifications..slack].channel` in run or workflow configuration. Fabro resolves these from the vault only. When `[server.integrations.slack]` is present and both credentials are present, startup logs `Slack integration enabled` and then the Slack Socket Mode connection status. If the Slack config table is absent or `enabled = false`, startup logs `Slack integration disabled by server configuration`. If the table is present but either credential is missing or empty, startup logs `Slack integration disabled; missing credentials` with the missing variable names. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set FABRO_SLACK_BOT_TOKEN xoxb-... fabro secret set FABRO_SLACK_APP_TOKEN xapp-... ``` | Variable | Description | | - | - | | `FABRO_SLACK_APP_TOKEN` | Slack App-level token | | `FABRO_SLACK_BOT_TOKEN` | Slack Bot token | ### Logging | Variable | Default | Description | | - | - | - | | `FABRO_LOG` | `info` | Log level: `error`, `warn`, `info`, `debug` | | `FABRO_LOG_DESTINATION` | Command-mode default | Server log destination: `file` or `stdout` | # Troubleshooting Source: https://docs.fabro.sh/administration/troubleshooting Diagnosing and resolving common issues with Fabro ## `fabro doctor` The `fabro doctor` command validates your installation: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro doctor # Local config checks + live server diagnostics fabro doctor --verbose # Show detailed output for each check fabro doctor --server https://fabro.example.com/api/v1 ``` It checks: * Local user config and storage directory health * Server-reported LLM provider connectivity, with configured providers probed concurrently * GitHub App, sandbox, and web search credentials (Brave or Venice), plus Docker daemon reachability when the Docker sandbox provider is enabled * Server authentication and crypto configuration LLM provider probe failures are reported as errors. Use `--verbose` to see the underlying provider error chain when a key, network route, or model endpoint fails. ## Common issues **Install wizard didn't open in my browser** — `fabro server start` prints the install URL to stderr before attempting to open it. Copy that URL into your browser manually. Auto-open fails on headless Linux hosts, inside most containers, and over SSH sessions without a display. **Server exited after I finished the install wizard** — Expected. The server writes `~/.fabro/settings.toml` and exits cleanly at the end of the wizard. Start it again with `fabro server start` to boot in configured mode, or run it under a supervisor with a restart policy (for example docker-compose `restart: unless-stopped`, systemd, or Railway's restart-on-exit) so the second start happens automatically. **"No API key configured"** — For runs, set at least one provider key in the server vault with `fabro provider login` or `fabro secret set`; workers start from a cleared environment, so exporting a provider key in the server's shell has no effect on runs. `fabro exec` and direct library usage can use env-backed credential sources explicitly. Run `fabro doctor` to verify server connectivity. **Stall watchdog timeouts** — If runs are cancelled unexpectedly, the agent may be stuck or the LLM provider may be slow. Check `FABRO_LOG=debug` output for `Agent.LlmRetry` events. Increase `stall_timeout` in the graph if needed, or add [fallback providers](/core-concepts/models) to handle outages. **Sandbox creation failures** — For Docker: run `fabro doctor`; when Docker is enabled, it pings the daemon and reports whether to start Docker, fix socket permissions, or disable the provider. Also ensure the configured image exists. For Daytona: verify `DAYTONA_API_KEY` is stored in the server vault, includes `write:snapshots`, `delete:snapshots`, `write:sandboxes`, and `delete:sandboxes`, and that GitHub access is configured. For Exe: verify your SSH keys are configured for `exe.dev` and that `ssh exe.dev` connects successfully. **Port already in use** — Change the port with `fabro server start --port 3001` or stop the conflicting process. **SSE streams disconnecting** — If using a reverse proxy, ensure buffering is disabled and the connection timeout is long enough for workflow runs. **Run config validation errors** — Use `fabro preflight` to validate without executing: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro preflight run.toml ``` # Hooks Source: https://docs.fabro.sh/agents/hooks Run custom logic in response to workflow lifecycle events Hooks let you run custom logic at key points during a workflow — before a stage starts, after a run completes, when a sandbox is ready, and more. Use them for validation, notifications, guardrails, and orchestration without modifying the workflow graph itself. ## Hook types Fabro supports four hook types, from simple shell commands to full agent sessions: ### Command Run a shell command via `sh -c`. The simplest and most common hook type. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[hooks]] event = "stage_start" command = "./scripts/pre-check.sh" ``` ### HTTP POST the event context as JSON to an HTTP endpoint. Useful for webhooks, external APIs, and notification services. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[hooks]] event = "run_complete" type = "http" url = "https://hooks.example.com/done" [hooks.headers] X-Deployment-Environment = "{{ vars.DEPLOY_ENV }}" ``` | Field | Description | | - | - | | `url` | The endpoint to POST to. Must use `https://` unless `tls = "off"`. Supports `{{ vars.NAME }}` interpolation. | | `headers` | Optional HTTP headers. Values support `{{ vars.NAME }}` interpolation. A token that is still unresolved when the hook fires blocks it (fail-closed), so a header is never sent half-rendered. | | `tls` | TLS mode: `"verify"` (default), `"no_verify"`, or `"off"`. | `{{ vars.NAME }}` is substituted when the run is created. Use variables only for non-sensitive metadata. Do not store tokens, API keys, or other credentials in variables or literal hook configuration. `{{ env.NAME }}` and `{{ secrets.NAME }}` are not available in hooks. ### Prompt A single-turn LLM call that evaluates the event context and returns an `ok`/`block` decision. The model responds with structured JSON. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[hooks]] event = "stage_start" type = "prompt" prompt = "Should this stage proceed given the current context? Check for any red flags." model = "haiku" blocking = true ``` | Field | Description | | - | - | | `prompt` | Instructions for the LLM evaluator. | | `model` | Model alias or ID. Defaults to `haiku`. | ### Agent A multi-turn agent session with full tool access (shell, file read/write, grep, glob). The agent can investigate the workspace before making a decision. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[hooks]] event = "run_complete" type = "agent" prompt = "Verify that all tests pass and the code compiles. Run the test suite." model = "sonnet" max_tool_rounds = 10 blocking = true ``` | Field | Description | | - | - | | `prompt` | Task instructions for the agent. | | `model` | Model alias or ID. Defaults to `haiku`. | | `max_tool_rounds` | Maximum tool call rounds before the agent gives up. Default: `50`. | ## Lifecycle events Each hook fires on a specific lifecycle event: | Event | When it fires | Blocking by default | | - | - | - | | `run_start` | Before the first node executes | Yes | | `run_complete` | After the run finishes successfully | No | | `run_failed` | After the run fails | No | | `stage_start` | Before a node handler begins | Yes | | `stage_complete` | After a node handler finishes successfully | No | | `stage_failed` | After a node handler fails | No | | `stage_retrying` | Before a failed node is retried | No | | `edge_selected` | After an edge is chosen for traversal | Yes | | `parallel_start` | Before parallel branches fan out | No | | `parallel_complete` | After parallel branches merge | No | | `sandbox_ready` | After the sandbox is initialized and ready | Yes | | `sandbox_cleanup` | Before the sandbox is torn down | No | | `checkpoint_saved` | After a checkpoint is written to disk | No | | `pre_tool_use` | Before an agent tool call executes | Yes | | `post_tool_use` | After an agent tool call succeeds | No | | `post_tool_use_failure` | After an agent tool call fails | No | ## Configuration Hooks are defined as `[[hooks]]` entries in any of these TOML config files: * **`.fabro/project.toml`** — project-level hooks, apply to all workflows in the project * **`workflow.toml`** — per-workflow hooks * **`~/.fabro/settings.toml`** — global defaults for all runs See [Merging hook configs](#merging-hook-configs) for how these layers combine. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[hooks]] name = "pre-check" event = "stage_start" command = "./scripts/pre-check.sh" matcher = "agent" blocking = true timeout_ms = 30000 sandbox = false ``` | Field | Description | | - | - | | `name` | Optional display name. Auto-generated from event and type if omitted. | | `event` | The lifecycle event to listen for (required). | | `command` | Shell command shorthand — implies `type = "command"`. | | `type` | Explicit hook type: `"command"`, `"http"`, `"prompt"`, or `"agent"`. | | `matcher` | Regex pattern to filter which stages trigger this hook. | | `blocking` | Whether the hook must complete before execution continues. Defaults vary by event. | | `timeout_ms` | Hook timeout in milliseconds. Default: `60000` (60s) for most types, `30000` (30s) for prompt hooks. | | `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). | ## Blocking vs. non-blocking Blocking hooks can affect workflow execution. Non-blocking hooks run for side effects only — their decisions are ignored. **Blocking by default:** `run_start`, `stage_start`, `edge_selected`, `pre_tool_use`, `sandbox_ready`. These events represent decision points where a hook can prevent or redirect execution. **Non-blocking by default:** All other events. Override with `blocking = true` if needed. When multiple blocking hooks match the same event, they run sequentially. If any hook returns a `block` decision, execution short-circuits — remaining hooks are skipped. ## Hook decisions Blocking hooks return a decision that controls what happens next: | Decision | Effect | | - | - | | `proceed` | Continue normal execution. | | `skip` | Skip the current stage (with optional reason). | | `block` | Stop execution with an error (with optional reason). | | `override` | Redirect to a different node by specifying `edge_to`. | When multiple blocking hooks run, decisions are merged with this precedence: **Block > Skip/Override > Proceed**. ### Command hook decisions Command hooks communicate decisions via exit code and stdout: | Exit code | Behavior | | - | - | | `0` | Proceed. If stdout contains valid JSON decision, use that instead. | | `2` | Block. If stdout contains valid JSON decision (e.g., `skip`), use that instead. | | Any other | Block with reason "hook exited with code N". | To return an explicit decision from a command hook, print JSON to stdout: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} #!/bin/bash if [ "$FABRO_NODE_ID" = "deploy" ]; then echo '{"decision": "skip", "reason": "deploy disabled in CI"}' exit 0 fi ``` ### Prompt and agent hook decisions Prompt and agent hooks return a JSON response: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} {"ok": true} ``` ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} {"ok": false, "reason": "Tests are failing, do not proceed"} ``` If the LLM fails to produce valid JSON, the hook **fails open** (proceeds). This fail-open behavior also applies to timeouts and LLM errors. ## Matchers The `matcher` field is a regex that filters when a hook fires. Omit `matcher` to match all occurrences of the event. Each event type matches against different context fields: | Event | What the matcher filters | Example matcher values | | - | - | - | | `stage_start`, `stage_complete`, `stage_failed`, `stage_retrying` | node ID and handler type | `implement`, `^agent$`, `test` | | `edge_selected` | edge source and edge target node IDs | `implement`, `deploy` | | `pre_tool_use`, `post_tool_use`, `post_tool_use_failure` | tool name and node ID | `write_file\|edit_file`, `^shell$` | | `checkpoint_saved` | node ID | `implement` | | `run_start`, `run_complete`, `run_failed`, `parallel_start`, `parallel_complete`, `sandbox_ready`, `sandbox_cleanup` | no matcher support | always fires on every occurrence | The matcher is a regex, so `write_file|edit_file` matches either tool and `^agent$` matches exactly the handler type `agent`. When an event has multiple matchable fields (e.g., tool events match against both `tool_name` and `node_id`), the hook fires if **any** field matches the regex. For tool events, the matcher is tested against the tool's internal name (e.g., `shell`, `write_file`, `edit_file`). See [Tools](/agents/tools) for the full list. To match all file-writing tools across providers, use `write_file|edit_file|apply_patch`. ### Examples This example auto-formats Rust code whenever the agent writes or edits a file: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[hooks]] name = "cargo-fmt" event = "post_tool_use" command = "cargo fmt" matcher = "write_file|edit_file|apply_patch" blocking = true ``` More examples: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} # Only fire for agent handler nodes [[hooks]] event = "stage_start" command = "./scripts/agent-guard.sh" matcher = "^agent$" # Fire for any node with "test" in its ID [[hooks]] event = "stage_complete" command = "./scripts/report-test.sh" matcher = "test" # Gate shell commands with a guardrail [[hooks]] event = "pre_tool_use" type = "prompt" prompt = "Is this shell command safe to run?" matcher = "^shell$" model = "haiku" ``` ## Execution environment ### Sandbox vs. host By default, command hooks run **inside the sandbox** (`sandbox = true`). This means they execute in the same environment as the agent's tools — same filesystem, same installed packages. Set `sandbox = false` to run on the host machine. This is useful for hooks that need access to host-only resources (CI systems, local credentials, notification tools). HTTP, prompt, and agent hooks ignore this setting — HTTP calls are always made from the host, and prompt/agent hooks use the LLM API directly. ### Environment variables Command hooks receive these environment variables: | Variable | Value | | - | - | | `FABRO_EVENT` | The event name (e.g., `stage_start`) | | `FABRO_RUN_ID` | The run's unique identifier | | `FABRO_WORKFLOW` | The workflow name | | `FABRO_NODE_ID` | The current node ID (when applicable) | | `FABRO_HOOK_CONTEXT` | Path to a JSON file containing the full event context (sandbox only) | ### Hook context The full event context is available as a JSON payload. For command hooks running in the sandbox, it is written to a temp file (path in `FABRO_HOOK_CONTEXT`). For command hooks running on the host (`sandbox = false`), it is piped to stdin. For HTTP hooks, it is the POST body. ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "event": "stage_start", "run_id": "run-abc123", "workflow_name": "ci-pipeline", "cwd": "/workspace", "node_id": "implement", "node_label": "Implement", "handler_type": "agent", "status": null, "edge_from": null, "edge_to": null, "edge_label": null, "failure_reason": null, "attempt": 1, "max_attempts": 3 } ``` Fields vary by event — `edge_from`/`edge_to`/`edge_label` are only set for `edge_selected`, `failure_reason` for failure events, `attempt`/`max_attempts` for `stage_retrying`, etc. Null fields are omitted from the serialized JSON. For tool-level events (`pre_tool_use`, `post_tool_use`, `post_tool_use_failure`), the context includes additional fields: | Field | Events | Description | | - | - | - | | `tool_name` | All tool events | Name of the tool being called (e.g., `shell`, `write_file`) | | `tool_input` | `pre_tool_use` | JSON object with the tool's input arguments | | `tool_call_id` | `post_tool_use`, `post_tool_use_failure` | Unique identifier for the tool call | | `tool_output` | `post_tool_use` | The tool's output string | | `error_message` | `post_tool_use_failure` | The error message from the failed tool call | ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "event": "pre_tool_use", "run_id": "run-abc123", "workflow_name": "ci-pipeline", "node_id": "implement", "tool_name": "shell", "tool_input": {"command": "rm -rf /tmp/build"} } ``` ## Timeouts Each hook type has a default timeout: | Hook type | Default timeout | | - | - | | Command | 60 seconds | | HTTP | 60 seconds | | Prompt | 30 seconds | | Agent | 60 seconds | Override with `timeout_ms` on any hook definition. Prompt and agent hooks **fail open** on timeout — execution proceeds as if the hook returned `ok: true`. ## Fail-open behavior Hooks are designed to be safe by default. Several failure modes result in the hook proceeding rather than blocking: * **Prompt/agent LLM call fails** — proceeds * **Prompt/agent hook times out** — proceeds * **Prompt hook returns unparseable JSON** — proceeds * **HTTP hook returns non-2xx** — proceeds * **HTTP hook connection fails** — proceeds Command hooks do **not** fail open. A non-zero exit code (other than 0 or 2) produces a `block` decision. ## Merging hook configs Hooks from multiple config files are merged in this order (later layers win on name collisions): 1. **`~/.fabro/settings.toml`** — global defaults 2. **`.fabro/project.toml`** — project-level overrides 3. **`workflow.toml`** — per-workflow overrides This lets you define global hooks at the server level, project-wide hooks in `.fabro/project.toml`, and override or extend them per workflow. ## Full example ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} # Validate environment before the run starts [[hooks]] name = "env-check" event = "run_start" command = "./scripts/check-env.sh" blocking = true sandbox = false # Notify Slack when a stage completes [[hooks]] name = "slack-notify" event = "stage_complete" type = "http" url = "https://hooks.slack.com/workflows/T00/B00/abc123" tls = "verify" blocking = false # LLM guardrail before agent stages [[hooks]] name = "safety-check" event = "stage_start" type = "prompt" prompt = "Review the event context. Is there any reason this stage should not proceed?" model = "haiku" matcher = "^agent$" blocking = true timeout_ms = 15000 # Post-run verification agent [[hooks]] name = "verify" event = "run_complete" type = "agent" prompt = "Run the test suite and verify all tests pass. Report any failures." model = "sonnet" max_tool_rounds = 20 blocking = true timeout_ms = 120000 # Auto-format Rust files after writes [[hooks]] name = "cargo-fmt" event = "post_tool_use" command = "cargo fmt" matcher = "write_file|edit_file|apply_patch" blocking = true ``` # MCP Source: https://docs.fabro.sh/agents/mcp Connect MCP tools to agents and expose Fabro runs to MCP clients MCP ([Model Context Protocol](https://modelcontextprotocol.io/)) lets you connect external tool servers to Fabro agents. An MCP server exposes tools over a standardized protocol — databases, APIs, file systems, custom services — and Fabro discovers and registers them automatically. Agents call MCP tools the same way they call built-in tools. Fabro can also run as an MCP server. MCP clients can register reusable workflow versions and use Fabro's run-management tools to create, inspect, control, wait for, and read events from workflow runs through the authenticated `fabro` CLI. Workflow agents can opt in to that same run-management tool catalog with `[run.agent] fabro_tools = true`. This is not the same as configuring external MCP servers for the agent. When a workflow agent calls `fabro_run_create`, created runs are always [child runs](/execution/child-runs) of the current run; an explicit `parent_id` must match the current run ID. ## Fabro as an MCP server Use `fabro mcp init` to configure an MCP client to launch Fabro: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro mcp init claude ``` Supported client targets are `claude`, `cursor`, and `windsurf`. The generated configuration launches `fabro mcp start` over stdio and reuses the CLI's normal server selection, OAuth refresh, dev-token handling, proxy behavior, and local storage. You can also print the MCP configuration JSON or start the server directly: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro mcp config fabro mcp start ``` Pass `--server` when the MCP client should connect to a specific Fabro server, or `--storage-dir` when it should use a non-default CLI storage directory. Both commands register the entry under the `mcpServers` key `fabro` by default. Pass `--name` to choose a different key. Each named entry launches its own single-target `fabro mcp start` process, so you can register more than one Fabro server in the same MCP client: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro mcp init claude --name fabro-production --server https://fabro.example.com fabro mcp init claude --name fabro-testing --server https://fabro-testing.example.com ``` `fabro mcp init` keeps entries with other names and replaces only the entry that matches `--name`. | Tool | Purpose | | - | - | | `fabro_workflow_version_create` | Register supplied workflow contents and local dependencies as an immutable version ID, without creating a run. | | `fabro_run_create` | Create one or more workflow runs, optionally under a parent run, starting them by default. | | `fabro_run_search` | Search runs by ID, parent, workflow, labels, status, archive state, and creation time. | | `fabro_run_get` | Read-only inspection of a run: returns its summary, projection, and pending questions without mutating state. | | `fabro_run_interact` | Control a run: start, approve, deny, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect or answer questions. | | `fabro_run_gather` | Wait for runs to reach terminal states, returning current state on timeout. | | `fabro_run_pair` | Inspect, start, message, end, or read transcript for a live run pairing session. | | `fabro_run_events` | List, inspect, or search stored events for a run. | ### Register workflow contents from a sandbox Use shell and read tools in your sandbox to acquire the workflow and all its local config, graph, prompt, script, import, and child-workflow files. For example, clone a repository with your sandbox's shell tool, then read `workflow.fabro` and its referenced `prompt.md`. Submit the actual contents: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "entrypoint": "workflow.fabro", "files": { "workflow.fabro": "digraph W { start [shape=Mdiamond] work [prompt=\"@prompt.md\"] exit [shape=Msquare] start -> work -> exit }", "prompt.md": "Review the implementation." } } ``` Call `fabro_workflow_version_create` with this object and keep the returned `workflow_version_id`. You can reuse it in a `RunIntent` submitted through the [Create Run API](/api-reference/runs/create-run). Registration packages and uploads child workflows before their parents; callers do not calculate dependency IDs. `entrypoint` is an exact supplied key, including when it has no extension. File values are text, never host paths or URLs to fetch. Missing references and paths that escape the supplied tree fail. The source tree is limited to 512 files, 512 KiB per file, and 2 MiB of text; each resulting serialized version must also fit the existing 2 MiB API limit. Case-insensitive file and ancestor collisions are rejected before staging. Graph nesting through child workflows and imports is limited to 64 levels, including the entrypoint. A supplied sibling `workflow.toml` must be valid even when a graph is the entrypoint; a valid config that selects another graph is omitted. Collection follows declared file references; command `script` values remain literal text, and paths embedded in shell commands are not inspected or acquired. Registration resolves no runtime secrets, selects no environment, and starts no execution. It requires a user credential or a worker token with `agent:run_tools`; ordinary worker tokens and same-run Ask Fabro sessions cannot register versions. If an upload fails, retry the same contents: previously registered immutable versions remain reusable. Use the returned ID with `fabro_run_create`. ### Create runs Call `fabro_run_create` with a registered workflow version ID and an independent workspace target. You can reuse the same ID for multiple runs without uploading again: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "runs": [{ "workflow_version_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "target": { "kind": "git", "repo": "acme/widgets", "branch": "main" }, "environment_id": "production", "goal": "Review the checkout implementation.", "args": { "inputs": { "component": "checkout" }, "labels": { "source": "mcp" }, "auto_approve": false }, "start": false }] } ``` Standalone MCP calls require an explicit `target`: Git coordinates, `kind: none` for an empty workspace, or a server-local folder for a compatible Local environment. A folder path refers to the server's filesystem, not the MCP client's filesystem. Workflow content does not determine the target. Native workflow-agent calls may omit `target` to inherit the parent's execution target; see [Child Runs](/execution/child-runs). Supplying `parent_id` in standalone MCP does not enable that inheritance. `args` uses the same fields as RunIntent: `inputs`, `labels`, `model`, `provider`, `dry_run`, `auto_approve`, and `preserve_sandbox`. Omitted boolean overrides stay omitted; explicit `false` is preserved. `environment_id` selects a server environment; omission uses the server default. `title`, literal `goal`, and an exact `parent_id` are optional. Caller/project/machine run settings are not read by the tool. Workflow-owned settings remain in the registered `workflow.toml`. Run creation accepts registered IDs only. Replace workflow strings and inline sources with a preceding `fabro_workflow_version_create` call. Move flat run options into `args`, use `environment_id` instead of `environment`, and send goal text instead of `goal_file`. Obtain local or remote files using the caller's own shell/read tools. Neither Fabro tool fetches remote sources or reads a caller path. Creation persists a submitted run. A separate start request follows by default; set `start: false` to start later. Batches contain 1–50 items and stop at the first failure. If creation succeeded before a start, summary lookup, or later item failed, the error includes the already-created run IDs. Inspect those runs before retrying to avoid creating duplicates. Run summaries returned by the MCP server include parent metadata. Use `parent_id` on `fabro_run_create` to create a child run, `parent_id` on `fabro_run_search` to list direct children, and the `link_parent` or `unlink_parent` actions on `fabro_run_interact` to change an existing run's parent. See [Child Runs](/execution/child-runs) for the orchestration model. Pending runs can be approved or denied through `fabro_run_interact`: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "run_id": "nightly", "action": "approve" } ``` ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "run_id": "nightly", "action": "deny", "reason": "Not approved for execution" } ``` Use `fabro_run_pair` when an MCP client needs to pair with an active API-mode agent stage. The tool can inspect current pair status, start a pair session for a selected stage, send messages, end the session, and read transcript entries. ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "action": "start", "run_id": "01K...", "stage_id": "implement@1" } ``` ## Fabro agents as MCP clients When an agent session starts with MCP servers configured, Fabro: 1. **Connects** to each server using the configured transport (stdio, HTTP, or sandbox) 2. **Performs the MCP handshake** — exchanging protocol versions and capabilities 3. **Discovers tools** — calls `tools/list` to get every tool the server exposes 4. **Registers tools** — each MCP tool is added to the agent's tool registry with a qualified name Once registered, MCP tools are indistinguishable from built-in tools to the LLM. The agent sees them in its tool list and can call them during its session. ## Tool naming MCP tools are registered with a qualified name that combines the server name and original tool name: ``` mcp__{server}__{tool} ``` For example, a server named `filesystem` exposing a `read_file` tool becomes `mcp__filesystem__read_file`. Special characters in server or tool names (hyphens, dots, etc.) are replaced with underscores. ## Agent MCP configuration MCP servers available to Fabro agents can be configured in two places: * **User configuration** — `~/.fabro/settings.toml` can define shared workflow MCP servers under `[run.agent.mcps.]`, or `fabro exec`-only servers under `[cli.exec.agent.mcps.]`. See [User Configuration](/reference/user-configuration). * **Run config TOML** — workflow config can define run-specific MCP servers under `[run.agent.mcps.]`. See [Run Configuration](/execution/run-configuration#runagentmcps). Each server entry specifies a transport type and optional timeouts. The server name is the TOML table key and is used in qualified tool names. ### Server-managed catalog Fabro servers can also manage a shared MCP catalog from **Settings → MCP servers** in the web UI or through the MCP servers REST API. Workflows reference a catalog definition by id instead of repeating its transport configuration: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent.mcps.sentry] id = "sentry" ``` Server-managed definitions are stored in the server's shared SQLite database. Read APIs return configured env/header names but never their values. When upgrading an installation that stored definitions as `mcps/*.toml` next to the active `settings.toml`, Fabro validates and imports the directory during startup. Existing SQLite rows win on id conflicts. After a successful transaction, Fabro renames the source directory to a timestamped backup such as `mcps.imported-20260711T120000000000Z.bak`. Transport env/header values preserve their existing plaintext-at-rest behavior in SQLite. Prefer `{{ secrets.NAME }}` interpolation over literal credentials where possible. Set `enabled = false` to keep an inline server or catalog reference in configuration without connecting to it: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent.mcps.sentry] id = "sentry" enabled = false ``` ## Runtime interpolation Inline transport fields can interpolate values at the run boundary: | Syntax | Resolution time | | - | - | | `{{ vars.NAME }}` | When the server creates the run, using that run's variable snapshot | | `{{ secrets.NAME }}` | When the worker launches the MCP transport, using a token secret from the server vault | Interpolation applies to stdio and sandbox commands and env values, plus HTTP URLs and headers. Variable tokens are replaced in the created run configuration. Secret expressions remain in persisted configuration, while resolved secret values do not. A missing or non-token secret fails MCP startup instead of passing an unresolved token to the transport. `{{ env.* }}` is unsupported and also fails before launch. Standalone `fabro exec` has no server vault, so a `{{ secrets.* }}` reference fails with an explicit error in standalone execution. ## Transports ### Stdio The most common transport. Fabro spawns a child process on the host and communicates over stdin/stdout: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent.mcps.filesystem] type = "stdio" command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"] startup_timeout = "15s" tool_timeout = "90s" [run.agent.mcps.filesystem.env] NODE_ENV = "production" ``` | Field | Description | Default | | - | - | - | | `enabled` | Whether to connect to this server. | `true` | | `type` | Must be `"stdio"`. | — | | `command` | Array: the executable followed by its arguments. | — | | `script` | Shell script alternative to `command`. Runs on the host through `sh -c`. | — | | `env` | Additional environment variables for the child process. | `{}` | | `startup_timeout` | Max duration to wait for the MCP handshake. | `"10s"` | | `tool_timeout` | Max duration for a single tool call. | `"60s"` | ### HTTP For remote MCP servers accessible over Streamable HTTP: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent.mcps.sentry] type = "http" url = "https://mcp.sentry.dev/mcp" [run.agent.mcps.sentry.headers] Authorization = "Bearer sk-xxx" ``` | Field | Description | Default | | - | - | - | | `enabled` | Whether to connect to this server. | `true` | | `type` | Must be `"http"`. | — | | `protocol` | HTTP MCP protocol: `"streamable_http"` or legacy `"sse"`. | `"streamable_http"` | | `url` | The MCP server endpoint URL. | — | | `headers` | Optional HTTP headers (e.g., for authentication). | `{}` | | `startup_timeout` | Max duration to wait for the MCP handshake. | `"10s"` | | `tool_timeout` | Max duration for a single tool call. | `"60s"` | ### Sandbox Runs the MCP server inside the workflow's sandbox (e.g., a [Daytona](/integrations/daytona) cloud VM). Fabro starts the server as a background process, waits for it to listen on the specified port, obtains an authenticated preview URL, and connects via HTTP. This is the right transport for MCP servers that need access to the sandbox environment — for example, [Playwright](https://github.com/microsoft/playwright-mcp) for browser automation. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent.mcps.playwright] type = "sandbox" protocol = "sse" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"] port = 3100 startup_timeout = "60s" tool_timeout = "2m" ``` | Field | Description | Default | | - | - | - | | `enabled` | Whether to connect to this server. | `true` | | `type` | Must be `"sandbox"`. | — | | `protocol` | HTTP MCP protocol exposed by the sandbox server: `"streamable_http"` or legacy `"sse"`. | `"streamable_http"` | | `command` | Array: the command to run inside the sandbox. Must include a flag that makes the server listen on `port`. | — | | `script` | Shell script alternative to `command`. Evaluated inside the sandbox by non-login Bash (`bash -c`), like every other sandbox command. | — | | `port` | The port the MCP server listens on inside the sandbox. | — | | `env` | Additional environment variables for the server process. | `{}` | | `startup_timeout` | Max duration to wait for the server to start listening and complete the MCP handshake. | `"10s"` | | `tool_timeout` | Max duration for a single tool call. | `"60s"` | The sandbox transport requires a remote sandbox provider (Daytona) that supports preview URLs. During session initialization, Fabro: 1. Launches the server inside the sandbox with `setsid "$BASH" -c` to fully detach the process while reusing the provider-selected Bash 2. Polls until the server is listening on the configured port (up to 30 seconds) 3. Obtains an authenticated preview URL from the sandbox provider 4. Connects to the server over HTTP using the preview URL ## Lifecycle ### Startup Each MCP server is started sequentially during session initialization. The startup sequence for each server is: 1. **Spawn / connect** — For stdio, spawn the child process. For HTTP, create the HTTP client. For sandbox, start the server inside the sandbox and connect via preview URL. 2. **Handshake** — Perform the MCP protocol handshake within the `startup_timeout` window. 3. **Tool discovery** — Call `tools/list` to enumerate available tools. 4. **Registration** — Add each tool to the agent's registry with its qualified name. If a server fails to start (process crash, timeout, handshake error), it is logged and skipped. Other servers continue starting normally. The agent session proceeds with whatever tools were successfully registered. ### Tool execution When the LLM calls an MCP tool: 1. Fabro looks up the qualified name in the connection manager 2. The call is routed to the correct server using the original (unqualified) tool name 3. The server executes the tool and returns a result 4. The result is converted to text and returned to the LLM as a tool result Tool calls are subject to the `tool_timeout` configured on the server. If a call exceeds the timeout, it fails with a timeout error. ### Content handling MCP tool results can contain multiple content blocks. Fabro converts them to text: | Content type | Conversion | | - | - | | Text | Used as-is | | Image | Replaced with `[image content]` | | Audio | Replaced with `[audio content]` | | Resource | Replaced with `[resource content]` | If the server marks the result as an error (`is_error: true`), the tool result is returned to the LLM as an error. ## Example: Playwright browser automation A workflow that uses Playwright MCP to automate a browser inside a Daytona sandbox: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [workflow] graph = "workflow.fabro" [run] goal = "Test the login page" [run.environment] id = "cloud" [environments.cloud] provider = "daytona" [run.artifacts] include = ["screenshots/**"] [run.agent.mcps.playwright] type = "sandbox" protocol = "sse" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"] port = 3100 startup_timeout = "60s" tool_timeout = "2m" ``` After startup, the agent sees 22 Playwright tools including: * `mcp__playwright__browser_navigate` * `mcp__playwright__browser_click` * `mcp__playwright__browser_snapshot` * `mcp__playwright__browser_take_screenshot` * `mcp__playwright__browser_type` * `mcp__playwright__browser_fill_form` The agent uses `browser_snapshot` (accessibility tree) for structured page understanding and `browser_take_screenshot` to save visual captures. Screenshots saved to `screenshots/` can be collected as [artifacts](/execution/run-configuration#runartifacts) by including `screenshots/**`. When using Playwright MCP with the sandbox transport, call the `browser_install` tool first to ensure the Playwright browser binaries are available inside the sandbox. MCP servers that fail to start do not block the agent session. The agent proceeds with its built-in tools plus any MCP tools from servers that started successfully. ## Protocol details For agent-side MCP connections, Fabro implements the MCP client side using the `rmcp` SDK (protocol version `2025-03-26`). The client identifies itself as `fabro-mcp` and supports: * Tool listing and invocation * Server logging notifications (routed to Fabro's tracing system) * Progress notifications * Resource update notifications * Cancellation notifications # Outputs & Artifacts Source: https://docs.fabro.sh/agents/outputs How Fabro captures agent responses, tracks file changes, and collects test assets When an agent or prompt node finishes, Fabro captures its response text and produces an **outcome** that feeds into context, transition logic, and downstream nodes. Fabro also tracks every file change per stage, offloads large outputs into content-addressed blob storage, and can collect configured artifacts such as screenshots and reports. ## Response capture After an agent or prompt node completes, Fabro captures the full response text and persists it to `stages/{rank:03}-{node_id}@{visit}/response.md` in metadata snapshots and `fabro dump` output. It also writes the final outcome (status, context updates, routing directives) to `stages/{rank:03}-{node_id}@{visit}/status.json`. ## Context updates Every agent and prompt node sets three context keys from its response: | Key | Value | | - | - | | `last_stage` | The node ID of the stage that just completed | | `last_response` | The response text, truncated to the first 200 characters | | `response.{node_id}` | The full response text | These keys are available to downstream nodes via the [context](/execution/context). The `last_response` key provides a quick preview, while `response.{node_id}` preserves the complete output for nodes that need it. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} plan -> implement -> review -> exit // In the review node's prompt, you can reference prior outputs: // The context key response.plan contains the full plan text // The context key response.implement contains the full implementation ``` ## Routing directives Agent and prompt nodes can influence which edge is taken after they complete by including a JSON object with routing fields in their response. Fabro scans the LLM output for the **last** JSON object containing any recognized routing field: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "outcome": "failed", "failure_reason": "tests failed", "preferred_next_label": "fix", "suggested_next_ids": ["implement", "review"], "context_updates": { "tests_passed": true, "coverage": 85 } } ``` | Field | Effect | | - | - | | `outcome` | Sets the node outcome: `succeeded`, `failed`, `partially_succeeded`, or `skipped` | | `failure_reason` | When `outcome` is `failed`, provides a structured failure message | | `preferred_next_label` | Matched against edge labels to select the next node | | `suggested_next_ids` | Ordered list of preferred target node IDs | | `context_updates` | Key-value pairs merged into the run context | ### How extraction works Fabro finds all balanced `{...}` JSON objects in the response text, parses each one, and uses the **last** object that contains at least one recognized field (`preferred_next_label`, `outcome`, `failure_reason`, `suggested_next_ids`, `context_updates`). The JSON can appear anywhere in the response -- inside a fenced code block, inline with natural language, or at the end. ```markdown theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} I've reviewed the code and found several issues that need fixing. The test coverage is below the threshold. {"preferred_next_label": "fix", "context_updates": {"coverage": 72}} ``` JSON objects without recognized fields are ignored. ### Validated routing output Set `output_schema="routing"` on an agent, prompt, or command node to require Fabro's built-in routing directive schema: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review [ shape=tab, prompt="Review the implementation and return routing JSON.", output_schema="routing", output_retries=2 ] ``` With `output_schema="routing"`, the routing JSON must be an object with at least one recognized routing field (`preferred_next_label`, `outcome`, `failure_reason`, `suggested_next_ids`, or `context_updates`) and those fields must have the expected types. Malformed routing JSON fails validation instead of being silently ignored. Validated routing uses the same reverse scan as normal routing extraction: Fabro validates the last parsable JSON object that contains a recognized routing field. If a routing object is present but malformed or has invalid field types, Fabro repairs that response instead of falling through to a file fallback. Fabro repairs invalid structured output inside the same LLM context before failing an agent or prompt node. For prompt nodes, Fabro appends the invalid assistant response and a corrective user message to the same message list. For agent nodes using the API backend, Fabro sends the corrective message to the same live agent session. `output_retries` controls these repair turns and defaults to `2`; `output_retries=0` validates once and fails without a repair turn. Negative values are treated as `0`. These repair turns are separate from workflow `max_retries` and do not consume node retry attempts. Command nodes instead validate only after an exit-code-`0` script, applying the same reverse scan to merged stdout and stderr. Print the intended JSON object last. Invalid output fails deterministically without a repair turn, command retry, or `status.json` fallback; `output_retries`, `retry_policy`, and `max_retries` do not retry the validation failure. Nonzero exits keep their normal command failure behavior without schema validation. ### Routing fallback sources Agent nodes can provide routing directives through fallback files. Fabro checks sources in this order: | Order | Source | | - | - | | 1 | The final response text | | 2 | `status.json` in the sandbox working directory | | 3 | The last file touched by the agent | This fallback chain applies to normal routing extraction and to `output_schema="routing"`. For validated routing, Fabro only advances to the next source when the current source has no JSON object or no object with recognized routing fields. If the current source contains malformed routing JSON or valid JSON with wrong routing field types, validation fails and Fabro starts the repair loop instead. The last-file fallback only reads `.json` and `.md` files (case-insensitive), and the routing JSON must be the final JSON object in the file, with only whitespace after it. Fabro ignores other file types and routing JSON followed by any other content. These restrictions do not apply to the dedicated `status.json` fallback. Prompt nodes do not use file fallbacks; they validate or extract routing directives from the response text only. Command nodes likewise have no file fallback and validate only their merged stdout and stderr. If no source provides routing directives, the transition falls through to condition matching, unconditional edges, or weight-based tiebreaking as described in [Transitions](/workflows/transitions). ### Instructing the agent Fabro does not automatically instruct agents to emit routing JSON. You must include instructions in your prompt: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review [ label="Review", shape=tab, prompt="Review the implementation. If changes are needed, \ respond with: {\"preferred_next_label\": \"fix\"}. \ If everything looks good, respond with: \ {\"preferred_next_label\": \"approve\"}." ] review -> fix [label="Fix"] review -> approve [label="Approve"] ``` ## Custom structured outputs Agent, prompt, and command nodes can also validate their final JSON object against a JSON Schema file: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} audit [ shape=tab, prompt="Audit the change and return JSON that matches the schema.", output_schema="@schemas/audit-result.schema.json", output_retries=2 ] ``` `output_schema="@path/to/schema.json"` uses the same workflow file-reference rules as prompt files: the schema is loaded relative to the workflow file and inlined before execution. The final JSON object in the LLM response, or in a successful command's merged stdout and stderr, is validated with `jsonschema`. For API-backed agent nodes, Fabro adds the resolved output contract to the task instructions. The contract applies only to the final response. It does not restrict intermediate tool calls or progress messages. Custom schema validation only reads response or command output text. It does not fall back to `status.json` or the last file touched by the agent. When custom schema validation succeeds, Fabro stores the parsed JSON value in context at: | Key | Value | | - | - | | `output.{node_id}` | The parsed JSON object that matched the custom schema | For example, node `audit` writes its parsed custom output to `output.audit`. Fabro still stores raw LLM response text at `response.audit`; command output remains available through `command.output`. If custom schema validation fails for an agent or prompt node, Fabro sends concise validation feedback to the same prompt conversation or agent session and asks for corrected JSON. After `output_retries` repair turns are exhausted, the node fails terminally with `output schema validation failed after N repair attempt(s)`. A command node instead fails deterministically after its first validation, without a repair turn or command retry. Structured output validation applies to agent, prompt, and command nodes. `backend="acp"` does not support `output_schema` in this release. Custom schemas update `output.{node_id}`; routing schemas update routing fields and merge `context_updates` as flat context keys that edge conditions can read. Edge conditions cannot traverse the custom `output.{node_id}` object. ## Output logging Fabro writes several files per stage to `stages/{rank:03}-{node_id}@{visit}/` in metadata snapshots and `fabro dump` output: | File | Contents | | - | - | | `prompt.md` | The assembled prompt (preamble + expanded prompt text) | | `response.md` | The full LLM response text | | `status.json` | The outcome: status, context updates, routing directives, usage stats | These files are written for every agent and prompt node execution, including retries. Use them for debugging unexpected agent behavior or verifying that routing directives were extracted correctly. ## File tracking Fabro records which files each stage touches. When an agent calls `write_file` or `edit_file`, Fabro tracks the file path. When using a CLI-based agent backend, Fabro snapshots the Git working tree before and after execution and diffs the results. The tracked paths are stored as `files_touched` on the stage outcome: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "status": "succeeded", "files_touched": ["src/main.rs", "tests/api_test.rs", "README.md"] } ``` ### Where files\_touched appears | Location | How it's used | | - | - | | `StageCompleted` event | Emitted with `files_touched` in the event stream and surfaced by `fabro events` / exported event streams | | Preambles | Listed under each completed stage so downstream agents know what changed | | `status.json` | Written to the stage's logs directory after each node completes | ### How tracking works For the **API backend**, Fabro subscribes to agent session events. When a `ToolCallStarted` event fires for `write_file` or `edit_file`, Fabro records the `file_path` argument as pending. When the corresponding `ToolCallCompleted` arrives without an error, the path is confirmed as touched. Failed tool calls are discarded. For the **CLI** and **ACP** backends, Fabro takes a different approach: it runs `git diff --name-only` and `git ls-files --others --exclude-standard` before and after the external agent session, then computes the difference. Any files that appear in the "after" snapshot but not "before" are recorded as touched. ## Artifact offloading When a stage produces a large context value -- an LLM response or any context update -- Fabro automatically offloads it into a global content-addressed blob store instead of leaving the full value inline in durable context. Command output is streamed to a stage log file while the command runs, then finalized into a durable blob ref after completion. ### How offloading works After each node completes, Fabro checks every context update. If the serialized JSON of a value exceeds **100KB**, it is stored once by SHA-256 hash and replaced with a durable blob ref. Command output is always stored this way after completion, even when it is small or empty: ``` response.plan --> blob://sha256/2cf24dba5fb0... command.output --> blob://sha256/a4f3c1d9c2e1... ``` Non-command values under 100KB remain inline. Checkpoints, checkpoint-completed events, forks, and resumes persist these `blob://` refs, not host-specific file paths. ### Preamble rendering When Fabro builds a [preamble](/execution/context#preamble-construction) for a downstream stage, it first materializes any blob refs into execution-local files and then renders a reference instead of inlining the full content: ```markdown theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} ## Completed stages - **plan**: success - Model: claude-sonnet-4-5, 12.4k tokens in / 3.2k out - Files: src/main.rs, tests/api_test.rs - Response: See: /path/to/runtime/blobs/.json - **test**: success - Script: `cargo test 2>&1 || true` - Stdout: See: /path/to/runtime/blobs/.json ``` This keeps preambles concise while still giving agents a path to read the full output if needed. ## Durable storage Checkpoint events and projections reference large offloaded context values with `blob://sha256/...` references. The payloads live in the durable content-addressed store (CAS). Captured stage artifacts such as screenshots, videos, reports, and traces still use the artifact store and export paths described below. ## Remote sandbox syncing For remote sandboxes (Docker, Daytona), execution-time file access happens inside the sandbox filesystem. * Blob refs are materialized into the sandbox runtime directory, `{runtime_directory}/blobs/{blob_hash}.json`. This directory lives outside the repository checkout, so materialized blobs never show up in `git status` or in checkpoint commits. * Explicit non-blob `file://` refs keep the existing copy-on-demand behavior and are copied into `{working_directory}/.fabro/artifacts/{filename}` when needed In both cases, downstream handlers and agents continue to consume ordinary `file://` pointers during execution. For local sandboxes, syncing is a no-op since the agent can already access the host filesystem directly. ## Automatic asset capture When `[run.artifacts]` contains include patterns, Fabro scans the sandbox after each stage and captures matching files. Artifact collection is opt-in; no scan runs when the include list is empty. ### How asset capture works 1. Fabro compiles and validates the configured workspace-relative globs. 2. The sandbox provider enumerates regular files and their sizes without recursing through symlinks below the workspace root. 3. Fabro applies the globs to normalized relative paths, enforces its collection limits, and copies selected files to the local directory or S3 bucket configured by `server.artifacts`. Originals remain in the sandbox. Each scan represents the post-stage workspace state; Fabro does not depend on filesystem modification timestamps. The same path and content hash is recorded only once per run, even when it still matches after later stages. Individual files over 10 MiB (10,485,760 bytes) are skipped, and each collection is limited to 100 files and 50 MiB total. A file exactly 10 MiB can be captured. New artifact bytes use the configured artifact backend, with stage and filename metadata in SQLite. Existing captures stored as SQLite blobs remain readable without moving their bytes. Generic offloaded context values and checkpoint patches continue to use SQLite blobs. ### What gets captured Configure the paths your workflow produces: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.artifacts] include = [ "test-results/**", "playwright-report/**", "**/*.trace.zip", ".ai/reports/*.md", ".ai/plans/????-??-??-*.md", ] ``` Patterns are rooted at the sandbox working directory. `*` and `?` stay within one path segment, while `**` crosses directories. See [`[run.artifacts]`](/execution/run-configuration#runartifacts) for the complete matching contract. Fabro prunes dependency, cache, and build directories including `.git`, `node_modules`, `target`, `.venv`, `.cache`, and `dist`. ### Browse and download captures The Artifacts page groups captures by file path. Expand a file to see and download earlier versions. **Download all** creates a ZIP archive with the latest captured version of each path. Fabro uses stage order, retry number, and stage ID to choose the latest version, and it excludes captures from the graph's start and exit nodes. ## Observability Outputs and artifacts appear in several observability surfaces: | Surface | What's reported | | - | - | | `StageCompleted` event | `files_touched` list for the stage | | `WorkflowRunCompleted` event | `artifact_count` -- total number of offloaded artifacts across the run | | Web UI | Run stage output, artifact version history, individual downloads, and a ZIP of the latest files | | [Preambles](/execution/context#preamble-construction) | File list and artifact pointer references for completed stages | | Stage logs | `status.json` in each stage's run directory contains the full outcome including `files_touched` | # Permissions Source: https://docs.fabro.sh/agents/permissions Controlling which tools agents can use Permissions control which tools an agent can use without human approval. Fabro uses a three-tier permission model that balances agent autonomy with safety — read-only agents can explore the codebase freely, read-write agents can also edit files, and full-access agents can run arbitrary shell commands. ## Permission levels | Level | Auto-approved tools | Prompted tools | | - | - | - | | `read-only` | `read_file`, `read_many_files`, `grep`, `glob`, `list_dir`, sub-agent tools | Everything else requires approval | | `read-write` | All read tools + `write_file`, `edit_file`, `apply_patch`, sub-agent tools | `shell`, `web_search`, `web_fetch`, MCP tools | | `full` | All tools including `shell`, `web_search`, `web_fetch`, MCP tools | None | The default permission level is `read-write`. ## Tool categories Every tool is classified into one of four categories that determine which permission level auto-approves it: | Category | Tools | Minimum level | | - | - | - | | read | `read_file`, `read_many_files`, `grep`, `glob`, `list_dir` | `read-only` | | write | `write_file`, `edit_file`, `apply_patch` | `read-write` | | subagent | `spawn_agent`, `send_input`, `wait`, `close_agent` | `read-only` | | shell | `shell`, `web_search`, `web_fetch`, MCP tools, unknown tools | `full` | Unknown tools (including MCP tools) default to the `shell` category, requiring `full` permission for auto-approval. ## Setting permissions ### CLI flag ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro exec --permissions read-only fabro exec --permissions read-write fabro exec --permissions full ``` ### Non-interactive mode When running non-interactively (no TTY or `--auto-approve` flag), tools outside the permission level are denied outright: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} # In CI: only allow read and write tools, deny shell fabro exec --permissions read-write --auto-approve ``` ### Interactive escalation In interactive mode, when an agent calls a tool outside its current permission level, Fabro prompts the user: ``` Agent wants to run: shell(command="cargo test") Allow? [y]es / [a]lways / [n]o: ``` * **yes** -- allow this one call * **always** -- upgrade the permission level for the rest of the session * **no** -- deny the call (the agent receives an error and can try a different approach) Choosing "always" on a write tool upgrades to `read-write`. Choosing "always" on a shell tool upgrades to `full`. ## How permissions interact with tools ### Read tools are always available Regardless of permission level, agents can always read files, search with grep, and find files with glob. This means even a `read-only` agent can fully explore a codebase. ### Write tools require read-write or higher The `write_file`, `edit_file`, and `apply_patch` tools are auto-approved at `read-write` and above. At `read-only`, these tools are either prompted (interactive) or denied (non-interactive). ### Shell requires full The `shell` tool and web tools (`web_search`, `web_fetch`) are only auto-approved at the `full` level. This is the most permissive setting since shell commands can do anything the sandbox allows. ### Sub-agent tools are always allowed Sub-agent management tools (`spawn_agent`, `send_input`, `wait`, `close_agent`) are auto-approved at all permission levels. Sub-agents inherit the parent's permission level, so a `read-only` parent spawns `read-only` children. ## Permission flow The following sequence shows how Fabro decides whether to execute a tool call: Permission flow: tool call is checked against current permission level; if auto-approved it executes, otherwise in interactive mode the user is prompted, in non-interactive mode the tool is denied ## Workflow-level permissions When agents run inside Fabro workflows, the workflow engine manages tool approval. Workflow agents typically run with full auto-approval since the workflow itself defines the trust boundary -- the sandbox provider (local, Docker, or Daytona) controls what the agent can actually access. For CLI-backed workflow agents, each provider handles auto-approval differently: * **Anthropic** (Claude Code) -- uses `--dangerously-skip-permissions` to bypass interactive prompts * **OpenAI** (Codex) -- uses `--full-auto` mode * **Gemini** -- uses `--yolo` flag ## Further reading Full reference for built-in tools and their parameters. Sandbox providers that control execution isolation. MCP tools follow the same permission model as built-in tools. Sub-agents inherit their parent's permission level. # Prompts Source: https://docs.fabro.sh/agents/prompts How Fabro constructs and delivers prompts to agents When a workflow reaches an agent or prompt node, Fabro assembles the full prompt from multiple sources: the node's `prompt` attribute, a fidelity-based preamble summarizing prior stages, a system prompt with tool guidance and environment context, project docs, and user instructions. Understanding this assembly helps you write effective prompts and control what context each node receives. ## The prompt attribute The `prompt` attribute on a node defines the task instructions for that stage. If no `prompt` is set, Fabro falls back to the node's `label`. ### Inline prompts Short prompts can be written directly in the Graphviz file: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} plan [label="Plan", prompt="Analyze the codebase and write a step-by-step plan."] ``` Use `\` for multi-line strings: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review [ label="Review", shape=tab, prompt="Review the implementation for correctness and \ code quality. If changes are needed, respond with: \ {\"preferred_next_label\": \"fix\"}. If everything \ looks good, respond with: \ {\"preferred_next_label\": \"approve\"}." ] ``` ### External file references For longer prompts, use the `@` prefix to load from a Markdown file relative to the Graphviz file: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} plan [label="Plan Implementation", prompt="@prompts/implement/plan.md"] implement [label="Implement", prompt="@prompts/implement/implement.md"] review [label="Review", prompt="@prompts/implement/review.md"] ``` The `@` prefix tells the engine to read the file contents and use them as the prompt text. This keeps Graphviz files concise and lets you version prompts as standalone Markdown. File references are resolved relative to the Graphviz file's directory first, then fall back to `~/.fabro/`. This lets you keep shared prompts in your user-level config and reference them from any project. ### Variable expansion Prompts support MiniJinja-style templates. Prompt rendering has access to the workflow goal and typed run inputs: ```dot title="pipeline.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Pipeline { graph [goal="Add a /health endpoint to the API server"] implement [prompt="Implement the following: {{ goal }}"] } ``` Before execution, `{{ goal }}` becomes `Add a /health endpoint to the API server`. | Expression | Resolves to | | - | - | | `{{ goal }}` | The graph-level `goal` attribute | | `{{ inputs.name }}` | A value from `[run.inputs]` | A prompt variable that nothing binds is a diagnostic from the workflow compile. `fabro validate` reports it as a warning (`attractor.unbound_input`) and leaves the text unrendered; run-style commands and `fabro preflight` refuse the workflow with `unsupported.template.unbound_input` before a run is created. Environment variables are not available in prompt templates. Prompt and goal templates can use static MiniJinja includes to share partials: ```md title="prompts/plan.md" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} {% include "partials/context.md" %} Write a plan for {{ goal }}. ``` Include names must be literal strings, resolve relative to the file being rendered, and stay under that template root. Dynamic include expressions such as `{% include inputs.partial %}` are rejected during manifest bundling because Fabro must know every template file before a sandbox run starts. ### Fallback to label If a node has neither a `prompt` attribute nor an empty one, Fabro uses the `label` as the prompt: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} // "Do work" is both the display label and the prompt work [label="Do work"] ``` This is convenient for simple nodes but not recommended for production workflows where you want precise control over agent instructions. ## Prompt assembly The prompt the agent receives is not just the `prompt` attribute. Fabro prepends a **preamble** (unless fidelity is `full`) that summarizes what happened in prior stages: ``` [Preamble: goal, completed stages, context values] [Your prompt attribute text] ``` The preamble is controlled by the **fidelity** setting. See [Context: Fidelity](/execution/context#fidelity-controlling-agent-context) for the full reference. Given a plan-then-implement workflow where the plan stage completed, the `implement` node with `fidelity="compact"` would receive: ```markdown theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} Goal: Add a /health endpoint to the API server ## Completed stages - **plan**: success - Model: claude-sonnet-4-5, 12.4k tokens in / 3.2k out - Files: docs/plan.md ## Context - tests_passed: false Read plan.md and implement every step. ``` The last line is the node's `prompt` attribute. Everything above it is the preamble. ## System prompt Each agent session has a **system prompt** that provides foundational instructions. The system prompt is built once per session and includes: | Section | Contents | | - | - | | Identity and role | What the agent is and how it should approach tasks | | Environment context | Working directory, platform, OS, git branch, model, date | | Tool guidance | Per-tool usage instructions (when to use `read_file` vs `shell`, etc.) | | Coding best practices | Guidelines for clean, minimal, focused changes | | Project docs | Contents of `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, or `.codex/instructions.md` | | Skills | Available skill templates the agent can invoke | The system prompt is **separate** from the preamble. The preamble is prepended to the user-facing prompt message, while the system prompt is set as the LLM's system message. The system prompt varies by LLM provider. Each provider has its own identity text, tool guidance, and coding conventions tuned to the model's strengths. The assembled prompt follows this structure: ``` [Core prompt: identity, environment, task guidance, tool reference, coding practices] [Project docs: AGENTS.md, CLAUDE.md, etc.] [Skills: available skill templates] ``` This is the full system prompt sent to Claude as the LLM system message. The `` block is filled in at runtime. Tool guidance tracks the tools actually registered for the session. The `web_search` section shown below is present when a [Brave Search API key](/integrations/brave-search) or [Venice API key](/integrations/venice-search) is configured. Without either key, both the tool and its guidance are omitted. ``` You are Claude, an AI coding assistant made by Anthropic. You help users with software engineering tasks including solving bugs, adding new functionality, refactoring code, explaining code, and more. You are an interactive agent that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. Working directory: /home/user/project Is git repository: true Git branch: main Platform: linux OS version: Linux 6.1.0 Today's date: 2026-03-05 Model: claude-sonnet-4-5 # Doing Tasks - The user will primarily request you to perform software engineering tasks. These may include solving bugs, adding new functionality, refactoring code, explaining code, and more. - In general, do not propose changes to code you have not read. If a user asks about or wants you to modify a file, read it first. Understand existing code before suggesting modifications. - Do not create files unless they are absolutely necessary for achieving your goal. Generally prefer editing an existing file to creating a new one, as this prevents file bloat and builds on existing work more effectively. - If your approach is blocked, do not attempt to brute force your way to the outcome. Consider alternative approaches or other ways you might unblock yourself. - Avoid over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused. - Do not add features, refactor code, or make improvements beyond what was asked. - Do not add error handling, fallbacks, or validation for scenarios that cannot happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). - Avoid backwards-compatibility hacks. If you are certain something is unused, delete it completely. # Tools Use the provided tools to interact with the codebase and environment. Do NOT use the shell tool to run commands when a relevant dedicated tool is provided: - To read files use read_file instead of cat, head, tail, or sed. - To edit files use edit_file instead of sed or awk. - To create files use write_file instead of cat with heredoc or echo redirection. - To search for files use glob instead of find or ls. - To search the content of files use grep instead of grep or rg. ## read_file Read files before editing them. Always read a file before attempting to edit it. Use offset/limit for large files. Reading a file you have not read before is always appropriate. ## edit_file Performs exact string replacements in files. The old_string must be an exact match of existing text and must be unique in the file. If old_string matches multiple locations, provide more surrounding context to make it unique. Prefer editing existing files over creating new ones. When editing text, ensure you preserve the exact indentation as it appears in the file. ## write_file Use write_file only when creating new files. Prefer edit_file for modifying existing files. Always prefer editing existing files in the codebase over creating new ones. ## shell Use for running commands, tests, and builds. Default timeout is 120 seconds. Use timeout_ms parameter for longer-running commands. ## grep Search file contents with regex patterns. Supports output modes: content, files_with_matches, count. Use this for searching the content of files rather than using shell grep or rg. ## glob Find files by name pattern. Results sorted by modification time (newest first). Use this for finding files rather than using shell find or ls commands. ## web_search Search the web. Returns titles, URLs, and descriptions. ## web_fetch Fetch content from a URL and optionally summarize it. Pass a prompt to extract specific information instead of returning the full page. URLs must start with http:// or https://. # Coding Best Practices Write clean, maintainable code. Handle errors appropriately. Follow existing code conventions in the project. Keep changes minimal and focused on the task. # Project Docs # Available Skills When the user's request matches a skill below, call the `use_skill` tool to load its instructions, then follow them. - `commit`: Create a git commit following best practices - `review`: Review code changes for issues and improvements ``` Fabro selects an agent profile for the model. Anthropic, Claude 5, OpenAI, GPT-5.6, Gemini, and Kimi profiles can use different identity text, tool names, tool guidance, and coding conventions. The overall system-prompt structure is the same. ### Project docs Fabro automatically discovers project instruction files by walking the directory hierarchy from the git root to the working directory. Which files are loaded depends on the agent profile: | Agent profile | Files | | - | - | | Anthropic and Claude 5 | `AGENTS.md`, `CLAUDE.md` | | OpenAI and GPT-5.6 | `AGENTS.md`, `.codex/instructions.md` | | Gemini | `AGENTS.md`, `GEMINI.md` | | Kimi | `AGENTS.md` | Files are loaded in directory order (root first, deepest last) with a total budget of 32KB. If the combined content exceeds this budget, later files are truncated. ### Environment context The system prompt includes an `` block with runtime details: ```xml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} Working directory: /home/user/project Is git repository: true Git branch: main Platform: linux OS version: Linux 6.1.0 Today's date: 2026-03-05 Model: claude-opus-4-6 Knowledge cutoff: May 2025 ``` This block also includes `git status --short` and recent commits when available, giving the agent awareness of the current repository state. ## Prompt nodes vs. agent nodes Both agent nodes (`shape=box`) and prompt nodes (`shape=tab`) go through the same prompt assembly pipeline: variable expansion, preamble prepending, and system prompt construction. The difference is execution: | | Agent node | Prompt node | | - | - | - | | Shape | `box` (default) | `tab` | | Tool access | Yes (agentic loop) | No (single LLM call) | | Prompt assembly | Preamble + prompt | Preamble + prompt | | Routing directives | Extracted from response | Extracted from response | | Context updates | `response.{node_id}`, `last_response` | `response.{node_id}`, `last_response` | Use prompt nodes for analysis, classification, and summarization tasks where tools are not needed. ## Prompt logging Fabro persists the assembled prompt to `stages/{rank:03}-{node_id}@{visit}/prompt.md` in metadata snapshots and `fabro dump` output for every agent and prompt stage. This includes the preamble (if any) and the expanded prompt text. Use these files for debugging when an agent behaves unexpectedly. # Skills Source: https://docs.fabro.sh/agents/skills Reusable prompt templates that extend agent capabilities Skills are reusable prompt templates that give agents structured instructions for common tasks -- code review, committing, test writing, refactoring, and more. They are discovered automatically from convention directories, listed in the agent's system prompt, and can be invoked either by the user (with `/skill-name` syntax) or by the agent itself (via the `use_skill` tool). Fabro implements the open [Agent Skills](https://agentskills.io/home) format. Each skill is a directory containing a `SKILL.md` file with YAML frontmatter and Markdown instructions. See the [Agent Skills specification](https://agentskills.io/specification) for the full file format, and [What are skills?](https://agentskills.io/what-are-skills) for an introduction. ## Discovery Fabro discovers skills from three convention directories, searched in order: | Directory | Scope | | - | - | | `~/.fabro/skills/` | Global -- applies to all projects | | `{git_root}/.fabro/skills/` | Project -- checked into the repo | | `{git_root}/skills/` | Project (alternate location) | Within each directory, Fabro looks for `*/SKILL.md` files (one level of nesting). When the same skill name appears in multiple directories, **later directories override earlier ones**. This means project-level skills take precedence over global skills, letting you customize behavior per-repo. You can override the default directories by setting `skill_dirs` in the session configuration. When set, only the specified directories are searched. ## Invocation Skills can be invoked in two ways. ### Slash syntax (user input) Users can reference a skill with a `/` prefix in their input: ``` /commit fix the off-by-one error in pagination ``` Fabro detects the `/commit` token, finds the matching skill, and expands the input by replacing the skill reference with the full template. The remaining text (`fix the off-by-one error in pagination`) is substituted into the `{{user_input}}` placeholder. Rules for slash syntax: * The `/` must be preceded by whitespace or be at the start of the input * The skill name must be followed by whitespace or end of input * Only one skill reference per input is allowed * File paths like `/usr/bin/bash` are not matched (the character after `/` must be a lowercase letter, and the name cannot contain `/`) ### use\_skill tool (agent-initiated) When skills are available, Fabro adds a `use_skill` tool to the agent's toolset and lists the available skills in the system prompt: ``` # Available Skills When the user's request matches a skill below, call the `use_skill` tool to load its instructions, then follow them. - `commit`: Create a git commit following best practices - `review`: Review code changes for issues and improvements ``` The agent can call `use_skill` with a skill name to load the template, then follow the instructions: | Parameter | Type | Required | Description | | - | - | - | - | | `skill_name` | string | yes | Name of the skill to load (without the `/` prefix) | Returns the skill's template text. ## Lifecycle 1. **Discovery** -- On session startup, Fabro scans the skill directories and parses all valid `SKILL.md` files 2. **Registration** -- If any skills are found, the `use_skill` tool is registered and the skill list is added to the system prompt 3. **Expansion** -- When user input contains a `/skill-name` reference, Fabro expands it before sending to the LLM 4. **Tool use** -- The agent can also call `use_skill` at any time to load a skill's template mid-conversation # Sub-agents Source: https://docs.fabro.sh/agents/subagents Delegate subtasks to child agent sessions An agent can spawn **sub-agents** to delegate work to independent child sessions. Each sub-agent gets its own LLM session and tool access, runs concurrently with the parent, and returns its result when finished. Sub-agents are only available with the [API backend](/core-concepts/agents#api-backend-default) (the default). Agents using the [ACP backend](/core-concepts/agents#acp-backend) cannot spawn Fabro sub-agents. ## Tools Sub-agent management is exposed through four built-in tools: | Tool | Description | | - | - | | `spawn_agent` | Create a new sub-agent with a task prompt | | `send_input` | Send follow-up input to a running or completed sub-agent | | `wait` | Block until a sub-agent completes and return its result | | `close_agent` | Close a running or completed sub-agent | These tools are registered automatically when the session starts. They inherit the parent's permissions. ## Session isolation Each sub-agent runs in its own session: * **Own conversation** -- the child starts with a fresh LLM history * **Own tool access** -- the child can use the same tools as the parent * **Concurrent execution** -- the parent can continue working before calling `wait` The parent can spawn multiple sub-agents and synchronize with them later. ## Continue a completed session A completed sub-agent remains available until the parent closes it. Calling `send_input` starts another turn in the same child session, so the child keeps its conversation history. A message sent while the child is still running is queued for a safe turn boundary instead. Call `wait` again to receive the new turn's result. Call `close_agent` when the child is no longer needed; a closed child cannot accept more input. ## Depth limits Sub-agents can themselves spawn sub-agents, creating a hierarchy. The coding agent limits how many child sessions a stage can hold open at once and how deep the tree can grow; the defaults keep one level of children. If a child tries to exceed the limit, `spawn_agent` returns an error immediately. ## Error handling Sub-agent failures do not automatically fail the parent stage. The parent receives the failure through `wait` and decides how to respond. Common cases: * **Panics or errors** -- returned as a failed `wait` result * **`spawn_agent` fails** -- returned immediately as a tool result ## Event forwarding Sub-agent observability now uses **session linkage**, not wrapper events. Parent-owned lifecycle events: | Event | When | | - | - | | `agent.sub.spawned` | A sub-agent was created | | `agent.sub.completed` | A sub-agent finished successfully | | `agent.sub.failed` | A sub-agent failed | | `agent.sub.closed` | A sub-agent was cancelled | Forwarded child activity: * Child tool calls, assistant messages, warnings, and other non-noisy session events are forwarded into the parent run's event stream as normal agent events. * The forwarded event keeps the child's `session_id`. * `parent_session_id` points to the child's immediate parent session. Example envelope: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "event": "agent.tool.started", "session_id": "ses_child", "parent_session_id": "ses_parent", "node_id": "code", "properties": { "tool_name": "read_file" } } ``` Nested sub-agents preserve the immediate parent-child relationship. A grandchild forwarded through multiple parents still carries its own `session_id`, and `parent_session_id` remains set to the grandchild's direct parent rather than the root. High-volume streaming events such as text deltas and tool output deltas are filtered out of the forwarded stream to reduce noise. ## When to use sub-agents Sub-agents are most useful for: * **Parallel research** -- inspect multiple code paths at once * **Isolation** -- try a risky approach without polluting the parent's context * **Divide and conquer** -- split an independent task into smaller pieces * **Context management** -- offload work when the parent session is getting crowded Use [child runs](/execution/child-runs) instead when the delegated work should be a separate Fabro run with its own workflow, lifecycle, sandbox, checkpoints, and outputs. Sub-agents run until they complete, fail, are cancelled, or hit the session's wall-clock timeout. All active sub-agents are cleaned up automatically when the parent session closes. # Tools Source: https://docs.fabro.sh/agents/tools Built-in tools for file I/O, shell commands, search, and web access Every agent in Fabro has access to a set of built-in tools for interacting with the codebase and environment. Tools execute inside the agent's [sandbox](/execution/environments) — whether that's the local machine, a Docker container, or a Daytona VM — so the same tool calls work identically regardless of provider. The tools described on this page apply to the **API backend** (the default). When using the [ACP backend](/core-concepts/agents#acp-backend), the external agent process provides its own tools — Fabro's built-in tools are not used. ## Core tools These tools are registered for every provider profile: | Tool | Category | Description | | - | - | - | | `shell` | shell | Run commands as Bash source via `bash -c` | | `read_file` | read | Read file contents with optional offset and limit | | `write_file` | write | Create or overwrite a file | | `grep` | read | Search file contents with regex patterns | | `glob` | read | Find files by name pattern | | `web_search` | shell | Search the web via Brave or Venice | | `web_fetch` | shell | Fetch and optionally summarize a URL | ## Provider-specific tools Some tools are only available with certain LLM providers: | Tool | Providers | Description | | - | - | - | | `edit_file` | Anthropic, Gemini | Replace a string in a file (find-and-replace) | | `apply_patch` | OpenAI | Apply a v4a-format patch to modify files | | `update_plan` | OpenAI | Maintain a multi-step plan for the current task | | `TaskCreate`, `TaskUpdate`, `TaskGet`, `TaskList` | Anthropic | Maintain a shared task list for the root agent session | | `read_many_files` | Gemini | Read multiple files in a single call | | `list_dir` | Gemini | List directory contents with depth control | ## Tool reference ### shell Executes the command as Bash source in the sandbox's working directory, equivalent to `bash -c `. The shell is not a login shell, and Fabro adds no shell options of its own — no `errexit`, no `pipefail`. Docker and Daytona sandboxes require `/bin/bash`; local sandboxes resolve `bash` through the worker's `PATH`. To run something under a different interpreter, say so in the command itself (`sh -c ...`, a script with a `#!/bin/sh` shebang, or an explicit `set -o pipefail`); those run beneath Fabro's Bash boundary. | Parameter | Type | Required | Description | | - | - | - | - | | `command` | string | yes | Bash source to evaluate | | `timeout_ms` | integer | no | Timeout in milliseconds | | `description` | string | no | Description of what the command does | The timeout defaults to the provider's configured value (10s for most providers, 120s for Anthropic) and is capped at the maximum (600s / 10 minutes). If the command exceeds the timeout, Fabro kills the process and returns a "Command timed out" message along with any output captured so far. The output includes the exit code, stdout, and stderr. ### read\_file Reads a file and returns its contents with line numbers. | Parameter | Type | Required | Description | | - | - | - | - | | `file_path` | string | yes | Absolute path to the file | | `offset` | integer | no | 1-based line number to start reading from | | `limit` | integer | no | Number of lines to read (default: 2000) | Output is formatted with line numbers (e.g. ` 1 | fn main() {`), making it easy for agents to reference specific lines when editing. ### write\_file Creates or overwrites a file. | Parameter | Type | Required | Description | | - | - | - | - | | `file_path` | string | yes | Absolute path to the file | | `content` | string | yes | Content to write | `write_file` replaces the entire file. For changes to an existing file, prefer `edit_file`, which only replaces the string you name. ### edit\_file Replaces a string in an existing file. Available for Anthropic and Gemini providers. | Parameter | Type | Required | Description | | - | - | - | - | | `file_path` | string | yes | Absolute path to the file | | `old_string` | string | yes | The string to find | | `new_string` | string | yes | The replacement string | | `replace_all` | boolean | no | Replace all occurrences (default: false) | If `old_string` is not found, the tool returns an error. If multiple occurrences exist and `replace_all` is false, the tool returns an error asking for more context or to set `replace_all`. Because `old_string` must match the file exactly, an edit built from a stale or remembered version of the file fails rather than silently applying somewhere unintended. ### grep Searches file contents with a regex pattern. The sandbox uses ripgrep when `rg` is on the path and falls back to POSIX `grep` otherwise, detected once and cached per sandbox. Keep patterns portable across both rather than relying on ripgrep-only syntax. | Parameter | Type | Required | Description | | - | - | - | - | | `pattern` | string | yes | Regex pattern to search for | | `path` | string | no | Path to search in (default: `.`) | | `glob_filter` | string | no | Glob pattern to filter which files are searched | | `case_insensitive` | boolean | no | Case-insensitive search (default: false) | | `max_results` | integer | no | Maximum number of results | Results are returned as `file:line:content` lines. ### glob Finds files matching a glob pattern. | Parameter | Type | Required | Description | | - | - | - | - | | `pattern` | string | yes | Glob pattern to match files (e.g. `**/*.rs`) | | `path` | string | no | Directory to search in (default: working directory) | Returns matching file paths, one per line, sorted lexicographically by their path relative to the search root. Patterns are case-sensitive and relative to `path`: `*` and `?` stay within one path segment, bracket expressions such as `[abc]` match one character, and `**` crosses directories when used as a complete segment. Leading dots are matched normally. For example, `*.rs` searches only the root of `path`, and `**/*.rs` searches recursively. Patterns must use `/`, be relative, and cannot contain a backslash or `..` segment. ### web\_search Searches the web using Brave Search or Venice Search. Fabro selects the backend automatically from the available credentials. | Parameter | Type | Required | Description | | - | - | - | - | | `query` | string | yes | Search query. Venice rejects queries longer than 400 characters before the HTTP call. | | `max_results` | integer | no | Maximum results (default: 5, max: 20) | Fabro uses direct [Brave Search](/integrations/brave-search) when `BRAVE_SEARCH_API_KEY` is present. Otherwise it uses [Venice Search](/integrations/venice-search) when `VENICE_API_KEY` is present. If both credentials are present, Brave wins. Venice always uses its Brave search engine. Runs read both keys from the server vault. Workers start from a cleared environment, so exporting a key in the server's shell has no effect. The standalone agent CLI reads the keys from the invoking shell instead. The tool is registered when either credential is available. Once Fabro selects a backend, a failed call returns an error; it does not retry through the other backend. Results contain numbered titles, URLs, and descriptions. Venice includes `date` on a fourth line when present. ### web\_fetch Fetches content from a URL and optionally summarizes it with an LLM. | Parameter | Type | Required | Description | | - | - | - | - | | `url` | string | yes | URL to fetch (must be `http://` or `https://`) | | `prompt` | string | no | A question about the page content; returns a concise answer instead of the full page | | `timeout_ms` | integer | no | Timeout in milliseconds (default: 30000, max: 60000) | HTML content is automatically converted to Markdown (with script and style tags stripped). Output is capped at 100KB. When a `prompt` is provided and a summarizer model is configured, the fetched content is passed to a lightweight LLM call that returns a concise answer. ### apply\_patch Applies a v4a-format patch to create, update, or delete files. Available for OpenAI providers. | Parameter | Type | Required | Description | | - | - | - | - | | `patch` | string | yes | Patch content in v4a format | The v4a format uses `*** Begin Patch` / `*** End Patch` delimiters with `*** Add File:`, `*** Delete File:`, and `*** Update File:` operations. ### read\_many\_files Reads multiple files in a single tool call. Available for Gemini. | Parameter | Type | Required | Description | | - | - | - | - | | `paths` | string\[] | yes | Array of absolute file paths | Returns each file's contents prefixed with `=== path ===`. ### list\_dir Lists directory contents with optional depth control. Available for Gemini. | Parameter | Type | Required | Description | | - | - | - | - | | `path` | string | yes | Directory path to list | | `depth` | integer | no | Depth of listing (default: 1) | Directories are suffixed with `/` in the output. ### update\_plan Maintains the current task plan. Available for OpenAI providers. | Parameter | Type | Required | Description | | - | - | - | - | | `plan` | object\[] | yes | Full ordered list of plan steps | | `plan[].step` | string | yes | Step text; step text must be unique within the plan | | `plan[].status` | string | yes | `pending`, `in_progress`, or `completed` | | `explanation` | string | no | Short note explaining why the plan changed | The submitted list replaces the current plan for that OpenAI session. Fabro reconciles steps by exact step text, emits `todo.created`, `todo.updated`, and `todo.deleted` events for changes, and projects the current list into run state. ### TaskCreate, TaskUpdate, TaskGet, and TaskList Maintains a shared task list for Anthropic providers. The list is scoped to the root agent session, so sub-agents share the same task projection. `TaskCreate` creates a task with `subject`, `description`, optional `activeForm`, and optional metadata. `TaskUpdate` changes an existing task by `taskId`; setting `status` to `deleted` removes it from the projection. `TaskGet` returns full details for one task, while `TaskList` returns the current shared task list. Like `update_plan`, task changes are persisted as `todo.created`, `todo.updated`, and `todo.deleted` events and replay into run state. When an Anthropic session has not used `TaskCreate` or `TaskUpdate` for ten assistant turns, Fabro may inject a system reminder asking the agent to keep task state current. The reminder is only added when both tools are available and resets after the agent uses either tool. ## Tool execution ### Parallel execution When an LLM returns multiple tool calls in a single response, Fabro can execute them in parallel. Independent tool calls run concurrently, while the results are collected in order. If the agent session is cancelled, pending tool calls return a "Cancelled" error. ### Argument validation Before executing a tool, Fabro validates the arguments against the tool's JSON Schema. Invalid arguments are rejected with a descriptive error before the tool executor runs. ### Output truncation Tool output is truncated before being stored in conversation history to prevent context window bloat. Each tool has default limits: | Tool | Character limit | Truncation mode | | - | - | - | | `grep` | 30,000 | Tail (keep end) | | `glob` | 20,000 | Tail | | `edit_file` | 10,000 | Tail | | `apply_patch` | 10,000 | Tail | | `write_file` | 1,000 | Tail | | Other tools | No default limit | Head + tail | Limits can be overridden per-tool via `SessionConfig.tool_output_limits`. ### Error handling When a tool call fails, the error is returned to the agent as a tool result with an error flag — the agent loop continues. Tool errors do **not** fail the stage. The LLM sees the error message and decides how to respond: retry the call, try an alternative approach, or move on. Common error cases: | Error | Cause | | - | - | | Unknown tool | The agent called a tool that doesn't exist | | Argument validation failure | Arguments don't match the tool's JSON Schema | | File not found | The target file doesn't exist | | Command timeout | A shell command exceeded its timeout | | `old_string` not found | An `edit_file` anchor didn't match the file's current contents | ### Timeouts Shell commands have two timeout settings: | Setting | Default | Description | | - | - | - | | `default_command_timeout_ms` | 10,000 (120,000 for Anthropic) | Timeout when the agent doesn't specify one | | `max_command_timeout_ms` | 600,000 (10 minutes) | Hard cap regardless of what the agent requests | The agent can request a specific timeout via the `timeout_ms` parameter, but it's always capped at the maximum. ## Extending with MCP Additional tools can be added via [MCP servers](/agents/mcp). MCP tools appear alongside built-in tools in the agent's tool set and follow the same permission and execution model. ## Further reading How permissions control which tools are available. Sandbox providers where tools execute. Extend agents with Model Context Protocol servers. Sub-agents inherit tools from their parent session. # Create automation Source: https://docs.fabro.sh/api-reference/automations/create-automation /api-reference/fabro-api.yaml post /api/v1/automations Creates a new automation definition. # Create automation run Source: https://docs.fabro.sh/api-reference/automations/create-automation-run /api-reference/fabro-api.yaml post /api/v1/automations/{id}/runs Creates a new run by firing the automation's enabled API trigger. # Delete automation Source: https://docs.fabro.sh/api-reference/automations/delete-automation /api-reference/fabro-api.yaml delete /api/v1/automations/{id} Deletes an automation definition when `If-Match` matches the current automation revision. # List automation runs Source: https://docs.fabro.sh/api-reference/automations/list-automation-runs /api-reference/fabro-api.yaml get /api/v1/automations/{id}/runs Returns durable runs created by one automation. # List automations Source: https://docs.fabro.sh/api-reference/automations/list-automations /api-reference/fabro-api.yaml get /api/v1/automations Returns all configured automation definitions. # Replace automation Source: https://docs.fabro.sh/api-reference/automations/replace-automation /api-reference/fabro-api.yaml put /api/v1/automations/{id} Replaces an automation definition when `If-Match` matches the current automation revision. # Retrieve automation Source: https://docs.fabro.sh/api-reference/automations/retrieve-automation /api-reference/fabro-api.yaml get /api/v1/automations/{id} Returns one automation definition by id. # Client SDKs Source: https://docs.fabro.sh/api-reference/client-sdks Language-specific clients generated from the Fabro OpenAPI spec The Fabro API is defined by an OpenAPI 3.1 specification (`docs/public/api-reference/fabro-api.yaml` in the repository) that serves as the single source of truth for all endpoints, request/response schemas, and parameter definitions. The spec is also available at runtime from the server at `GET /api/v1/openapi.json`. Both client SDKs below are generated directly from this spec. ## TypeScript (Axios) The `@qltysh/fabro-api-client` package is a fully typed HTTP client generated with the [OpenAPI Generator](https://openapi-generator.tech/) using the `typescript-axios` template. It produces typed API classes (one per tag) and model interfaces for every schema. ### Regenerating From the repository root: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} cd lib/packages/fabro-api-client bun run generate ``` This runs `openapi-generator-cli` against `docs/public/api-reference/fabro-api.yaml` and writes the generated source into `lib/packages/fabro-api-client/src/`. ### Usage ```typescript theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} import { RunsApi, Configuration } from "@qltysh/fabro-api-client"; const config = new Configuration({ basePath: "http://localhost:3000" }); const runs = new RunsApi(config); const { data } = await runs.listRuns(); console.log(data); ``` The generated client includes a typed API class for each endpoint group: `RunsApi`, `WorkflowsApi`, `SessionsApi`, `VerificationsApi`, `InsightsApi`, and others. ## Rust (Types + Client) The `fabro-api` crate generates Rust structs, enums, and a `reqwest`-based HTTP client from the full OpenAPI spec at compile time using [progenitor](https://github.com/oxidecomputer/progenitor). This provides type-safe representations of all API models and a builder-style client for every endpoint. ### How It Works A `build.rs` script reads `docs/public/api-reference/fabro-api.yaml`, patches it from OpenAPI 3.1 to 3.0 for progenitor compatibility, and generates both types and a client. The generated code is written to `OUT_DIR` and included via: ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} // lib/foundation/fabro-api/src/lib.rs include!(concat!(env!("OUT_DIR"), "/codegen.rs")); ``` ### Regenerating The types and client are regenerated automatically on every `cargo build` when the OpenAPI spec changes: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} cargo build -p fabro-api ``` ### Usage ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use fabro_api::types::RunListItem; use fabro_api::Client; let client = Client::new("http://localhost:3000"); ``` All generated types derive `serde::Deserialize` and `serde::Serialize`. The client uses builder-style methods for each endpoint. # Create environment Source: https://docs.fabro.sh/api-reference/environments/create-environment /api-reference/fabro-api.yaml post /api/v1/environments Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API. # Delete environment Source: https://docs.fabro.sh/api-reference/environments/delete-environment /api-reference/fabro-api.yaml delete /api/v1/environments/{id} Deletes a non-default environment definition when `If-Match` matches the current environment revision. # List environments Source: https://docs.fabro.sh/api-reference/environments/list-environments /api-reference/fabro-api.yaml get /api/v1/environments Returns all server-managed environment definitions, sorted by id. # Replace environment Source: https://docs.fabro.sh/api-reference/environments/replace-environment /api-reference/fabro-api.yaml put /api/v1/environments/{id} Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`. # Retrieve environment Source: https://docs.fabro.sh/api-reference/environments/retrieve-environment /api-reference/fabro-api.yaml get /api/v1/environments/{id} Returns one server-managed environment definition by id. # List Run Questions Source: https://docs.fabro.sh/api-reference/human-in-the-loop/list-run-questions /api-reference/fabro-api.yaml get /api/v1/runs/{id}/questions Returns pending human-in-the-loop questions for a run. Questions are generated when the workflow needs user input to proceed. # Preview URL Source: https://docs.fabro.sh/api-reference/human-in-the-loop/preview-url /api-reference/fabro-api.yaml post /api/v1/runs/{id}/preview Generates a preview URL for a port exposed by the run's sandbox environment. # Submit Run Answer Source: https://docs.fabro.sh/api-reference/human-in-the-loop/submit-run-answer /api-reference/fabro-api.yaml post /api/v1/runs/{id}/questions/{qid}/answer Submits an answer to a pending question. The answer can be freeform text or a selected option key, depending on the question type. # List Models Source: https://docs.fabro.sh/api-reference/models/list-models /api-reference/fabro-api.yaml get /api/v1/models Returns one row per provider/model offering from the catalog. Model IDs are unique within a provider; `(provider, id)` is the resource identity. # Test Model Source: https://docs.fabro.sh/api-reference/models/test-model /api-reference/fabro-api.yaml post /api/v1/models/{id}/test Tests a model by sending a simple prompt and reporting pass/fail. # API Overview Source: https://docs.fabro.sh/api-reference/overview Introduction to the Fabro REST API The Fabro API is **under active development** and may be subject to change. Endpoints, request/response formats, and authentication mechanisms may evolve as the project matures. The Fabro API is a REST API for managing workflow runs, interactive sessions, and related resources. All requests and responses use JSON. ## Base URL By default, `fabro server start` listens on the Unix socket `~/.fabro/fabro.sock`. If you bind Fabro to TCP instead, the versioned API is served at a URL like: ``` http://localhost:3000/api/v1 ``` The advertised public API URL is configurable via `settings.toml`: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.api] url = "https://fabro.example.com/api/v1" ``` ## Authentication Fabro configures server auth with bootstrap methods: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.auth] methods = ["dev-token", "github"] [server.auth.github] allowed_usernames = ["alice", "bob"] ``` Protected `/api/v1/*` routes always accept: * `Authorization: Bearer ` * the browser session cookie when the web UI is enabled If both are present, the `Authorization` header wins. ### Dev Token When `"dev-token"` is enabled, the API accepts the raw dev token directly as a bearer credential: ``` Authorization: Bearer fabro_dev_... ``` This is the simplest way to make ad hoc local requests with `curl` or a script. The dev token is also available as a web login method when the web UI is enabled. ### GitHub OAuth When `"github"` is enabled, browser users can sign in through GitHub OAuth. Successful logins mint a server-issued session cookie that the browser automatically sends on subsequent API requests. `[server.auth.github].allowed_usernames` restricts which GitHub usernames may complete login. ### Browser Sessions When `[server.web].enabled = true`, the server requires `SESSION_SECRET` and issues a private `__fabro_session` cookie after successful login. The cookie is session transport only; the underlying bootstrap method remains `dev-token` or `github`, and that provenance is preserved in run metadata. ### HTTPS and Reverse Proxies Fabro's listener is plain HTTP (or a Unix socket) only. If you want a public HTTPS endpoint, terminate TLS at a reverse proxy, load balancer, or platform ingress and point it at Fabro's internal listener. ## Errors ### Error Shape All error responses share a consistent JSON structure: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "errors": [ { "status": "404", "title": "Not Found", "detail": "Run abc123 not found." } ] } ``` Each entry in the `errors` array contains: | Field | Type | Description | | - | - | - | | `status` | `string` | The HTTP status code as a string | | `title` | `string` | The canonical reason phrase for the status code | | `detail` | `string` | A human-readable explanation of the error | ### HTTP Status Codes | Status | Meaning | When It Occurs | | - | - | - | | `400 Bad Request` | The request body or parameters are invalid | Missing required fields, malformed JSON | | `401 Unauthorized` | Authentication is missing or invalid | No token, invalid dev token, or missing/expired session | | `403 Forbidden` | The authenticated user lacks access | Username not in the allowed list | | `404 Not Found` | The requested resource does not exist | Unknown run ID, unknown workflow name | | `409 Conflict` | The resource is in a conflicting state | Answering a question on a run that isn't running yet | | `410 Gone` | The resource is no longer available | SSE event stream has closed | | `501 Not Implemented` | The endpoint exists but is not yet implemented | Placeholder routes | | `502 Bad Gateway` | An upstream dependency failed | An upstream service returned an error | ## Pagination List endpoints that return large collections use offset-based pagination. Pass pagination parameters as query strings: | Parameter | Type | Default | Description | | - | - | - | - | | `page[limit]` | `integer` | `20` | Maximum number of items to return (clamped to 1–100) | | `page[offset]` | `integer` | `0` | Number of items to skip | Paginated responses include a `meta` object alongside the `data` array: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "data": [...], "meta": { "has_more": true } } ``` When `has_more` is `true`, increment the offset by the limit to fetch the next page. ## Immutable workflow versions `POST /api/v1/workflow-versions` validates and stores a complete workflow package. The package contains an entrypoint, its text files, and exact IDs for child-workflow dependencies. Its SHA-256 ID is based on canonical content, so submitting the same package again returns the same ID. A package can contain at most 512 files and 512 workflow dependencies. Each file can contain at most 512 KiB, and the complete canonical package can contain at most 2 MiB. Invalid workflow content or missing dependencies return `422`. ## Versioning The Fabro API is versioned under `/api/v1`. All versioned endpoints, including the OpenAPI document, live under that prefix. Future breaking changes can be introduced under a new versioned prefix while preserving existing clients. ## Discovery The root endpoint (`GET /`) returns discovery URLs. The health endpoint (`GET /health`) can be used for liveness checks. The OpenAPI spec is available at `GET /api/v1/openapi.json`. # Download Run Artifacts Source: https://docs.fabro.sh/api-reference/run-internals/download-run-artifacts /api-reference/fabro-api.yaml get /api/v1/runs/{id}/artifacts/download Streams a ZIP archive with the latest captured version of each artifact path. Stage order, retry number, and then stage ID determine the latest version, matching the artifacts page. Captures from the graph's boundary nodes are excluded, identified by their `start` and `exit` handler type rather than by node name. The archive streams, so the response status is sent before the first artifact is read. A failure after that point aborts the transfer rather than returning `500`. The ZIP central directory is written last, so a truncated download does not open as a valid archive. # List Run Artifacts Source: https://docs.fabro.sh/api-reference/run-internals/list-run-artifacts /api-reference/fabro-api.yaml get /api/v1/runs/{id}/artifacts Lists captured artifact files for a run. # List Run Events Source: https://docs.fabro.sh/api-reference/run-internals/list-run-events /api-reference/fabro-api.yaml get /api/v1/runs/{id}/events Returns one page of the run's stream (`PaginatedRunStreamList`): one ordered delivery of Petri's own `RunEvent`s and Fabro's platform records in the `RunStreamItem` envelope, in `stream_seq` order. The cursor is `after`: the last `stream_seq` the client saw, exclusive; the first page is `after=0`. A client that reconnects resumes from its last `stream_seq` and deduplicates by each item's `id`; every item is delivered once, in order, with no gap. # List Run Stages Source: https://docs.fabro.sh/api-reference/run-internals/list-run-stages /api-reference/fabro-api.yaml get /api/v1/runs/{id}/stages Returns the ordered list of stages in a run's workflow graph with their current status and timing. Stages are bounded by the workflow graph size, typically fewer than 20. # Retrieve Run Settings Source: https://docs.fabro.sh/api-reference/run-internals/retrieve-run-settings /api-reference/fabro-api.yaml get /api/v1/runs/{id}/settings Returns the persisted dense `WorkflowSettings` snapshot used to launch this run. # Retrieve Run Usage Source: https://docs.fabro.sh/api-reference/run-outputs/retrieve-run-usage /api-reference/fabro-api.yaml get /api/v1/runs/{id}/usage Returns token counts and costs broken down by stage and model for a specific run. # Approve Run Source: https://docs.fabro.sh/api-reference/runs/approve-run /api-reference/fabro-api.yaml post /api/v1/runs/{id}/approve Approves a pending run that requires pre-execution approval and makes it runnable. # Cancel Run Source: https://docs.fabro.sh/api-reference/runs/cancel-run /api-reference/fabro-api.yaml post /api/v1/runs/{id}/cancel Cancels a pending, runnable, or running run. Pre-execution runs are cancelled synchronously. Live runs return after the cancellation request is durably recorded and continue converging to a terminal cancelled state. Returns 409 if the run has already completed or been cancelled. # Create Run Source: https://docs.fabro.sh/api-reference/runs/create-run /api-reference/fabro-api.yaml post /api/v1/runs Creates a new workflow run in `submitted` status from an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The endpoint responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `pull_request_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`). # Create Run Pull Request Source: https://docs.fabro.sh/api-reference/runs/create-run-pull-request /api-reference/fabro-api.yaml post /api/v1/runs/{id}/pull_request Durably requests creation of a pull request for a completed run. The server generates the pull request content and creates the GitHub pull request after this request returns. Poll the URL in the Location response header until the creation succeeds or fails. If a creation is already pending for the run, the response returns that creation unchanged; any different `model` or `force` values in the new request are ignored. # Deny Run Source: https://docs.fabro.sh/api-reference/runs/deny-run /api-reference/fabro-api.yaml post /api/v1/runs/{id}/deny Denies a pending run that requires pre-execution approval and fails it with `approval_denied`. # Get Run Pull Request Creation Source: https://docs.fabro.sh/api-reference/runs/get-run-pull-request-creation /api-reference/fabro-api.yaml get /api/v1/runs/{id}/pull_request/creation Returns the latest explicit pull request creation requested for this run. # Link Run Parent Source: https://docs.fabro.sh/api-reference/runs/link-run-parent /api-reference/fabro-api.yaml put /api/v1/runs/{id}/parent Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs. # List Runs Source: https://docs.fabro.sh/api-reference/runs/list-runs /api-reference/fabro-api.yaml get /api/v1/runs Returns durable run summaries from the backing store, including runs persisted before the current server boot. Supports per-status filtering and sorting for both list and kanban renderings. Archived runs are hidden by default; pass `include_archived=true` (or `status=archived`) to include them. Runs in the `removing` bucket are hidden unless explicitly requested via `status=removing`. # Pause Run Source: https://docs.fabro.sh/api-reference/runs/pause-run /api-reference/fabro-api.yaml post /api/v1/runs/{id}/pause Pauses a running run. Returns 409 if the run is not running. # Render SVG Source: https://docs.fabro.sh/api-reference/runs/render-svg /api-reference/fabro-api.yaml get /api/v1/runs/{id}/graph Renders the workflow graph as an SVG image using Graphviz. # Retrieve Run Source: https://docs.fabro.sh/api-reference/runs/retrieve-run /api-reference/fabro-api.yaml get /api/v1/runs/{id} Returns the durable run summary for a run. # Start Run Source: https://docs.fabro.sh/api-reference/runs/start-run /api-reference/fabro-api.yaml post /api/v1/runs/{id}/start Requests start for a submitted run. User-created runs become runnable; parent-generated child runs may become pending until approved. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. # Unlink Run Parent Source: https://docs.fabro.sh/api-reference/runs/unlink-run-parent /api-reference/fabro-api.yaml delete /api/v1/runs/{id}/parent Removes a run's orchestration parent. Already-root runs are returned unchanged. # Unpause Run Source: https://docs.fabro.sh/api-reference/runs/unpause-run /api-reference/fabro-api.yaml post /api/v1/runs/{id}/unpause Resumes a paused run. Returns 409 if the run is not paused. # Retrieve Server Settings Source: https://docs.fabro.sh/api-reference/settings/retrieve-server-settings /api-reference/fabro-api.yaml get /api/v1/settings Returns the server's current in-memory settings view as the typed `ServerSettings` payload. # Delete a variable Source: https://docs.fabro.sh/api-reference/variables/delete-a-variable /api-reference/fabro-api.yaml delete /api/v1/variables/{name} # Get a variable Source: https://docs.fabro.sh/api-reference/variables/get-a-variable /api-reference/fabro-api.yaml get /api/v1/variables/{name} # List variables Source: https://docs.fabro.sh/api-reference/variables/list-variables /api-reference/fabro-api.yaml get /api/v1/variables Returns non-sensitive variables, including values. # Replace a variable value Source: https://docs.fabro.sh/api-reference/variables/replace-a-variable-value /api-reference/fabro-api.yaml put /api/v1/variables/{name} Replaces a variable value and preserves the existing description when omitted. # Store or update a variable Source: https://docs.fabro.sh/api-reference/variables/store-or-update-a-variable /api-reference/fabro-api.yaml post /api/v1/variables Stores a non-sensitive variable for run config interpolation. # Create Workflow Version Source: https://docs.fabro.sh/api-reference/workflow-versions/create-workflow-version /api-reference/fabro-api.yaml post /api/v1/workflow-versions Validates and stores an immutable workflow package in content-addressed storage. Repeating the same canonical content returns the same identifier. Requires an authenticated user or a worker token with the `agent:run_tools` capability. Ordinary worker tokens cannot register versions. Registration creates no run and starts no execution. # First CLI, Docker sandboxing, and web dashboard Source: https://docs.fabro.sh/changelog/2026-02-23 ## Fabro CLI Run AI workflows from the command line with `fabro run start`, validate DOT workflow definitions with `fabro validate`, and step through dry-runs to test logic before committing real LLM calls. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run start spec-dod-multimodel.fabro fabro validate my-workflow.fabro fabro run start --dry-run my-workflow.fabro ``` The CLI streams LLM responses in real time and supports interactive tool approval — each tool call pauses for you to approve or reject via arrow-key prompts, giving fine-grained control over what the agent does. ## Docker sandboxing Agent tool execution can now run inside Docker containers, so workflows can safely run shell commands, edit files, and install dependencies without affecting your host machine. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run start --docker my-workflow.fabro ``` The container is shared across all stages in a run, so tools have access to the same filesystem throughout the workflow. ## Web dashboard A pipeline kanban board shows all active runs with their status, progress, and any pending human-in-the-loop prompts. Watch workflows execute in real time from the browser. ## More * Color-coded terminal output with ANSI styling makes it easier to follow agent activity * Use `--output-format json` for NDJSON event streaming into CI/CD pipelines or custom dashboards * Ships with Claude Opus 4.6 (1M context window) as the default Anthropic model and Gemini 3.1 Pro Preview as the default Gemini model * Anthropic models use adaptive thinking by default for deeper reasoning on complex tasks # Auto-compaction and run progress logs Source: https://docs.fabro.sh/changelog/2026-02-24 ## Auto-compaction Long-running agent sessions used to fail when they hit the context window limit. Now, sessions automatically summarize earlier conversation history when approaching the limit, so long workflows keep running without manual intervention. The compaction preserves the most relevant context — recent tool calls, file changes, and key decisions — while condensing older turns into a structured summary. ## Progress logs Every run now writes `progress.ndjson` and `live.json` to the logs directory, giving you a structured record of every LLM call, tool execution, and routing decision. Use these for post-run analysis, debugging, or building custom dashboards. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} # Watch events stream in real time tail -f ~/.fabro/logs//progress.ndjson ``` ## More * Run `fabro model sync` to download the latest model metadata from OpenRouter * Each run now tracks cumulative input/output tokens and estimated cost, displayed at completion # Skills system and secret redaction Source: https://docs.fabro.sh/changelog/2026-02-25 ## Skills Skills are reusable prompt templates that any workflow can invoke on demand. Define them as `skills//SKILL.md` files and activate them during runs with the `use_skill` tool. ``` skills/ code-review/ SKILL.md security-audit/ SKILL.md ``` This lets you build a library of domain-specific capabilities — a code review checklist, a security audit procedure, a migration playbook — and compose them into different workflows without duplicating prompts. ## Secret redaction API keys, tokens, and other secrets detected in NDJSON logs, `live.json`, and SSE events are now automatically redacted before being written. Previously, progress logs could contain sensitive values if the agent encountered them during tool execution. Now they're safe to share, store, and ship to external observability systems. ## More * Nodes now track visit counts and stop after a configurable maximum, preventing infinite loops in dry-run mode * Set `codergen_mode = "one_shot"` to use a single LLM call instead of an agent loop, useful for classification and summarization nodes * Added Claude Haiku 4.5 to the model catalog for fast, cost-effective tasks within workflows # Daytona cloud sandboxes and TOML run config Source: https://docs.fabro.sh/changelog/2026-02-26 ## Daytona cloud sandboxes Workflows can now execute in Daytona cloud environments with SSH access, persistent storage, and network isolation. Previously, Docker was the only sandbox option, which meant running everything locally. Daytona moves execution to the cloud, freeing up your machine and providing a more production-like environment. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run start --execution-env daytona my-workflow.fabro ``` ## TOML run configuration Long chains of CLI flags are hard to remember and impossible to version-control. Run settings now live in a TOML file with `[execution]` for sandbox config and `[vars]` for DOT source parameterization. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [execution] environment = "daytona" [vars] repo = "https://github.com/example/project" branch = "main" language = "typescript" ``` Use `$variable` placeholders in DOT workflow sources and supply values through the TOML config, making workflows reusable across different projects and environments. # MCP tool servers, web tools, and multi-CLI backends Source: https://docs.fabro.sh/changelog/2026-02-27 ## MCP support Agents can now connect to external tool servers using the Model Context Protocol. Any MCP-compatible server can provide tools to your workflows without custom integration — databases, APIs, internal services, anything with an MCP server. ## Web search and web fetch Two new built-in tools bring real-time information into workflow decisions. `web_search` queries the Brave Search API, and `web_fetch` retrieves web pages, converting HTML to Markdown and summarizing long content to fit within context limits. ## CLI backends Individual workflow nodes can delegate work to external AI coding assistants. Set `backend="cli"` and choose a provider; Fabro selects `claude`, `codex`, or `gemini` for the node instead of the built-in API agent loop. Current backend values are `api`, `cli`, and `acp`. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [type="agent", backend="cli", provider="openai"] review [type="agent", backend="cli", provider="anthropic"] ``` This means each stage in a workflow can use a different AI tool — use Codex for implementation and Claude Code for review, for example. ## Subagent tools Agents can spawn sub-agents to handle focused tasks in parallel, each with their own tool set and conversation context. The parent agent delegates work and receives results without its own context window growing. ## More * Context compaction now tracks which files were modified and preserves file-level state across compaction boundaries * Files created in remote sandboxes are automatically synced back to the host # Fabro web app and git checkpointing Source: https://docs.fabro.sh/changelog/2026-02-28 ## Fabro web app A full React-based dashboard for managing workflows. Browse workflow definitions with syntax-highlighted DOT and TOML, view run details with side-by-side diffs showing what each run changed, and inspect agent sessions turn-by-turn. The workflow detail page renders an interactive SVG diagram of the workflow graph with zoom, pan, direction toggle, and fit-to-window controls — see exactly where a run is in the graph at a glance. ## Git checkpointing Fabro now automatically creates git commits at each workflow node, so you can see the exact state of the codebase at any point in a run. The run detail page includes a checkpoint picker that lets you compare file changes between any two stages. Previously, you could only see the final output of a run. Now you can trace exactly what changed at each step and when. ## More * Tag runs with custom labels and filter them with `fabro run list` * DOT parser now supports unquoted bare string values (e.g., `model = gpt-5.2`) for cleaner workflow definitions * Moonshot AI, Z.AI, and Minimax LLM providers added to model catalog * Runs use time-sortable ULIDs instead of UUIDs, making it easier to find recent runs in logs and the API * Asymmetric JWT authentication between fabro-web and the API server, with GitHub identity in the token # Verifications, retrospectives, insights, and parallel branching Source: https://docs.fabro.sh/changelog/2026-03-01 ## Verifications Define verification checks that automatically validate run outputs. Browse the verification catalog to see all available checks, and review per-run results to catch regressions before merging. Verifications run after each workflow completes and report pass/fail status, so you get a quality gate between "the agent finished" and "the code is ready." ## Retrospectives After each run, an LLM-powered retro agent analyzes what happened and generates a structured summary — what worked, what didn't, timing breakdown, cost, and improvement suggestions. The retro prints inline in your terminal after the run completes, rendered as Markdown. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run start my-workflow.fabro # ... run executes ... # === Retro === # The run completed in 4m 32s across 6 stages... ``` Previously, understanding what happened in a run meant reading through raw logs. Now you get a concise post-mortem automatically. ## Insights SQL workbench Query your run data directly with SQL to answer questions like "which workflows fail most?" or "what's the average cost per stage?" The web app includes a query editor, template library, and execution history. ## Parallel git branching Workflow branches now execute in isolated git worktrees, so parallel stages can edit files simultaneously without conflicts. At fan-in nodes, changes merge cleanly back together. Previously, parallel stages sharing a single worktree would overwrite each other's changes. Worktree isolation makes parallel execution reliable. ## More * Fixed non-transient infrastructure failures (like invalid API keys) triggering retries instead of failing immediately * Fixed UTF-8 string slicing panics when truncating multi-byte characters in progress output * Interactive SVG graph rendering with animated nodes showing which stage is currently executing * Failure signatures and circuit breakers detect deterministic failure cycles (like the same syntax error on every retry) and short-circuit them, saving tokens and time * Stall watchdog detects hung pipeline handlers and reports them instead of silently blocking * Run data, sessions, and verifications stored in SQLite for fast querying and the Insights feature * Full light mode support with a theme toggle in the web app * File-based structured tracing across all crates controlled by `FABRO_LOG` # Sessions and workflow improvements Source: https://docs.fabro.sh/changelog/2026-03-02 ## Sessions Persistent chat sessions with SQLite storage. Start a conversation with an agent, close the terminal, and pick up where you left off. Sessions track messages, model, and conversation state. ## More * New `POST /runs/{id}/preview` endpoint generates a live preview URL for any running sandbox * Introduced interactive CLI chat sessions via the now-removed `fabro llm chat` command * GitHub OAuth login for the web app with automatic session management and JWT-based API authorization * Full documentation site with getting started guides, core concepts, workflow reference, and API docs * Asset snapshots capture files produced by workflow nodes for review * Styled 404 and 500 error pages matching the Fabro design system * `--debug` flag switches log level from INFO to DEBUG for troubleshooting **"Pipeline" renamed to "Workflow" and "Run" across the entire system.** API endpoints, CLI commands, config files, and the web UI now use `workflow` and `run` instead of `pipeline`. To migrate: 1. Replace `pipeline` with `workflow` in DOT files and TOML configs 2. Update API integrations to use new endpoint paths (e.g., `/runs` instead of `/pipelines`) 3. Regenerate API clients with `bun run generate` # Authentication, setup wizard, and fabro doctor Source: https://docs.fabro.sh/changelog/2026-03-03 ## Authentication Two new authentication methods for the API server. Mutual TLS provides strong identity verification for production deployments — both client and server present certificates. For teams on a Tailscale network, API requests can authenticate using Tailscale identity with no tokens or certificates required. Historical note: later releases removed inbound mutual TLS listener support from `fabro-server`; current deployments terminate TLS or mTLS upstream at a reverse proxy or platform ingress. ## Setup wizard Previously, getting Fabro running meant manually editing config files and setting environment variables. Now run `fabro install` for an interactive walkthrough that configures API keys, server settings, and authentication. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro install ``` The wizard detects your current configuration, prompts for missing values, and writes everything to `~/.fabro/fabro.toml`. ## Fabro doctor A single command to check your entire installation: system dependencies, cryptographic key validation, LLM provider connectivity, and web server configuration. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro doctor fabro doctor --verbose ``` Later releases removed the separate `--live` mode; doctor now always uses live server-backed diagnostics. If something is misconfigured, `fabro doctor` tells you exactly what's wrong and how to fix it. ## More * All configuration consolidated into `~/.fabro/fabro.toml` — API keys, auth settings, GitHub App config, and server options in one file * Fixed JWT `sub` claim using the GitHub API URL instead of the profile URL, causing identity mismatches * Fixed stale configuration persisting after `fabro install` until a manual restart * New indicatif-based progress display for `fabro run start` shows real-time stage progress, tool calls, model names, and timing * Mercury provider updated to `mercury-2`; estimated output speed (tok/s) added to `fabro model list` * Run defaults in `server.toml` are inherited by all workflow runs, so you don't have to repeat sandbox, model, or concurrency settings # Concurrency limits, SSH access, and timer nodes Source: https://docs.fabro.sh/changelog/2026-03-04 ## Concurrency limiter Set `max_concurrent_runs` in server config to control how many runs execute simultaneously. Additional start-requested runs wait as `runnable` before moving to `starting`, with both states visible in the UI and API. Previously, starting too many runs at once could overwhelm the machine. Now excess runs wait in a queue and start as capacity frees up. ## SSH access to running sandboxes Use `fabro ssh ` to get SSH access into running Daytona sandboxes for live debugging while the workflow executes. When something goes wrong mid-run, you can drop into the sandbox, inspect the filesystem, and understand the problem without waiting for the run to finish. Use `--preserve-sandbox` to keep sandboxes alive after a run completes for post-mortem inspection. ## Timer nodes Add `wait.timer` nodes to workflows that pause for a configured duration. Useful for rate limiting between API calls or waiting for external processes to complete. ## More * All list endpoints now support offset-based pagination with `offset` and `limit` parameters * New `GET /usage` endpoint returns aggregate token counts and estimated costs broken down by model * Hot config reload — changes to `server.toml` are detected and applied without restarting the server * All error responses now use a consistent JSON structure with error codes and messages * Fixed HTTP 529 (Overloaded) responses from LLM providers being misclassified as non-retryable * Fixed progress display panic when tool calls contain long whitespace sequences * Gemini 3.1 Flash Lite added to the model catalog for ultra-fast, low-cost tasks * Multi-provider ensemble demo workflow showing fan-out across models and result merging * Parallel branches show in progress UI; sandbox details with hyperlinks display during runs # Slack interviews, hooks, model failover, and GPT-5.4 Source: https://docs.fabro.sh/changelog/2026-03-05 ## Slack integration for interviews Fabro can now conduct human-in-the-loop interviews directly in Slack. The new Slack Socket Mode integration supports all five question types — yes/no, confirmation, multiple choice, multi-select, and freeform (via threaded replies). Because it uses Socket Mode, no public URL or webhook endpoint is required. Previously, interviews required the CLI or web UI. Now teams can answer Fabro's questions without leaving Slack. ## Hooks Workflows can now trigger actions at key moments — before a stage starts, after it completes, or when a run fails. Use hooks to notify external systems, enforce policies, or gate execution on custom conditions. Three executor types are available: `http` for calling external endpoints (with TLS mode configurable per-hook), `prompt` for natural-language conditions evaluated by an LLM, and `agent` for autonomous decision-making. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[hooks]] event = "stage.before" executor = "http" url = "https://api.example.com/webhook" tls_mode = "strict" ``` ## Model failover If your primary model is unavailable or rate-limited, runs now automatically switch to a backup. Configure fallback models at the provider level in your server config: ```toml title="server.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [providers.anthropic] failover = ["openai", "gemini"] ``` Previously, a provider outage would fail the entire run. Now it retries with the next provider in the list. ## GPT-5.4 and GPT-5.4 Pro OpenAI's GPT-5.4 and GPT-5.4 Pro are now available in the model catalog. Use the aliases `gpt54` and `gpt54-pro` in your workflow stylesheets. Neither replaces GPT-5.2 as the default OpenAI model. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} stage_a [model="gpt54-pro"]; ``` ## More * Added `fabro parse` command for printing the raw AST of a DOT workflow as JSON * Persistent CLI defaults in `~/.fabro/cli.toml` — set output format, default workflow, or any flag once * Richer condition expressions: `||`, `!`, numeric comparisons, `contains`, and `matches` (regex) * Per-node `max_visits` attribute overrides the graph-level `max_node_visits` setting * `$$` escapes literal dollar signs in prompts, fixing a bug where `$goal` could match as a substring * Simplified handler type names: `agent_loop` is now `agent`, `one_shot` is now `prompt` (old names still work) * Fixed a server-side error message that could leak to the browser in production * Fixed a crash in the web UI when loading run stages (`apiStages.map` on non-array response) * Fixed Docker demo build and verifications API response parsing * MODEL column in CLI tables widened from 24 to 30 characters to avoid truncating longer model names * Unknown `$variable` placeholders in DOT prompts now produce clear error messages instead of being silently passed through **`script.output` and `script.stderr` context keys renamed to `command.output` and `command.stderr`.** The old names were inconsistent with the handler name (`command`). To migrate: Update any DOT workflow prompts referencing `{{script.output}}` or `{{script.stderr}}` to use `{{command.output}}` and `{{command.stderr}}`. **`GET /runs/{id}/compare` renamed to `GET /runs/{id}/files`.** The endpoint now returns a standard paginated list of `FileDiff` items instead of the `RunCompare` envelope. To migrate: Update API integrations using `/compare` to use `/files`, and adjust response parsing from `{ checkpoints, files, stats }` to `{ data, meta }`. # API restructuring and sandbox improvements Source: https://docs.fabro.sh/changelog/2026-03-06 ## Provider API keys forwarded to sandboxes Agent CLI tools running inside Daytona sandboxes can now authenticate with LLM providers automatically. Fabro collects the relevant API key environment variables (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) and forwards them into the sandbox environment, so CLI agents like Claude and Codex can make API calls without manual credential setup. Previously, sandbox-based agents would fail to authenticate because API keys were only available on the Fabro server, not inside the sandbox. ## Checkpoint pushing for Daytona runs When a workflow runs on a Daytona sandbox, Fabro now pushes the run branch to origin after each checkpoint. Previously, commits on the run branch were lost when the sandbox was deleted, making it impossible to review intermediate work. This required upgrading the GitHub App token permission from `contents:read` to `contents:write`. Tokens are automatically refreshed before they expire. ## GitHub App tokens for Daytona Daytona sandboxes now authenticate git clones using GitHub App Installation Access Tokens (IATs) instead of the user's `gh` CLI token. Each token is scoped to `contents: read` on the specific repository being cloned. Fabro automatically detects whether a repository is public or private. Public repositories are cloned without credentials. Private repositories get a short-lived, minimally-scoped IAT generated from the GitHub App configured during `fabro install`. When credentials are missing or misconfigured, Fabro provides specific error messages for each failure mode: * App not installed for the organization * Installation suspended * Repository not accessible to the app * Authentication failure (bad key or app ID) **API response shapes have changed.** Flat fields are now nested into typed sub-objects (e.g. `model_id` becomes `model.id`), and relative time strings are replaced with ISO 8601 timestamps. Several endpoints were renamed: `retrieveRunSvg` is now `retrieveRunGraph`, `listRunCompare` is now `retrieveRunDiff`, and `RunListItemStatus` is now `BoardColumn`. To migrate: 1. Regenerate your TypeScript client: `cd lib/packages/fabro-api-client && bun run generate` 2. Update any code that reads flat fields to use the new nested paths 3. Update any code that displays timestamps — the API now returns ISO 8601 strings instead of pre-formatted relative times ## More * API responses now nest related fields into typed sub-objects (e.g. `model_id` becomes `model.id`) and return ISO 8601 timestamps instead of pre-formatted relative strings * `/runs/{id}/retro` now returns a structured `RetroDetail` schema instead of an untyped JSON blob; `/retros` supports new `workflow` and `smoothness` filter parameters * Adds pagination parameters to `listRunQuestions`, `listRunStages`, and `listRunVerifications` endpoints * Removes redundant API response types (`StartRunResponse`, `CancelRunResponse`, `SteerRunResponse`, `SubmitAnswerResponse`) * Filters preamble noise by excluding meta nodes, blank context values, and empty sections * Fixes 27 OpenAPI spec review items across verifications, insights, HITL, run outputs, workflows, and configuration * Fixed dry-run mode issues with stub scripts, runner working directory, and transition weights * Fixed GitHub App repo visibility check incorrectly treating 401/403 responses as public * Fixed duplicate `.env` variables when re-running `fabro install` # Completions API, new sandbox providers, and sandbox.env Source: https://docs.fabro.sh/changelog/2026-03-07 ## Completions API with SSE streaming Fabro now exposes a `POST /completions` endpoint for single-turn LLM completions. You can use it for structured output via JSON Schema, one-off prompts, or building custom frontends on top of Fabro's model routing. Streaming uses Anthropic-style SSE events (`message_start`, `content_block_delta`, `message_stop`, etc.), so you get tokens as they're generated rather than waiting for the full response. At the time of this release, the CLI's `fabro llm` commands could target the server instead of calling providers directly. That CLI namespace has since been removed. ## Two new sandbox providers: exe.dev and Sprites Fabro now supports three sandbox environments for running agent stages. In addition to Daytona, you can now use **exe.dev** VMs or **Sprites** (powered by Fly.io). Daytona provides cloud sandbox VMs with full lifecycle support. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [execution] environment = "daytona" ``` ## Sandbox environment variables You can now pass environment variables to sandboxes directly from your workflow config using the new `[sandbox.env]` section. This is useful for passing API keys, feature flags, or other configuration that agents need inside the sandbox — without baking them into Dockerfiles or snapshot images. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [sandbox.env] API_KEY = "sk-..." FEATURE_FLAG = "true" ``` **CLI commands renamed.** `fabro agent` is now `fabro exec` and `fabro models` is now `fabro model`. To migrate, update any scripts or aliases that reference the old command names. **Verification API paths have changed.** `/verifications` is now split into `/verification/criteria` and `/verification/controls`. The run sub-resource moved from `/runs/{id}/verifications` to `/runs/{id}/verification`. To migrate: 1. Regenerate your TypeScript client: `cd lib/packages/fabro-api-client && bun run generate` 2. Update any direct API calls to use the new paths **Removed API endpoints.** The following endpoints have been removed: * `GET /projects` and `GET /projects/{name}/branches` — projects and branches are no longer exposed via the API * `POST /workflows/{name}/runs` — use `POST /runs` instead (identical functionality) To migrate, regenerate your TypeScript client and update any direct API calls. ## More * New `POST /completions` endpoint for single-turn LLM completions with SSE streaming and structured output via JSON Schema * New `GET /models` endpoint exposes the full LLM model catalog with pagination * New `POST /models/{id}/test` endpoint for testing model connectivity in server mode * Session endpoints for interactive LLM chat; the old `fabro llm chat` CLI wrapper has since been removed * Verification API reorganized: `/verifications` split into `/verification/criteria` and `/verification/controls` * This release added `fabro llm prompt/chat --server-url`, but the `fabro llm` CLI namespace was later removed * `fabro model list --server ` now fetches the model list from the Fabro server * Added `--goal` arg to `fabro run start` to override the workflow goal from the command line * Turn and tool-call counts now display correctly in non-TTY mode * `[sandbox.env]` passes environment variables from workflow config to sandbox execution * `project_memory` attribute for prompt nodes provides persistent context across turns * `@file` references in DOT prompts are now inlined at prepare time, including untracked files * `dockerfile = { path = "..." }` in snapshot config lets you specify a custom Dockerfile location * Checkpoint exclude globs skip bulky artifacts from git checkpoint commits * Metadata branch pushed to origin after checkpoint in Remote (Daytona) mode * Stage logs now stream in real-time while CLI agents are working * Sub-workflow context diffs no longer leak child engine-internal keys into the parent run * Configurable git author identity for checkpoint commits via `[git.author]` in config * Auto-install agent CLIs (like `claude` or `codex`) in sandboxes at runtime when missing * Default OpenAI model upgraded from gpt-5.3 to gpt-5.4 * New models added to catalog: `gpt-5.3-codex-spark` and `claude-sonnet-4-6` * `apply_patch` parser now accepts bare `@@` hunk headers (compatibility with GPT-5.4 output) * imagegen tool for Gemini-based image generation * Fixed stall watchdog killing agents during long LLM reasoning turns — streaming events now correctly reset the timer * Fixed OpenAI reasoning SSE events being silently swallowed, causing stall watchdog to fire during extended reasoning phases * Fixed orphaned OpenAI reasoning items after context compaction causing "item provided without required following item" errors * Fixed push credentials for public repos in Daytona sandboxes * Fixed Daytona CLI installation with rootless Node.js install and better error messages # Lifecycle hooks, server mode, and auto-PR Source: https://docs.fabro.sh/changelog/2026-03-08 ## Lifecycle hooks You can now intercept tool calls at three points during agent execution: before a tool runs (`PreToolUse`), after it succeeds (`PostToolUse`), and after it fails (`PostToolUseFailure`). Each hook receives the tool name and arguments, and can modify, skip, or block the call. This makes it possible to enforce project conventions — like auto-formatting after file writes — or add guardrails without modifying agent prompts. ```toml title="fabro.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[hooks]] event = "PostToolUse" tool = "write" command = "cargo fmt" ``` ## Server mode for `fabro exec` `fabro exec` can now run as a long-lived server that proxies requests through a `/completions` endpoint. Instead of running a single agent session and exiting, server mode keeps the agent process alive and accepts Anthropic-style streaming requests over HTTP. This enables integrating Fabro agents into applications that speak the standard completions API. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro exec --server --port 8080 ``` ## Auto-PR on workflow completion Workflows can now automatically open a GitHub pull request when a run completes successfully. Fabro creates the PR from the agent's working branch with the run summary as the description. No extra scripting or post-run steps needed — just enable the option in your run configuration. **Breaking: CLI commands renamed.** `fabro run start` is now `fabro run`, and `fabro agent` is now `fabro exec`. The old command names no longer work. To migrate, update any scripts or aliases: 1. Replace `fabro run start` with `fabro run` 2. Replace `fabro agent` with `fabro exec` ## More * Renamed `/runs/{id}/compare` to `/runs/{id}/files` with standard paginated response * Added `POST /runs/{id}/pause` and `POST /runs/{id}/unpause` endpoints * Added `POST /completions` endpoint with Anthropic-style SSE streaming * Removed `List Projects` and `List Branches` endpoints * Added `--goal-file` flag to `fabro run` for reading goal from a file instead of inline text * MCP servers can now be configured in TOML via `cli.toml` instead of JSON * `verbose = true` in `cli.toml` defaults to verbose output without needing `-v` * `goal` is now optional in run config TOML — precedence is CLI `--goal` > TOML `goal` > DOT graph attribute * Configurable git author identity for checkpoint commits via `[git.author]` * Added `[log]` config section to `server.toml` and `cli.toml` for log level and format control * Script nodes now execute inside the configured sandbox instead of on the host — fixes issues where agents edited files in the sandbox but lint/test commands ran on the host * Artifact collection is now opt-in via `[artifacts]` config with custom include globs, eliminating \~30s file scans per stage when not needed * Workflow runs now fail immediately on git checkpoint commit failure instead of silently continuing * GitHub webhook listener via Tailscale funnel — auto-configures webhook URL on startup * `[sandbox.env]` support passes environment variables through to sandbox tool execution * Dockerfile path syntax (`dockerfile = { path = "..." }`) in snapshot config * `fabro validate` and the workflow validator now catch unresolved `@file` references before execution * Fixed `@file` references with `~` home directory and `..` parent directory paths * Compaction progress now visible in non-verbose `fabro run` output * Run ID printed at start of `fabro run` and on resume for cross-referencing * Git checkpoint commit skipped for start node to reduce noise * Daytona sandboxes now named `fabro-{run_id}` for easy correlation with workflow runs * Logs directory naming changed from `fabro-run-YYYYMMDD-HHMMSS` to `YYYYMMDD-{run_id}` * Truncated stage failure error display to show only the last line in `fabro run` output * `apply_patch` error responses now include file contents for better debugging of failed edits * v4a patch parser/applier rewritten to match canonical OpenAI codex spec * Accept bare `@@` hunk headers in v4a `apply_patch` parser * Added Sprites (Fly.io) VM sandbox provider * Fixed SSE streaming format mismatch in `/completions` endpoint * Exempted `/tmp/` paths from sandbox read-before-write guard * Fixed sandbox file-write validation incorrectly blocking valid operations * Fixed stall watchdog killing agent during long LLM reasoning turns * Added 5-minute stream read timeout with 5-second retry backoff for sandbox connections * Increased default stall watchdog timeout from 10 to 30 minutes * Fixed subagent and system prompt handling when prompt text is empty * Fixed parallel stage compaction bars interfering with each other # Project config, fabro cp, fabro pr create, and exe.dev maturation Source: https://docs.fabro.sh/changelog/2026-03-09 ## Project config (fabro.toml) Fabro now supports project-level configuration via `fabro.toml`. Previously, you had to pass full file paths to `fabro run` and configure each run individually. Now, you can define workflow aliases, pull request defaults, and retro settings at the project level. Fabro discovers `fabro.toml` by walking up from the current directory, similar to how Cargo or Node find their config files. ```toml title="fabro.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [workflows] implement = "fabro/workflows/implement.toml" hello = "fabro/workflows/hello.toml" [retro] enabled = false ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run implement # resolves via fabro.toml ``` ## exe.dev sandbox maturation The exe.dev sandbox provider now supports the full workflow lifecycle. Previously, exe.dev VMs could run agent stages but lacked git integration, cancellation, and checkpointing — making them unusable for real multi-stage workflows. Now, Fabro automatically clones the local git repo into exe.dev VMs, supports remote git checkpointing between stages, and wires up cancellation tokens so stopping a run cleanly tears down the VM. You can also specify a custom container image and connect via SSH: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [execution] environment = "exe" [execution.exe] image = "my-custom-image:latest" ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro ssh ``` ## `fabro cp` — copy files to and from sandboxes After a workflow run completes, you often need to retrieve files from the sandbox — build artifacts, generated reports, or logs. Previously, there was no easy way to get files out of a completed run's sandbox. Now, `fabro cp` lets you copy files in either direction using the run ID. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro cp :/path/in/sandbox ./local-dir # download fabro cp ./local-file :/path/in/sandbox # upload ``` ## `fabro pr create` — create PRs from completed runs Sometimes you want to review a run's changes before opening a PR, or a run finishes without auto-PR configured. `fabro pr create` lets you create a pull request after the fact for any completed workflow run, using the persisted manifest, conclusion, and diff from the run's log directory. PRs are created as drafts by default. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro pr create ``` ## `fabro system df` — disk usage reporting Run logs and worktrees accumulate over time. `fabro system df` shows a breakdown of disk usage across your run logs directory and any preserved worktrees, so you know when it's time to run `fabro system prune`. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro system df ``` ## More * PRs are now created as drafts by default; opt out with `draft = false` in `[pull_request]` config * Added `[sandbox.local] worktree_mode` config (`always`/`clean`/`dirty`/`never`) for controlling when git worktrees are created * Added `[pull_request]` config section in `cli.toml` so auto-PR works with `.fabro` files * Added version info (semver, git SHA, build date) to `fabro --version` * Run summary now shows Run ID, logs path, base commit, branch, and PR URL * Workflow run output now shows local time instead of UTC * Fatal errors now display with colored output and suggest related workflows * Git push is skipped when the local branch is already in sync with origin * Subagent status lines now indent deeper than tool calls in CLI progress for clearer hierarchy * Fixed deadlock in retro agent session when event channel fills up * Fixed PR body rendering bugs in auto-generated pull requests * Fixed 4 safety bugs in `fabro system prune` (symlink traversal, race conditions) * Fixed `@file` references failing when files aren't tracked by git * Fixed `fabro validate` not supporting TOML workflow config files * Fixed pipe deadlock in local sandbox when child process output exceeds 64KB * Fixed shell-quoting in sandbox cleanup commands # One-line installer, MCP servers, and new CLI commands Source: https://docs.fabro.sh/changelog/2026-03-10 ## One-line installer and guided setup wizard Getting Fabro installed and configured previously required multiple manual steps — downloading binaries, setting API keys, and configuring GitHub integration individually. Now, a single command installs Fabro and walks you through everything: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} curl -fsSL https://raw.githubusercontent.com/fabro-sh/fabro/main/install.sh | bash fabro install ``` The installer detects your platform, installs to `~/.fabro/bin`, and auto-configures your shell PATH. The `fabro install` wizard then guides you through LLM provider setup with immediate API key validation, OpenAI OAuth for Codex compatibility, and GitHub App creation via manifest flow — all with styled terminal output showing progress at each step. ## MCP servers in workflows Agents running inside sandboxes can now access MCP tools during workflow runs. A new `sandbox` transport type starts an MCP server inside the Daytona sandbox, waits for it to listen, and connects automatically — no host-side setup required. This means agents can use tools like Playwright for browser automation, or any other MCP server, directly from within their sandbox environment. ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [mcp_servers.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"] port = 3100 ``` **Historical note.** This release temporarily standardized on `~/.fabro/.env`, but later releases removed automatic dotenv loading in favor of server-owned secrets plus explicit process environment variables. ## Manage PRs with fabro pr `fabro pr list`, `fabro pr view`, `fabro pr merge`, and `fabro pr close` let you manage pull requests created by workflow runs without leaving the CLI. Pair with `fabro pr create` (introduced Mar 9) for the full lifecycle. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro pr list # list PRs from workflow runs fabro pr view # view PR details fabro pr merge # merge a workflow PR ``` ## Scaffold projects with fabro init `fabro init` generates a `fabro.toml` with sensible defaults for your project — workflow aliases, pull request config, and sandbox settings — so you can go from zero to `fabro run` in seconds. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro init ``` ## View run diffs with fabro diff `fabro diff` shows the code changes from any workflow run. It works on both completed and in-progress runs — for active runs, you get a live diff of the current state. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro diff ``` ## Preview sandbox URLs with fabro preview `fabro preview` returns preview URLs for ports exposed by Daytona sandboxes, so you can open a running web app or service directly from the CLI. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro preview ``` ## More * New signoff resource in the verification system — stamps of approval for (control, repository, commit SHA) tuples via `POST /verification/signoffs` * `fabro setup` renamed to `fabro install` for clarity * Added `fabro ssh` command for direct SSH access to Daytona sandboxes * `fabro doctor` now runs live service probes by default * `fabro doctor` now validates GitHub App configuration and private key * `fabro doctor` now hides unconfigured LLM providers for a cleaner output * `fabro doctor` sections reordered: Config, LLM, GitHub App, Cloud sandbox, Brave Search * `fabro run --preflight` now shows a Repository section, merged LLM check, and truncated output * Added `[pull_request]` support to project config (`fabro.toml`) for project-level PR defaults * Per-run data moved from `~/.fabro/logs/` to `~/.fabro/runs/`; daily CLI logs stay in `~/.fabro/logs/` * Per-run tracing logs now written to `cli.log` inside each run directory * Asset paths now display full `~/...` paths after workflow run output * Hidden `fabro ps`, `llm`, `exec`, and `parse` from CLI help output * PR body now includes Fabro Details section even when retro is skipped * Bumped version to 0.2.0 * Fixed Daytona sandbox clone failing when the worktree branch hadn't been pushed to the remote * Fixed "Git clean: false" showing incorrectly for remote sandboxes by checking both clean and pushed status * Fixed LLM provider resolution to check the model catalog before falling back to the default provider * Fixed `install.sh` prompt not showing when piped via stdin * Fixed race condition in git checkpoint push operations # SSH sandbox, skill installer, and fabro graph Source: https://docs.fabro.sh/changelog/2026-03-11 ## SSH sandbox provider Previously, running workflows on a remote machine meant provisioning a cloud VM through Daytona or exe.dev. Now you can point Fabro at any host you can already SSH into — a dev server, a home lab machine, or a NAS box — and run workflows there without any additional setup. The SSH sandbox connects using your existing SSH credentials, optionally clones the current repository, and runs all agent operations directly on the host. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [sandbox] provider = "ssh" [sandbox.ssh] destination = "user@myserver" working_directory = "/home/user/workspace" ``` Custom SSH config files and ProxyJump hosts are supported via the `config_file` field. ## fabro skill install Fabro now ships with a built-in `fabro-create-workflow` skill for AI assistants (Claude Code, Codex) that knows Fabro's DOT syntax, workflow attributes, node types, and run configuration. Run `fabro skill install` to extract it to your filesystem: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} # Install for the current project fabro skill install --for project # Install for all projects (user-level) fabro skill install --for user ``` After installation, AI assistants in your editor can use the skill when generating or editing Fabro workflows. ## Visualize workflows with fabro graph `fabro graph` renders your workflow DOT graphs as SVG, so you can see the full structure of your workflow at a glance. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro graph # SVG to stdout fabro graph -o flow.svg # SVG to file ``` ## More * `fabro run` and `fabro validate` now print the resolved workflow path before execution * `fabro doctor` now groups checks into **Required**, **Optional**, and **Server** sections and shows a spinner while checks run * Added warning during `fabro init` when no git remote is configured * Added `--shortstat` flag to `fabro diff` for compact one-line summary output * `fabro diff` now includes uncommitted changes for in-progress runs * Retro step now shows tool call progress while the retro agent works * Context compaction now produces higher-quality summaries: the summarization prompt is framed as a handoff document, and recent user messages are preserved through compaction so the agent retains the user's actual words * Retro agent limits increased to 20 tool rounds and a 3-minute timeout for complex runs * Shadow branch now stores the full execution trace per node — prompts, responses, status files, diffs, and retro data — making it a self-contained, git-native run record * Parallel fan-out branches render like subagent info lines in the progress UI * Tool call file paths now display relative to the sandbox working directory * Remote sandbox runs skip unnecessary local worktree creation * `progress.jsonl` events now preserve consistent field order (`ts`, `run_id`, `event`) * Added `last_file_touched` as a third fallback for status extraction # Simpler model stylesheets with provider auto-inference Source: https://docs.fabro.sh/changelog/2026-03-12 ## Simpler model stylesheets Model stylesheets no longer require you to specify the provider alongside the model. The `llm_model` and `llm_provider` properties have been renamed to `model` and `provider`, and the provider is now auto-inferred from the model catalog — so you can drop `provider` entirely in most cases. Before: ```css theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} * { llm_model: claude-opus-4-6; llm_provider: anthropic; } .gpt { llm_model: gpt-5.2; llm_provider: openai; } ``` After: ```css theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} * { model: claude-opus-4-6; } .gpt { model: gpt-5.2; } ``` **Breaking change.** `llm_model` and `llm_provider` stylesheet properties have been renamed to `model` and `provider`. Update your workflow stylesheets. ## More * SSH sandbox now supports `preview_url_base` for correct port preview URLs on remote hosts * Renamed `logs-directory` to `run-directory` in docs reference * Fixed demo workflow serialization # .fabro file extension, human gate improvements, and user workflows Source: https://docs.fabro.sh/changelog/2026-03-13 ## .fabro file extension Workflow files now use the `.fabro` extension instead of `.dot`. This gives workflows a distinct identity and avoids conflicts with Graphviz `.dot` files. Existing `.dot` files still work as a fallback — the engine checks for `graph.fabro` first, then falls back to `graph.dot`. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [workflow] graph = "workflow.fabro" ``` **Breaking change.** Workflow files have been renamed from `.dot` to `.fabro`. Existing `.dot` files continue to work as a fallback, but new projects should use `.fabro`. ## Smarter human gates Human-in-the-loop gates now show the previous stage's output before prompting, so you can see what the agent produced before deciding what to do next. Gates with only a freeform edge also skip the multiple-choice menu and go straight to a text input, making conversational loops like REPL workflows feel more natural. ## User-level workflows Personal workflows stored in `~/.fabro/workflows/` are now available across all your projects. Drop a workflow into your home directory once and run it from anywhere with `fabro run ` — no need to copy it into each repo. ## More * New `thread_id_requires_fidelity_full` lint rule warns when `thread_id` is set without `fidelity=full` * Added example REPL workflow for interactive agent loops * Branding now appears in generated commits and PR descriptions # fabro logs, background runs, and workflow scaffolding Source: https://docs.fabro.sh/changelog/2026-03-14 ## View run logs with fabro logs Previously, the only way to follow a workflow's progress was through the terminal that started it. The new `fabro logs` command lets you view event logs for any run — active or completed — from any terminal. With `--pretty`, agent conversations render with formatted messages and tool calls instead of raw JSON. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro logs my-workflow --pretty fabro logs -f abc123 -p ``` You can reference runs by name, ID prefix, or workflow slug. The `-f` flag follows live events as they happen. ## Background workflows with --detach You can now fork a workflow into a background process with `fabro run --detach`, then reconnect later with `fabro logs -f`. This is useful for long-running workflows where you don't want to keep a terminal open. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run my-workflow --detach fabro logs -f my-workflow ``` ## Rewind workflow runs You can now rewind a workflow run to an earlier checkpoint and resume from there. This is useful when a later stage goes off-track and you want to try again from a known-good point without restarting the entire workflow. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro rewind my-run plan@2 ``` Target a specific node by name, `node@visit` for a particular visit count, or `@ordinal` for a checkpoint index. ## More * Added `fabro workflow create ` subcommand to scaffold new workflows from a template * Added `fabro workflow list` command showing all available workflows grouped by source * Added `fabro inspect` command to display detailed JSON data for a workflow run * Added project-level run defaults in `fabro.toml` (model, environment, sandbox image, etc.) * Added `~/.fabro/` fallback for `@` file references * Default Daytona sandbox now uses the `daytona-medium` snapshot with standard dev tools pre-installed * Run resolution now matches workflow slugs and display names, not just run IDs * Routing events now include context about why an edge was selected * Fixed `fabro logs --pretty` wrapping past terminal edge on long assistant messages * Fixed empty `run_id` on sandbox events in `progress.jsonl` * Fixed credential-embedded GitHub URLs not being parsed correctly * Fixed logo SVG viewBox clipping the right edge of the O * Fixed missing `git_commit_sha` in run branch commit messages # Run forking, self-update, and Docker-style fabro ps Source: https://docs.fabro.sh/changelog/2026-03-15 ## Non-destructive run forking You can now branch a new run from any checkpoint in an existing run with `fabro fork`. Unlike `fabro rewind`, which rewinds the original run in place, `fabro fork` creates an independent copy — the original run stays intact. This is useful for exploring alternate paths from a checkpoint without losing the work that came after it. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro fork plan # fork from the "plan" node fabro fork review@2 # fork from the 2nd visit of "review" fabro fork @3 # fork from checkpoint index 3 ``` ## Self-update with fabro upgrade Fabro can now update itself. For self-managed installs, `fabro upgrade` downloads the latest release from GitHub, verifies its SHA256 checksum, and atomically replaces the binary. Downgrade protection prevents accidentally installing an older version (override with `--force`). On Homebrew installs, `fabro upgrade` leaves the Homebrew-managed binary alone and prints the matching `brew upgrade fabro` or `brew upgrade fabro-nightly` command instead. A daily background check notifies you when a new version is available — disable it with `upgrade_check = false` in `~/.fabro/cli.toml` or `--no-upgrade-check`. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro upgrade # upgrade to latest fabro upgrade --dry-run # check without installing ``` ## Docker-style process listing `fabro ps` has been rebuilt to behave like `docker ps`. It now shows a table with run ID, status, workflow name, goal, and timing — making it easy to see what's running at a glance. Run status is tracked via a proper state machine, so status transitions are reliable and `fabro ps` always reflects the current state. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro ps # active runs fabro ps -a # all runs including completed ``` ## More * Added `fabro artifact list` to view run artifacts and `fabro artifact cp` to copy them locally with optional `--tree` directory structure * Added `fabro rm` command to remove runs by ID with sandbox cleanup * Added `--direction` (`-d`) flag to `fabro graph` for overriding layout direction (`lr` or `tb`) * Added `-p` short alias for `--pretty` in `fabro logs` * Added progress spinner during `run --preflight` * Added `auto_merge = true` in `[pull_request]` config to enable GitHub auto-merge on created PRs, with configurable `merge_strategy` (squash/merge/rebase) * `fabro init` now generates `[pull_request]` with `enabled = true` and `draft = true` by default * Added `selection="random"` node attribute for weighted-random edge selection in workflow graphs * GitHub App installation tokens are now automatically injected into sandboxes as `GITHUB_TOKEN` * Workflow PRs now open as ready-for-review by default (control with `[pull_request] draft = false`) * Metadata branch renamed from `refs/fabro/{run_id}` to `fabro/meta/{run_id}` for cleaner ref namespace * Added granular git checkpoint events and retro lifecycle events (`RetroStarted`, `RetroCompleted`, `RetroFailed`) * `goal` field now included in `WorkflowRunStarted` event and rendered in `fabro logs --pretty` * Run completion events now include final status and usage totals * Command output in stage preambles now truncated to the last 25-50 lines, reducing token waste from verbose build logs * Artifact collection now enforces a 100-file limit and excludes `.venv`, `venv`, `.cache`, `.tox`, `.pytest_cache`, `.mypy_cache`, and `dist` directories * Unified dry-run behavior across all handler types — wait and human-in-the-loop nodes now properly simulate without side effects * Fixed sub-agent file writes not being tracked in the API backend, causing downstream stages to receive incomplete file lists * Fixed `--dry-run` executing command/script nodes instead of simulating them * Fixed `--dry-run` pushing branches to remote * Fixed `--goal-file` not expanding `~` to the home directory * Fixed race condition between `fabro run --detach` and `fabro logs -f` * Fixed dry-run runs cluttering `fabro ps -a` output (now uses temp directory) # Auto-merge for PRs, three new lifecycle hooks, and styled workflow graphs Source: https://docs.fabro.sh/changelog/2026-03-16 ## Auto-merge for workflow-created pull requests Workflows that create pull requests can now automatically merge them once CI passes. Set `auto_merge = true` in your `[pull_request]` config and Fabro enables GitHub's auto-merge via the GraphQL API. You can control the merge method with `merge_strategy` — squash (default), merge, or rebase. If the target repo doesn't have auto-merge enabled in its settings, Fabro warns but doesn't fail the run. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [pull_request] auto_merge = true merge_strategy = "squash" ``` ## Three new lifecycle hooks Three new hook events are now available — `stage_retrying`, `parallel_start`, and `parallel_complete`. `stage_retrying` fires immediately before backoff sleep on both error-retry and explicit-retry paths, and the parallel hooks fire when parallel node groups start and finish execution. ## More * Added `-q`/`--quiet` flag to `fabro ps`, mirroring `docker ps -q` — outputs only run IDs, one per line, composable with `-a` (e.g., `fabro ps -qa`) * Agent sessions now retry up to 3 times when an LLM provider drops a stream mid-response, preserving conversation history and replaying partial output * Workflow graph SVGs from `fabro graph` and the API now render with styled defaults (teal nodes, clean typography) and automatic dark mode support * Crash reporting via Sentry — CLI panics are now captured and uploaded in a detached subprocess so they survive parent exit * Added `install.md` for AI-agent-driven installation (Claude Code, Codex, etc.), with platform detection, binary download, and PATH setup * Installer now skips shell config modification in non-interactive mode, printing the manual PATH export instead * Fixed stage durations always showing `0s` in PR descriptions due to a key mismatch between node labels and node IDs * Fixed OpenAI OAuth callback showing a deserialization error when the provider returns an error response (e.g. `invalid_scope`) — now displays a styled error page and propagates the error to the CLI # OpenAI OAuth support and GPT-5.4 Mini Source: https://docs.fabro.sh/changelog/2026-03-17 ## Use Fabro with your OpenAI subscription Fabro now supports OpenAI OAuth — if you have an OpenAI Codex or ChatGPT subscription, you can sign in with your OpenAI account and use Fabro with your existing plan. Requests are routed through OpenAI's Codex backend API with streaming and provider-specific optimizations handled automatically. ## GPT-5.4 Mini support `gpt-5.4-mini` is now available in the model catalog. Use it with `fabro model set gpt-5.4-mini` or set it per-workflow in your `workflow.toml`. ## More * Added `fabro docs` subcommand to open the documentation website in your browser * Added `fabro discord` subcommand to open the Discord community invite in your browser * DOT workflow syntax is now highlighted in the documentation with color-coded keywords, strings, and attributes # Secret management and repo commands Source: https://docs.fabro.sh/changelog/2026-03-18 ## Secret management from the CLI Managing API keys and credentials previously meant manually editing `~/.fabro/.env`. This release introduced `fabro secret` commands; later releases made secrets server-owned and write-only. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set ANTHROPIC_API_KEY sk-ant-... fabro secret list fabro secret rm ANTHROPIC_API_KEY ``` ## Repository lifecycle with `fabro repo` Project setup commands now live under `fabro repo`. You can initialize a project with `fabro repo init` and, when you want to remove Fabro from a project, reverse it with `fabro repo deinit` — which removes `fabro.toml` and the `fabro/` directory. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro repo init fabro repo deinit ``` The old `fabro init` still works but prints a deprecation warning. ## More * Moved `fabro init` to `fabro repo init` with a backwards-compatible deprecation shim * Added `--show-values` flag to `fabro secret list` to reveal secret values Later releases removed this flag when secrets became write-only # Provider login, auto-detect provider, and fabro wait Source: https://docs.fabro.sh/changelog/2026-03-19 ## `fabro provider login` Re-authenticating with LLM providers previously required re-running the full `fabro install` wizard. You can now re-authenticate with any provider independently using `fabro provider login`. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider login --provider openai ``` For OpenAI, this launches the browser-based OAuth PKCE flow with an automatic fallback to manual API key entry. All other providers prompt for an API key with validation. Later releases moved these credentials into the server-owned secret store. ## Auto-detect default LLM provider The CLI now checks which providers have API keys configured and automatically selects the best available one, using precedence order Anthropic > OpenAI > Gemini. Previously, running commands without an explicit `--provider` flag always defaulted to Anthropic, which caused errors for users who only had OpenAI or Gemini keys configured. ## `fabro wait` A new `fabro wait` subcommand blocks until a workflow run reaches a terminal state and exits with a code reflecting the outcome — analogous to `docker wait`. Accepts a run ID prefix or workflow name, with an optional `--timeout` flag and `--json` output mode for scripting. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro wait abc123 --timeout 300 ``` ## More * Default Anthropic model changed from `claude-opus-4-6` to `claude-sonnet-4-6` * Removed OpenSSL runtime dependency — the CLI binary no longer dynamically links against `libssl` or `libcrypto` * Added `--web-url` flag to `fabro install` for specifying the web UI base URL for OAuth callbacks * `fabro ps` now strips markdown headings and "Plan:" prefix from the GOAL column for cleaner output * Node-level `model` attributes are now validated against the model catalog at parse time, with missing aliases surfaced as warnings * `reasoning_effort` is now mapped to Anthropic's `output_config.effort` parameter * Cloud sandbox pre-run checks now properly verify git sync status and warn about uncommitted changes before remote runs * `fabro doctor` no longer shows false connectivity warnings when using the ChatGPT/Codex backend * `repo init` detects GitHub App visibility mismatches and warns when cross-owner installs require the app to be public * Fixed agent sessions ignoring node-level `model` and `provider` attributes set in stylesheets * Fixed `fabro upgrade` failing with "No such file or directory" * Fixed OAuth callback URLs missing from CLI-generated GitHub App manifests * Fixed OpenAI multi-turn conversations failing because `store: false` was incorrectly set for all Responses API requests # Composable run lifecycle: create, start, attach Source: https://docs.fabro.sh/changelog/2026-03-20 ## `fabro create` / `start` / `attach` `fabro run` has been decomposed into three composable primitives following the Docker-style lifecycle model. `fabro create` allocates the run directory, `fabro start` spawns the detached engine process, and `fabro attach` tails progress with live rendering and handles human-in-the-loop interactions. `fabro run` remains the all-in-one command — it now composes these three steps internally, and `--detach` simply skips the attach step. This enables new automation patterns: create a run, start it in the background, and attach later — or script CI pipelines that create and start without ever attaching. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro create my-workflow --goal "Implement feature X" fabro start fabro attach ``` ## More * Added Linux ARM64 (`aarch64-unknown-linux-gnu`) release target * `fabro logs` and `fabro attach` now show tool details for `spawn_agent`, `use_skill`, `list_dir`, and all remaining agent tools * Subagent IDs now use short hex strings instead of UUIDs for readability * `StageStarted` events are now emitted on retry attempts for better observability * Added bundled `implement-issue` workflow for implementing GitHub issues via `fabro run implement-issue` * Added bundled `gh-triage` workflow for checking open PRs and issues via `gh` CLI * Model aliases in graph nodes are now resolved to canonical IDs at parse time * Updated `tar` crate to 0.4.45 (security fix) # Anthropic fast mode and deep model testing Source: https://docs.fabro.sh/changelog/2026-03-21 ## Anthropic fast mode You can now enable Anthropic's fast mode for up to 2.5x faster output from Claude Opus 4.6 by setting `speed: fast` in your workflow stylesheet. This is useful for latency-sensitive stages where throughput matters more than cost. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} graph { graph [stylesheet="speed=fast"] } ``` ## `fabro model test --deep` Model validation now supports a `--deep` flag that runs multi-turn tool-use conversations to verify that a provider handles the full agent loop correctly — not just single-turn completions. Tests run concurrently with a progress bar. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model test --deep ``` ## More * Fixed workflow TOML config being lost when running in detach mode * Fixed edge `thread_id` precedence to match documented fidelity behavior * Fixed Sonnet 4.5 `reasoning_effort` parameter error by converting to the thinking API format * Fixed OpenAI reasoning round-trip losing encrypted content by setting `store: false` * Fixed OpenAI provider incorrectly omitting `store: true` from requests * Suppressed stderr output noise in detach and create modes # fabro resume subcommand Source: https://docs.fabro.sh/changelog/2026-03-22 ## `fabro resume` Resuming workflows previously required constructing a `fabro run --run-branch fabro/run/` invocation manually. The new `fabro resume` subcommand accepts a run ID or prefix directly, matching the ergonomics of `fabro rewind` and `fabro fork`. Checkpoint-file-based resumption is also supported. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro resume abc123 fabro resume --checkpoint path/to/checkpoint.json --workflow workflow.fabro ``` The unified foreground run path now uses the `create` → `start` → `attach` lifecycle internally, ensuring consistent behavior whether you run, resume, or rewind a workflow. # Unlimited tool rounds and workflow syntax improvements Source: https://docs.fabro.sh/changelog/2026-03-23 ## Unlimited agent tool rounds Agent stages no longer have an artificial cap on tool rounds per input or subagent turns, so agents run until they complete their work or are interrupted. ## More * Condition expressions now support quoted strings: `edge [label="status == \"ready\""]` * `"end"` and `"End"` are now recognized as exit node identifiers alongside `"END"` * Edge selection now falls back to the default edge when no condition matches, instead of erroring * `default_max_retries` attribute renamed to `max_retries` on graph-level stylesheets * Removed `error_policy` and `k_of_n`/`quorum` attributes from parallel nodes — parallel stages now always wait for all branches * Subagents are now automatically cleaned up when a session closes, preventing orphaned background processes * All 5xx HTTP status codes from LLM providers are now classified as retryable server errors * Request timeouts from LLM providers are now treated as non-retryable by default to avoid redundant retries on slow requests * Fixed workflow slug lookup failing for split and resumed runs * Fixed split run metadata being lost across engine restarts * Fixed split run lifecycle lookup returning stale state # Pull request pipeline stage and resume reliability Source: https://docs.fabro.sh/changelog/2026-03-26 ## Pull request pipeline stage Workflows can now automatically create pull requests as part of the run pipeline. When a workflow finishes with code changes, the new `pull_request` pipeline stage creates a PR on the target repository with the run's diff, title, and description — no manual `gh pr create` step needed. This integrates with the existing GitHub App authentication and respects the workflow's git configuration. ## More * `fabro resume` now rejects already-succeeded runs with a clear error instead of silently restarting them * Resume now validates the checkpoint file before cleaning up state, preventing partial cleanup on corrupt checkpoints * `fabro attach` now detects early init failures and rolls back partial worktree state automatically * Added restore operation that rebuilds runtime state from the checkpoint when resuming interrupted runs * Fixed resume creating unnecessary worktrees and failing to keep the sleep inhibitor alive * Fixed git checkpoint options not being threaded through to the resume path * Fixed verification regressions introduced by the runtime initialization refactor # fabro settings, preflight subcommand, and storage-dir Source: https://docs.fabro.sh/changelog/2026-03-27 ## `fabro settings` A new `fabro settings` command displays the fully resolved configuration for a workflow, showing how CLI flags, environment variables, project config, and workflow defaults are layered together. This makes it easy to debug configuration issues without guessing which layer is winning. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro settings my-workflow ``` ## `fabro preflight` Preflight validation has been extracted from `fabro run --preflight` into its own top-level command. This makes it easier to validate a workflow before running it, especially in CI pipelines where you want a separate validation step. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro preflight my-workflow ``` **`--run-dir` renamed to `--storage-dir`.** The `--run-dir` CLI flag has been renamed to `--storage-dir` to better reflect its purpose as the base storage directory. Runs are now created under `/runs/` automatically. The `data_dir` config key is also renamed to `storage_dir` (a serde alias preserves backward compatibility for config files). ## More * Added `goal_file` and `labels` fields to workflow configuration for specifying goals from files and tagging runs with metadata # File-based workflow imports Source: https://docs.fabro.sh/changelog/2026-03-28 ## Workflow imports Workflows can now import subgraphs from other `.fabro` or `.dot` files, enabling reuse of common patterns across workflows. Imported graphs are merged into the parent workflow at parse time, with node name prefixing to avoid collisions. This replaces the previous `graph_merge` approach with a more flexible file-based import system. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} graph { graph [import="shared/review-loop.fabro"] } ``` ## More * Detached engine processes now have descriptive OS-level process titles (visible in `ps` and activity monitors) * Fixed a panic when checking if a process is alive on systems where PID values exceed `i32::MAX` # Dump export command Source: https://docs.fabro.sh/changelog/2026-03-29 ## `fabro dump` A new `fabro dump` command exports the contents of the run store to a human-readable format for debugging and inspection. This is useful for diagnosing issues with run state, verifying data integrity after migrations, or extracting run data for external analysis. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro dump ``` ## More * Fixed store migration gaps affecting retro agent data, finalize commits, engine failure records, and hydration tolerance # CLI restructuring, shell completions, and API versioning Source: https://docs.fabro.sh/changelog/2026-03-30 ## CLI command restructuring The CLI command tree has been reorganized for consistency and discoverability. Sandbox-related commands (`cp`, `ssh`, `preview`) now live under `fabro sandbox`, the server command is now `fabro server start`, settings are shown with `fabro settings`, and the deprecated `fabro init` command has been removed. **Breaking CLI changes.** Several commands have moved or been renamed: * `fabro serve` → `fabro server start` * `fabro config show` → `fabro settings` * `fabro cp` → `fabro sandbox cp` * `fabro ssh` → `fabro sandbox ssh` * `fabro preview` → `fabro sandbox preview` * `fabro init` has been removed ## Shell completions You can now generate shell completions for Bash, Zsh, Fish, Elvish, and PowerShell using the new `fabro completion` subcommand. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro completion zsh > ~/.zfunc/_fabro ``` **API routes versioned.** All REST API routes are now prefixed with `/api/v1`. Update any direct API integrations accordingly. ## More * Added `fabro completion` subcommand for Bash, Zsh, Fish, Elvish, and PowerShell * Added help text snapshots for all subcommands * Typed `RunId` used consistently across the codebase * Fixed cli-table ignoring `NO_COLOR` environment variable # Global JSON output mode Source: https://docs.fabro.sh/changelog/2026-03-31 ## Global JSON output mode Every CLI command now supports a `--json` flag for machine-readable output. Previously, only a few commands had JSON variants. Now you can pipe any Fabro command into `jq` or feed it directly into scripts and CI pipelines. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro runs list --json | jq '.[].id' fabro system df --json fabro preflight --json ``` ## More * Stale git worktrees are now pruned automatically before branch creation in worktree sandboxes * Fixed `logs --follow` timing out on long-running runs due to slow manifest polling * Fixed rewind snapshot filtering when using short commit SHAs # Server-backed web dashboard and event-sourced run state Source: https://docs.fabro.sh/changelog/2026-04-01 ## Server-backed web dashboard The Fabro web app is now a single-page application served directly by the Fabro server. Previously, the frontend required a separate Node.js server with server-side rendering. Now `fabro server start` serves both the API and the web dashboard from the same origin, simplifying deployment and eliminating the need for a separate frontend process. ## Event-sourced run state Run state is now fully derived from events rather than written to disk as independent files. This means runs are more reliable, resumable, and inspectable — every piece of run metadata (status, provider info, checkpoints, PR data) is reconstructed from the event stream. The legacy SQLite metadata store and file-based projections have been retired. ## More * `unsafe_code` is now denied workspace-wide for security hardening * Dry-run mode no longer activates automatically when LLM providers are missing — runs now fail explicitly, making misconfiguration easier to diagnose * Fixed `attach` hanging on Linux when the workflow engine exits before the attach process starts * Fixed login route collision after the SPA cutover # Background server daemon Source: https://docs.fabro.sh/changelog/2026-04-02 ## Background server daemon The Fabro server now runs as a managed background daemon instead of requiring a dedicated terminal tab. `fabro server start` launches the daemon in the background with file-lock-based lifecycle management, and `fabro server stop` shuts it down gracefully. The daemon listens on a Unix socket by default for fast local communication. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server start # launch background daemon fabro server status # check running state, PID, uptime fabro server status --json # machine-readable status fabro server stop # graceful shutdown fabro server start --foreground # old blocking behavior ``` The `--bind` flag replaces the previous `--host`/`--port` flags and supports both Unix sockets and TCP addresses. ## More * Workflow state is now fully derivable from events, making run replay and debugging more reliable # Run lifecycle API refinement Source: https://docs.fabro.sh/changelog/2026-04-04 ## Separate run create and start The `POST /api/v1/runs` endpoint now creates a run in `submitted` status without immediately making it runnable. A new `POST /api/v1/runs/{id}/start` endpoint transitions the run to `runnable` and notifies the scheduler. This two-step lifecycle gives API consumers more control — you can inspect or modify a run's configuration between creation and execution. ## More * `POST /api/v1/runs` now returns a run in `submitted` status * New `POST /api/v1/runs/{id}/start` endpoint makes a submitted run runnable for execution * Removed unused `GET /api/v1/runs/{id}/context` endpoint * Run artifacts are now stored as blobs in the run store instead of on disk # System management API and log rotation Source: https://docs.fabro.sh/changelog/2026-04-06 ## System management API New server-backed system commands give you visibility into Fabro's operational state through both the CLI and the REST API. `fabro system info` reports server version, uptime, and run counts. `fabro system events` streams the global event log. `fabro system df` and `fabro system prune` now route through the server for consistent behavior across local and remote setups. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro system info # server version, uptime, run counts fabro system info --json # machine-readable fabro system events # global event stream fabro system prune # clean up completed runs ``` ## Automatic log rotation Server logs now rotate daily and are automatically cleaned up after 7 days. Previously, log files could grow unbounded on long-running servers, requiring manual cleanup. ## More * New `GET /api/v1/system/info` endpoint returns server version, uptime, and run statistics * New `GET /api/v1/system/events` endpoint streams the global event log via SSE * New `GET /api/v1/system/df` endpoint returns disk usage by run * New `POST /api/v1/system/prune` endpoint removes completed run data * Removed deprecated `GET /runs/{id}/verification`, sessions, retros, and steer endpoints * Removed `fabro skill install` command (skills are now managed through workflow definitions) * Settings and model commands now route through the server daemon for consistency # Worker lifecycle hardening and object-backed artifacts Source: https://docs.fabro.sh/changelog/2026-04-07 ## Reliable worker lifecycle Run workers now operate under tighter server supervision. The server tracks worker health, properly terminates active workers during force removal, and propagates cancellation into running command stages. Resumed runs no longer attempt to clean up stale workers from a previous session, preventing spurious errors after restarts. ## Object-backed artifact storage Artifacts and offloaded agent context are now persisted as content-addressed objects in the global blob store instead of the local scratch directory. This makes artifacts portable across sessions and available through the server API regardless of which machine originally produced them. ## More * Removed deprecated workflows and steer endpoints * `fabro run attach` now streams events over SSE for more reliable output * Runs now record creation provenance (CLI, API, or scheduled trigger) * Fixed `fabro run attach` not following paginated event streams * Fixed terminal runs blocking deletion during the grace period * Fixed cancelled workers not stopping in-progress command stages # Embedded web UI and fabro uninstall Source: https://docs.fabro.sh/changelog/2026-04-08 ## Embedded web UI The web dashboard is now bundled directly into the Fabro server binary. When you run `fabro server start`, the UI is available immediately at the server's address with no separate build step or dev server required. The web UI is optional and can be disabled in server settings. A demo mode toggle lets you explore the interface with sample data without connecting to a live workspace. ## `fabro uninstall` A new `fabro uninstall` command cleanly removes Fabro's local state, server data, and configuration files. Previously, uninstalling required manually tracking down scattered directories. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro uninstall # interactive confirmation fabro uninstall --force # skip confirmation ``` ## More * Added host-only TCP bind support for the server (e.g., `127.0.0.1:8080` without TLS) * Fixed GitHub App setup flow failing on nullable webhook secrets, duplicate POST requests, and incorrect port detection * Fixed session cookie decryption errors on server restart * Fixed setup commands continuing to run after the server received a shutdown signal * Fixed dark theme not being selected by default for new users # Settings v2 and events schema v2 Source: https://docs.fabro.sh/changelog/2026-04-09 ## Settings v2 The TOML configuration format has been redesigned for clarity and consistency. Settings are now organized into logical sections (`[run]`, `[server]`, `[cli]`, `[hooks]`, `[sandbox]`, `[mcp]`) with typed fields throughout. Run goals can now reference external files using a tagged union syntax, so you can keep large goal prompts separate from your workflow config. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run] goal = { file = "goals/deploy-review.md" } model = "claude-sonnet-4-6" [server] host = "127.0.0.1" port = 8080 ``` Comments in your `settings.toml` are now preserved when Fabro edits the file during setup and registration. ## Events schema v2 The event wire format has been flattened and enriched. Each event envelope now carries stage scope (visit count, parallel group and branch IDs) and actor information directly, rather than requiring consumers to reconstruct context from surrounding events. The TypeScript API client has been regenerated to match. ## More * Server auth resolver now fails closed instead of panicking on unexpected states * Config parse errors now include the file path for easier debugging # GitHub App owner selection Source: https://docs.fabro.sh/changelog/2026-04-10 ## GitHub App owner selection When running `fabro install`, you can now choose whether to install the GitHub App under your personal account or an organization. Previously, the app was always installed under the authenticated user's personal account, requiring manual reconfiguration for org-level access. ## More * Fixed an infinite loop when a goal-gate retry targeted a terminal node * Fixed detached workers not exiting cleanly after post-run shutdown # Unified template syntax Source: https://docs.fabro.sh/changelog/2026-04-11 ## Unified template syntax Workflow definitions and configuration files now share the same template syntax for variable interpolation. Previously, workflow DOT files and TOML config files used slightly different templating rules, which made it easy to use the wrong syntax in the wrong context. The unified syntax works everywhere Fabro processes templates. ## More * Project state files moved from the workspace root into `.fabro/` for a cleaner directory layout * Config parse errors now show the file path where the error occurred # Provider credential auth and resolved settings Source: https://docs.fabro.sh/changelog/2026-04-12 ## Provider credential auth Managing LLM provider credentials used to mean setting environment variables or manually configuring API keys for each session. Now, `fabro provider login` walks you through a typed authentication flow — OAuth device auth for OpenAI Codex, API key entry for other providers — and stores credentials securely in the vault. Workflows automatically pick up stored credentials at runtime, so you configure once and forget. ## Resolved settings view `fabro settings` now renders a compact, fully-resolved view by default, showing the effective value for every setting after all layers (builtins, project, CLI overrides) are merged. Previously you had to mentally merge multiple config sources to understand what value was active. Builtin defaults are now materialized as a base layer, so `fabro settings` always shows a complete picture even when you haven't configured anything explicitly. ## More * Added `--force` option to `fabro pr create` to skip confirmation prompts * `fabro doctor` now checks that the storage directory exists and is writable * Settings API endpoint now returns a `resolved` view alongside the raw settings # Scripted install and local server auth Source: https://docs.fabro.sh/changelog/2026-04-13 ## Scripted non-interactive install `fabro install` now supports fully non-interactive setup, including GitHub App creation. You can pass all required values as flags — app ID, private key, webhook secret — without any browser interaction. The installer generates Ed25519 keys natively in Rust instead of shelling out to OpenSSL, and the browser-based GitHub App bootstrap flow has been removed in favor of the CLI-only path. This makes it possible to script Fabro deployments in CI or headless environments. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro install --github-app-id 12345 --github-private-key-file key.pem --github-webhook-secret s3cret ``` ## Dev-token local server auth Local CLI-managed servers previously started with no authentication. Now, `fabro server start` provisions a dev token and passes it to both the CLI and web clients automatically. This means local servers are authenticated by default — the web UI redirects through a token-based login flow, and CLI commands authenticate transparently. The dev token is scoped to the local session and stored alongside other server state. **`GITHUB_CLI_TOKEN` renamed to `GITHUB_TOKEN`.** The environment variable and vault secret for GitHub token auth have been renamed. The `gh_cli` strategy is now called `token`, reflecting that at runtime it's just a stored token — not tied to the `gh` CLI. To migrate: replace `GITHUB_CLI_TOKEN` with `GITHUB_TOKEN` in your environment or vault configuration. Current server runtimes read `GITHUB_TOKEN` from the vault. ## More * Fixed OpenAI Codex OAuth device auth to align with current endpoint response shape * Fixed setup registration and OAuth state validation to prevent half-written configurations * Fixed vault-backed credentials not loading during server-spawned workflow execution # Vendored Graphviz, version command, and install improvements Source: https://docs.fabro.sh/changelog/2026-04-14 ## Vendored Graphviz Fabro no longer requires a system-installed `dot` binary to render workflow graphs. The Graphviz C library is now compiled directly into the Fabro binary, so graph rendering works out of the box on every platform. Rendering runs in an isolated subprocess to contain any native library faults, so a malformed graph can't crash the main process. ## `fabro version` command You can now check your installed version and build metadata with a dedicated command. The output includes the git SHA and build date, making it easy to identify exactly which build you're running — especially useful when reporting issues or verifying upgrades. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro version ``` ## More * Added `ls` alias for `list` subcommands (e.g., `fabro runs ls`, `fabro models ls`) * Added `fabro install --github-only` to reconfigure just the GitHub App without a full reinstall * Install now prompts for a GitHub token when the `gh` CLI is unavailable * Doctor now shows `fabro secret set` in remediation output when a secret is missing * Doctor check labels improved for Sandbox and Brave Search readability * Doctor and install output now use `~` for home directory paths * Install runs doctor in non-verbose mode after completing setup * Install output reordered for clearer step progression * Enabled Zstd compression for the embedded storage engine, reducing disk usage * Fixed server not restarting automatically after `fabro install` * Fixed `server.listen` being ignored when `bind` was omitted from settings # Live Web UI with real-time stage tracking Source: https://docs.fabro.sh/changelog/2026-04-15 ## Live Web UI The Web UI now connects to real server data instead of showing static demos. The runs board updates in real time via server-sent events — new runs appear, statuses change, and timers tick without refreshing the page. Each run's detail page has a stages tab with a live sidebar that shows the currently executing stage, elapsed time, and stage history as events stream in. ## Workflow graph visualization Run detail pages now render an interactive workflow graph with controls for switching between top-down and left-right layouts. Nodes are color-coded by status — running stages pulse, failed nodes turn red, and completed nodes are marked. The graph updates live as the workflow progresses, so you can watch execution flow through the graph in real time. ## Markdown rendering in stage output Stage detail pages now render markdown in system prompts and assistant response blocks, making agent output much easier to read. Command and script stage output is also rendered on the stage detail page, so you can inspect what each stage produced without switching to the CLI. ## More * `fabro secret set` now accepts the secret value via stdin or interactive prompt instead of requiring it as an argument * Install output now shows the active auth method and dev token * Restored Run ID display in `fabro run` output header * Cloud sandboxes (Daytona) are now preserved after runs complete instead of being torn down immediately * Doctor output now shows the server location URL * Added `disk_cache` setting for S3-backed storage deployments * Fixed workflows not using configured providers for default models * Fixed Daytona API key not being passed through to the SDK client from vault storage * Fixed Web UI logout producing a React Router 500 error * Fixed Web UI redirecting to `/start` instead of `/runs` after login * Fixed run goals displaying raw markdown heading markers in the Web UI * Fixed stage failed events missing duration data in the sidebar display * Fixed Web UI not handling 404 and 501 API responses gracefully # Server restart, runs page empty state, and prerelease upgrades Source: https://docs.fabro.sh/changelog/2026-04-16 ## `fabro server restart` You can now restart the Fabro server without manually stopping and starting it. This is useful after configuration changes or when the server needs a fresh start. The command stops the running server and starts a new one in a single step. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server restart ``` ## Getting-started empty state on the runs page The runs page now greets new users with a getting-started empty state instead of a blank table. When there are zero runs, you'll see quick-start commands and links to docs and Discord — so a fresh install points you at the next step instead of leaving you to guess. ## More * `fabro server start` now displays the Web UI URL and active auth methods in its output * Added `--watch-web` flag to `fabro server start` for auto-rebuilding web assets during development (debug builds only) * Added `--prerelease` flag to `fabro upgrade` that widens the candidate set to include pre-release tags and picks the highest semver across stable and prereleases * Fixed device code login output missing blank line spacing, improving readability * Fixed Web UI login page not showing both GitHub and dev-token options when both auth methods are enabled * Fixed install generating inconsistent web URLs for GitHub App callback and settings * Fixed the header logo linking to `/start` instead of `/runs` in non-demo mode # Curated landing output and Homebrew-aware upgrades Source: https://docs.fabro.sh/changelog/2026-04-17 ## Curated landing output for bare `fabro` Running `fabro` with no subcommand used to dump the full clap `--help` reference, which buried the commands most users actually need. The bare command now prints a short, colorized guide organized by lifecycle stage — Set up, Author workflows, Run workflows, Inspect runs — with a Discord callout and a pointer to `fabro help` for the full reference. `fabro --help` and `fabro help` still render the comprehensive command listing unchanged. ## Homebrew-aware upgrade flow If you installed Fabro via Homebrew, the upgrade path now knows. The background upgrade nag fetches the Fabro tap's `versions.json` instead of GitHub's latest release, so notifications stay in sync with what `brew update` can actually install. Running `fabro upgrade` on a Homebrew-managed binary refuses to overwrite it and prints the correct `brew upgrade` command instead. ``` $ fabro upgrade Fabro was installed via Homebrew. To upgrade, run: brew upgrade fabro ``` The cache distinguishes `fabro`, `fabro-nightly`, and tarball installs, so switching between them no longer leaves stale version data behind. ## More * Added `fabro docs` to the landing output's Set up section * `fabro upgrade --dry-run` on Homebrew installs prints the `brew upgrade` command and exits 0 * Published multi-arch (linux/amd64, linux/arm64) server images to `ghcr.io/fabro-sh/fabro` with `:latest`, `:nightly`, and per-version tags * Added an automated nightly release pipeline that publishes a fresh pre-release at 09:00 UTC each day * The Fabro Homebrew tap now publishes a `fabro-beta` formula on pre-release tags alongside the stable `fabro` formula * Fixed `fabro uninstall` leaving behind `~/.fabro/tmp/` in release builds when background telemetry flushing would re-create the directory after removal # Homebrew install, deployment guides, and hardened hosting Source: https://docs.fabro.sh/changelog/2026-04-18 **GitHub webhook exposure is now explicit.** Fabro no longer starts Tailscale Funnel or rewrites the GitHub App webhook URL just because `[server.integrations.github.webhooks]` exists. To migrate: 1. Set `strategy = "server_url"` for deployments with a stable HTTPS URL. 2. Set `strategy = "tailscale_funnel"` if you intentionally want Fabro to run Tailscale Funnel on startup. ## More ways to install and deploy Fabro Homebrew is now a first-class install path, so macOS and Linux users can let Brew manage updates instead of downloading release archives manually. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} brew install fabro-sh/tap/fabro-nightly ``` Self-hosting also has more direct paths: Railway uses the prebuilt GHCR image, Render has a blueprint and deploy button, Fly.io ships with a `fly.toml`, and DigitalOcean now has a Droplet plus Compose guide. The deployment docs cover persistent storage, required environment variables, server URLs, and how to point the CLI at the hosted server. ## Alpine and musl Linux support Fabro now publishes `x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl` tarballs alongside the glibc Linux builds. The installer detects musl-based distributions through `ldd --version` and downloads the matching archive automatically, so Alpine and other musl hosts work without a custom build. The Docker image also moved to an Alpine-based Docker Hardened Image. That keeps the release binary layout shared between the image and tarballs while reducing image size and trimming the default runtime surface. ## Server hardening by default Fabro now emits baseline HTTP security headers on every server response and adds a report-only Content Security Policy for the web app. Operators get stronger defaults without having to place every deployment behind custom proxy header rules first. The GitHub webhook handler now lives on the main API router at `POST /api/v1/webhooks/github`. When `GITHUB_APP_WEBHOOK_SECRET` is configured, Fabro verifies signed webhook deliveries whether or not it is configured to update the GitHub App webhook URL. ## More * Added the Homebrew install path to the quick start and public install page * `fabro version` now warns when the CLI and server versions differ * `fabro version` now surfaces when the binary was built from a debug profile * Added Render, Fly.io, and DigitalOcean deployment guides alongside the existing Railway path * Added a production Docker Compose setup with Caddy handling automatic HTTPS in front of Fabro * Added `/health` to the container deployment path so platform health checks can target the server directly * Published the runtime image from the Docker Hardened Images Alpine base * Fixed startup crashes on `aarch64-unknown-linux-musl` by building musl targets through `cargo-zigbuild` * Fixed the SPA fallback so `/api/*` paths no longer resolve to the frontend shell * Fixed Gemini API keys being sent as URL query parameters; they now use the `x-goog-api-key` header * Hardened the `fabro-demo` cookie with `HttpOnly` and conditional `Secure` * Fixed GitHub repo lookup path validation so encoded `..` segments cannot redirect server-side GitHub API requests # Run files, archived runs, and browser setup Source: https://docs.fabro.sh/changelog/2026-04-19 **Fabro no longer terminates inbound TLS or mTLS itself.** Keep the Fabro listener on plain HTTP or a Unix socket, then terminate HTTPS or mTLS at a reverse proxy, load balancer, or platform ingress. To migrate: 1. Remove direct TLS listener settings from Fabro. 2. Put the TLS-terminating proxy in front of Fabro. 3. Set `server.api.url` and `server.web.url` to the external HTTPS origin. ## Browse changed files from a run Run detail pages now include a Files view backed by server-side file metadata and diffs. Instead of leaving the web app to infer output from terminal logs or downloaded artifacts, Fabro can show the changed files, empty states, loading states, errors, and refresh behavior directly in the run UI. The same data is available through the API: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} GET /api/v1/runs/{id}/files ``` The server reads the run's sandbox path when available and can fall back to final patch data for failed runs, so file review still works across more failure modes. ## Archive terminal runs Terminal runs - `succeeded`, `failed`, and `dead` - can now be archived after review. Archived runs are hidden from default listings and are read-only; if you try to mutate one, Fabro returns an actionable error asking you to unarchive it first. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro archive [ ...] fabro unarchive [ ...] ``` Both commands are idempotent in their respective directions, which makes retries and mixed batches safe. `fabro ps -a` shows active, terminal, and archived runs together, while the default `fabro ps` view stays focused on active work. ## Browser-based server setup Starting a server without an existing settings file now enters install mode and opens a browser wizard. The wizard walks through server URL, object storage defaults, LLM credentials, and GitHub integration instead of requiring a long manual `settings.toml` edit before first use. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server start ``` The install flow is also available in the embedded web app, so Docker and hosted deployments can complete setup from the same server process they will keep running. ## More * New `GET /api/v1/runs/{id}/files` endpoint returns paginated run file data and diff metadata * New `POST /api/v1/runs/{id}/archive` and `POST /api/v1/runs/{id}/unarchive` endpoints manage archived terminal runs * `GET /api/v1/runs` accepts `include_archived=true` for listing archived runs * GitHub webhook handling added explicit source IP allowlisting with GitHub webhook range refresh (removed on 2026-05-27) * Added `fabro archive` and `fabro unarchive` for bulk archiving terminal runs * `fabro ps -a` now includes archived runs alongside active and terminal runs * Added `run.archived` and `run.unarchived` events for archive state changes * Captured `final_patch` on failed runs so Run Files can still show changed files when finalization degrades * Added machine-readable sandbox git helpers for Run Files extraction * Promoted Settings to the top-level web navigation and added page headers for settings pages * Tightened run detail layout, graph toolbar behavior, billing presentation, and stage sidebar ergonomics * Added a shared empty, loading, and error state primitive for web run views * Canonicalized paginated run list responses across real and demo mode * Fixed Run Files path normalization for Unicode paths and tightened denylist handling for sandbox, artifact, and blob paths * Fixed archive and unarchive bulk failure grammar in CLI output * Fixed archived run mutation handling so archived runs return the correct actionable error * Fixed install wizard edge cases around GitHub App setup, unsupported providers, and demo status parsing # GitHub CLI auth and web run controls Source: https://docs.fabro.sh/changelog/2026-04-20 ## GitHub login for the CLI The CLI can now authenticate to GitHub-backed Fabro servers through a browser login flow. Instead of copying bearer tokens around, run `fabro auth login`, approve the flow in your browser, and let the CLI store and refresh the session for future commands. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro auth login fabro auth status fabro auth logout ``` Token refresh is wired through the shared client path used by commands such as `fabro doctor`, `fabro model`, `fabro repo init`, `fabro secret`, `fabro system`, and `fabro version`, so authenticated server commands can recover from expired access tokens without a manual retry. ## Run controls in the web app Run detail pages now expose lifecycle actions directly in the browser. You can start, pause, unpause, cancel, archive, and unarchive runs from the run UI, with toasts and server-sent event updates keeping the page state in sync after the action completes. Blocked runs also get a clearer notice in the stage sidebar. The web app now treats lifecycle actions as first-class run operations instead of burying them in CLI-only workflows. ## More * Corrected the GitHub webhook OpenAPI contract so generated clients match the server route * Added `fabro auth login`, `fabro auth status`, and `fabro auth logout` * Restored configured socket autostart behavior for local server targets * `fabro system df` now checks server stores in parallel * Fixed CLI login so the browser must confirm the auth request before the CLI receives a token * Removed explicit remote same-host auth fallbacks from the CLI client * Fixed server stop polling so zombie processes do not keep a server marked as running * Rejected obfuscated IPv4 host forms when parsing CLI server targets * Deduplicated archive, unarchive, cancel, and lifecycle toasts in the web app # Install-first server startup and safer auth boundaries Source: https://docs.fabro.sh/changelog/2026-04-21 **Fabro now expects one public API and web origin for browser auth.** Deployments should route API and web traffic through the same external origin, then set `server.api.url` and `server.web.url` to that origin. To migrate: 1. Put API and web traffic behind the same public origin. 2. Update `settings.toml` so both URLs point at that origin. 3. Re-run `fabro auth login` from clients that still have old target metadata. ## `fabro server start` is the setup path The happy path for a fresh machine now starts with the server itself. If settings do not exist yet, `fabro server start` enters install mode, opens the browser wizard, and guides you through configuring the deployment before normal server traffic begins. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server start ``` The install flow no longer enables dev-token material when GitHub App auth is selected, keeps loading states visible until the install session resolves, and carries GitHub App manifest state as form data instead of URL query data. ## Auth failures are easier to recover from CLI commands now return exit code `4` for authentication-required failures, giving scripts a stable signal that the user needs to log in. When a GitHub-authenticated server has no runs yet, the blank-slate quick start in the web app starts with `fabro auth login` so the first command users copy is the one they actually need. The OAuth callback and CLI resume pages were restyled to match the Fabro app, making browser auth feel like part of the product instead of a detached server page. ## More * Added exit code `4` for authentication-required command failures * `fabro exec` now classifies auth-required responses consistently with other server commands * Browser auth and CLI resume pages now use the Fabro auth theme * API status types now reuse the domain run status shape in generated Rust API types * GitHub auth translation now converts non-JWT auth into bearer tokens before request handling * Fixed markdown links in run stage output so unsafe links are sanitized before rendering * Fixed detached MCP launch commands by shell-quoting interpolated command values * Fixed event replay so unknown stage statuses do not stop run event streaming * Fixed Linux process checks so zombie-only process groups are treated as stopped * Restricted CLI start session reuse to GitHub-authenticated sessions # Object-store setup in the installer Source: https://docs.fabro.sh/changelog/2026-04-22 ## Configure storage from the browser wizard The install wizard now includes an Object store step, so hosted deployments can choose local disk or AWS S3 before the server starts serving normal traffic. The wizard manages SlateDB and artifact storage together, which prevents the common setup mistake of putting run events in one backend and artifacts somewhere else. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.slatedb] provider = "s3" disk_cache = true [server.slatedb.s3] bucket = "{{ env.SLATEDB_BUCKET }}" region = "us-east-1" ``` For AWS S3, the wizard supports runtime credentials from the deployment environment or manually entered `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. Advanced S3-compatible backends such as MinIO, R2, custom endpoints, and path-style settings remain available through manual `settings.toml` configuration. ## More * `fabro settings --local` now reads and writes local settings without contacting a server * Authentication-required errors now suggest `fabro auth login` * Run dump artifact paths now use the displayed stage ID in export paths * Dev-token handling is now gated on explicit `dev-token` auth configuration * `fabro server start` no longer mints dev-token material when dev-token auth is disabled * Added a Discord badge to the README * Run status handling now uses one tagged lifecycle state representation across API and UI surfaces * Fixed archive outcome alignment so status transitions report consistently * Fixed server-side settings authority so unit tests and dry paths do not accidentally reach live object stores * Fixed install runtime refresh behavior after settings updates # Server secrets, fabro dump, and faster workflow finalization Source: https://docs.fabro.sh/changelog/2026-04-23 **`fabro store dump` has been renamed to `fabro dump`.** Update scripts and docs that still call the nested command. To migrate: 1. Replace `fabro store dump ...` with `fabro dump ...`. 2. Re-check any automation that depends on dump output paths. ## Tighter server secret boundaries Server startup now validates authority-bearing secrets at the server boundary instead of letting every subprocess inherit whatever happened to be in the parent environment. Worker subprocesses receive a scoped worker token when they need one, then scrub it from their process environment before launching hooks, sandbox commands, MCP stdio, or other descendants. That change reduces the chance of leaking server-level credentials into user-controlled command paths while preserving authenticated run operations. It also makes install-time and startup-time secret handling easier to reason about for self-hosted deployments. ## Faster workflow finishing Several workflow phases now do less serialized work. Retros load the event log once, and final patch creation can overlap with finalize commit work. Users should notice this most on longer workflows with large event logs or expensive final patch generation. The behavior is the same, but the slow tail of a run has fewer avoidable waits. ## More * Renamed `fabro store dump` to `fabro dump` * `fabro run --json` no longer opens a preview browser window * Manifest git detection now respects the workflow `working_directory` * Added `CommandContext::json_output()` and `CommandContext::verbose()` accessors for more consistent CLI behavior * Sub-workflows now reuse the parent credential source * Terminal run events now emit from FINALIZE and include the real terminal error when finalization fails * Stage and billing summaries are deduplicated before terminal events are written * Auto-PR creation now reuses the resolved LLM client instead of resolving credentials again * Dense settings resolution is now cached across manifest, server, workflow, and CLI loaders * Server secrets are scrubbed from worker environments before descendant commands can inherit them * Pull request creation now runs through server-side plumbing with shared validation * Fixed the Get Started button to point to the docs introduction * Fixed LLM source resolution gaps in settings-driven runs * Fixed PR refactor review feedback around conflict and auth error handling # Server-backed rewind, setup polish, and diff stats Source: https://docs.fabro.sh/changelog/2026-04-24 **`fabro rewind` now creates a replacement run instead of mutating the source run.** The source run is archived after rewind succeeds, and the new run ID is the one you should resume. To migrate: 1. Read the new run ID printed by `fabro rewind`. 2. Resume or inspect that replacement run instead of assuming the original run ID changed in place. **`fabro pr list` has been removed.** Remove scripts that still call it and use the run detail surfaces that own pull request creation state. ## Server-backed rewind and fork `fabro rewind` now creates a replacement run at the target checkpoint and archives the source run instead of mutating the source run in place. The command prints the new run ID: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro rewind plan@2 fabro resume ``` The source run records which run superseded it, so archived run history keeps a clear audit trail. Rewind requires the source to be terminal (`succeeded`, `failed`, or `dead`). `fabro fork`, `fabro rewind`, and their `--list` modes now use the server API. The server must be able to read the run's recorded working directory. Timeline listing no longer rebuilds a missing metadata branch; if the metadata branch is gone, the timeline is empty until metadata is restored. ## Smoother setup and local auth Self-hosted setup is easier to complete from the browser and the CLI. The install wizard now works from the server root, exposes the local object store root, treats the AWS access key ID as readable text, and the server start banner prints the install token on its own copyable line. Dev-token credentials are now stored in the same auth store as OAuth credentials, so CLI targets resolve credentials consistently whether the server is reached through TCP or a Unix socket. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro auth login --dev-token ``` ## More * New `POST /api/v1/runs/{id}/rewind` endpoint creates the replacement run and archives the source run * New `POST /api/v1/runs/{id}/fork` endpoint forks a run from the server-side run record * New `GET /api/v1/runs/{id}/timeline` endpoint returns checkpoint timeline entries from run metadata * `GET /api/v1/runs/{id}/files` responses now include aggregate additions and deletions in `meta.stats` * Install prefill and object-store responses now include local object store root details * Run summaries now include `superseded_by` data for rewound source runs * Removed the obsolete `fabro pr list` command * `fabro fork`, `fabro rewind`, and timeline listing now call the server API * Fatal CLI errors now use styled diagnostics while preserving exit codes, telemetry, and auth help hints * Server start output now prints the install token on a separate copyable line * Added `fabro auth login --dev-token` for saving dev-token credentials in the auth store * Disabled git signing for sandbox checkpoint commits so local signing configuration does not block workflow bookkeeping * Run Files now shows aggregate `+/-` diff stats beside the file count * Run Files now uses a PR-style header with captured time, refresh, and Split/Stacked controls in one row * Credentials embedded in URLs are redacted in logs and error output * CLI and user-configuration reference docs are generated and checked for drift * Updated the quick start with supported platform details * Fixed the install wizard not mounting at the root route * Fixed the local object store root missing from install wizard configuration * Changed the AWS access key ID field to a readable text input * Changed the install wizard token field to a single-line input # Files Changed sidebar, settings panels, and install validation Source: https://docs.fabro.sh/changelog/2026-04-25 ## Files Changed sidebar Large diffs are easier to navigate from the run detail page. The Files Changed tab now has a GitHub-style file tree sidebar that lists only changed files, shows each file's git status, supports search, and keeps selection tied to the existing `#file=` deep-link behavior. Clicking a file in the tree scrolls and focuses the matching diff, while mobile layouts keep the stacked diff view uncluttered. The tree lazy-loads on desktop and reserves its column while loading, so the diff layout no longer jumps as sidebar data arrives. ## Settings panels The Settings page now presents server configuration as curated panels instead of a raw JSON dump. Server, access, integration, and artifact values are rendered with typed controls and readable summaries, so operators can scan what matters without parsing the full settings payload. ## Safer install URLs Install and server startup now reject wildcard public URLs before they can be saved or used for callbacks. This catches misconfigured deployment origins at setup time instead of letting GitHub OAuth or browser redirects fail later with harder-to-debug errors. ## More * Added grouped `cargo dev spa` and `cargo dev docs` commands for asset and documentation maintenance * Updated `cargo dev docker-build` to default image tags to `fabro-sh/fabro` * Added the captured commit short SHA to the Files Changed freshness label * Unboxed the Files Changed tree so it sits directly in the sidebar layout * Styled web OAuth callback state-validation errors with the same browser shell as the CLI auth flow * Fixed aggregate diff stats counting sensitive, binary, symlink, and submodule entries that are not visible in the diff * Fixed Files Changed tree selection so it only targets valid file paths after filtering or reloads * Fixed sidebar loading and filtering behavior to keep the desktop layout stable * Fixed install and doctor validation accepting wildcard public URLs # Clone-based Docker sandboxes and per-run logs Source: https://docs.fabro.sh/changelog/2026-04-26 **Docker is now the default runtime sandbox provider.** Docker sandboxes no longer bind-mount the caller's working directory. They create a container workspace and clone the run manifest's GitHub origin into `/workspace`. To migrate: 1. Use `--sandbox local` or `[run.sandbox] provider = "local"` when a run must operate directly on the host working tree. 2. For Docker or Daytona runs without a GitHub origin, set `[run.clone] enabled = false`. 3. Make sure private GitHub repositories have GitHub App credentials configured before running clone-based sandboxes. ## Clone-based Docker sandboxes Docker now uses the same clone-shaped runtime model as Daytona. When the run manifest includes a GitHub origin, Fabro clones that repository into the sandbox workspace and configures git lifecycle operations inside the sandbox. This is a breaking sandbox change: * Docker no longer bind-mounts host workspaces. * Docker and Daytona accept GitHub origins only when cloning. * Present non-GitHub origins fail unless `[run.clone] enabled = false`. * Absent origins and `[run.clone] enabled = false` runs create empty workspaces without repository files. The packaged Docker Compose service mounts the host Docker socket so the server can create sibling run containers. Operators remain responsible for Docker socket permissions, Docker Desktop behavior, `DOCKER_HOST`, TLS, and remote daemon security. ## Run logs Each server-dispatched run now gets its own worker tracing log at `runtime/server.log`. The server also exposes the log through `GET /api/v1/runs/{id}/logs`, so tools can fetch execution-time tracing for a single run without grepping the process-wide server log. `fabro dump` now includes the same log as `run.log` when it is available. Dumps still succeed for older runs or submitted runs that do not have a worker log yet. ## Operators Worker subprocesses continue writing to the main `/logs/server.log`, and also mirror worker tracing into the per-run file. `FABRO_LOG` still controls log verbosity; if a server config sets `[server.logging] level`, that level is now propagated to workers unless the parent process already set `FABRO_LOG`. Server logs can also go to stdout, which is useful for containers and process supervisors: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.logging] destination = "stdout" ``` ## More * New `GET /api/v1/runs/{id}/logs` endpoint returns the per-run worker log * Server settings now include `server.logging.destination` with `file` and `stdout` values * `fabro dump` now includes per-run worker logs as `run.log` when they are available * Fixed web requests being served from non-canonical hosts instead of redirecting to the configured canonical host # Install wizard sandbox selection and sandbox-native git metadata Source: https://docs.fabro.sh/changelog/2026-04-27 ## Install wizard sandbox selection The web install flow now includes a sandbox provider step, so operators choose and validate their execution environment during setup instead of discovering missing Docker or Daytona configuration on the first run. The setup screen also preserves the GitHub App return state, which makes install failures easier to resume after bouncing through GitHub. This gives new deployments a clearer path from "server is running" to "runs can actually execute". The wizard validates the selected provider through the server install API and shows the result in the same setup flow. ## Sandbox-native git metadata Run metadata now follows the sandbox instead of assuming the server's current working directory is the source of truth. Clone-based providers can record origin, branch, dirty state, checkpoint branches, and pre-run push outcomes from the workspace where execution actually happens. This matters for Docker and Daytona runs because the working tree lives inside the sandbox. Fork, rewind, resume, PR creation, and run detail pages now have a more accurate view of the sandbox repository state. ## More * New run graph source response returns the raw workflow DOT source for graph viewers and external tools * Added level and target coloring to the web run logs view * Polished the runs board and install navigation layout * Removed the redundant Unarchive action from the archive toast * Fixed packaged Docker deployments missing access to the mounted Docker socket * Fixed sandbox error chains losing useful context before reaching users * Fixed server routing middleware behavior around nested route handling # Workflow-relative prompt files and sandbox metadata fixes Source: https://docs.fabro.sh/changelog/2026-04-28 ## Workflow-relative prompt files Prompt file references now resolve relative to the workflow file instead of the shell's current working directory. A workflow can live with its prompt files in a subdirectory and behave the same whether it is run from that directory, from the repo root, or through a run config. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review [prompt="@prompts/review.md"] ``` This makes checked-in workflow folders more portable and removes a class of failures where `@path` references only worked from one launch location. ## Sandbox metadata recovery Sandbox-native metadata handling now covers more run shapes, including non-git local runs and clone-based sandboxes with pre-run git state. Runs that do not start from a normal host git checkout can still initialize, record useful metadata, and keep fork or rewind operations aligned with the sandbox workspace. Metadata snapshots are also batched with `git fast-import`, which reduces checkpoint overhead on workflows that write many stage snapshots. ## More * Added `cargo dev bench-tests` for capturing per-test timing CSVs * Kept `fabro validate` focused on workflow validation instead of running runtime preflight checks * Fixed manifest creation paths that could prompt for git credentials while preparing a run * Fixed non-git local sandbox runs failing during initialization * Fixed sandbox-native git metadata gaps affecting fork, rewind, and run projections # Faster preflight checks and clearer setup errors Source: https://docs.fabro.sh/changelog/2026-04-29 ## Faster preflight checks Preflight checks no longer need to clone repositories just to validate a run manifest. Fabro now bundles and validates the workflow inputs directly, which makes `fabro preflight` and server-side run creation much faster for remote repositories and private GitHub origins. The CLI also shows a spinner while the server runs checks, so long validations no longer look stuck. Run creation skips redundant implicit preflight work when validation has already been handled. ## Setup and auth recovery Setup failures now point at the configured GitHub App and tell operators when Docker-based runs need GitHub credentials for private clones. The web setup page also shows GitHub App install return state, and the auth quick start includes the server URL so users copy a complete login command. CLI login now persists the selected auth target, and dev-token recovery messages explain how to get back to a working local session. These changes make setup problems more diagnosable without reading server logs first. ## More * Error responses now include request IDs so API failures can be correlated with server logs * Run projection types reuse canonical event, interview, secret, billing, and model shapes across the Rust and TypeScript clients * Metadata snapshot events now emit into the run event stream during workflow execution * Added a Settings/JSON toggle on the settings page * Regrouped settings into Server, Data, Security, and Integrations sections * Added syntax highlighting to the run graph source view * Sped up Docker repository clones during sandbox startup * Fixed degraded Run Files diffs so placeholder, truncation, and unavailable-content states render consistently * Fixed Graph/Source toggling unmounting the graph view unnecessarily * Fixed worker subprocesses missing `GITHUB_APP_PRIVATE_KEY` for GitHub App operations * Fixed git push and origin-credential failures leaking unsafe stderr into logs # Command logs and precise stage outcomes Source: https://docs.fabro.sh/changelog/2026-04-30 **Workflow condition outcomes use new names.** Conditions and routing directives now use `succeeded`, `failed`, `partially_succeeded`, and `skipped`. Retry intent is internal and no longer appears as an edge-routable outcome. To migrate: 1. Replace `outcome=success` with `outcome=succeeded`. 2. Replace `outcome=fail` with `outcome=failed`. 3. Replace `outcome=partial_success` with `outcome=partially_succeeded`. 4. Remove `outcome=retry` branches and use retry policies plus `stage.retrying` events for retry observability. ## Live command output logs Command stages used to expose output mostly through event payloads and short diagnostic tails. Fabro now records stdout and stderr as per-stage logs while the command runs, stores the finalized streams in CAS-backed storage, and renders separate live output panels in the run stage view. API clients can tail the same logs by byte offset, which makes long-running scripts easier to follow without reloading an entire run projection: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} curl "$FABRO_SERVER/api/v1/runs/$RUN_ID/stages/$STAGE_ID/logs/stdout?offset=0" ``` The same command log data is available to edge routing and retros after the command finishes, so workflow decisions and run narratives can inspect the final output without relying on truncated event fields. ## Precise stage outcomes Fabro now separates a stage's terminal outcome from its live execution state. Workflow routing sees four stable outcomes - `succeeded`, `failed`, `partially_succeeded`, and `skipped` - while retrying remains an internal execution state surfaced through events. This makes edge conditions, agent routing directives, status files, and API responses line up around one vocabulary: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate -> deploy [condition="outcome=succeeded || outcome=partially_succeeded"] gate -> fix [condition="outcome=failed"] gate -> skip [condition="outcome=skipped"] ``` ## Clearer run state in the API and web app The server now projects precise stage states to API clients, and the generated TypeScript client exposes those shapes directly. The web app uses the same model across run overview, stage sidebar, run files, settings, billing, and workflow detail views, so running, retrying, succeeded, failed, partially succeeded, and skipped stages render consistently. The OpenAPI schema also reuses canonical billing, model, provider, settings, and stage outcome types instead of parallel generated copies. Client code sees fewer accidental shape differences between API responses that represent the same product concepts. ## More * New `GET /api/v1/runs/{id}/stages/{stageId}/logs/{stream}` endpoint tails command stdout or stderr with byte offsets * Replaced the generated `StageStatus` model with `StageState` and `StageOutcome` shapes * Tightened shared model, billing, provider, and settings schemas in the OpenAPI contract * Added `configured` to `GET /models` responses so clients can tell whether credential material is present before testing a model * Regenerated the TypeScript API client with precise run, workflow, sandbox, hook, MCP, and settings models * `allow_partial=true` now promotes exhausted retryable failures to `partially_succeeded` * Retryable failures emit retrying events while the node remains active instead of exposing `retry` as a final outcome * Command results now distinguish cancellations from timeouts * Docker, Daytona, local, and worktree sandboxes feed command stdout and stderr into the same streaming log pipeline * Run progress no longer prints successful metadata snapshot lines in the main progress stream * Run detail headers are less noisy after removing the repeated source and sandbox path line * Fixed command diagnostics being lost on early command failures * Fixed Daytona and worktree sandboxes losing streamed command semantics * Fixed retros missing command timing log references # Clearer failure diagnostics and deployment docs Source: https://docs.fabro.sh/changelog/2026-05-01 ## Full error context in user-facing failures Fabro now preserves source error chains across the CLI, server, sandbox, GitHub, OAuth, MCP, tracker, and workflow paths. Previously, several errors were flattened into a single message before reaching the user, which hid the underlying network, credential, serialization, or provider failure that actually needed attention. This makes failures from commands such as `fabro run`, `fabro exec`, `fabro install`, `fabro dump`, and server-side run creation more actionable. The top-level message still describes the Fabro operation, while the source chain keeps the lower-level cause available in rendered output and diagnostics. ## Self-hosting documentation refresh The public docs now separate local operation, self-hosted Docker, Railway deployment, and server operations into distinct pages. Older Render, Fly.io, DigitalOcean, and one-off deploy artifacts were removed so operators see the supported deployment paths instead of stale platform-specific instructions. This gives new server installs a clearer path from "run the binary" to "operate Fabro behind your own process manager or container platform." The Docker Compose documentation now reflects the packaged service shape used by current self-hosted deployments. ## More * Run projections now use stage vocabulary for stage completion and stage projection types * Stage state and artifact retry fields are separated so API clients do not mix execution status with retry URLs * TypeScript `StageState` JSON fields are typed instead of emitted as `any` * Removed the development-token environment override from normal CLI auth resolution * Fixed Docker exec stop requests running an extra command after cancellation * Fixed `fabro dump` hiding serde errors when a run projection could not be hydrated * Preserved exec failure diagnostics through workflow initialization, finalization, and sandbox metadata snapshots # Answer blocked runs in the web app Source: https://docs.fabro.sh/changelog/2026-05-02 ## Interview dock for blocked runs Blocked runs can now be answered directly from the run page. The new interview dock supports yes/no, confirmation, multiple choice, multi-select, and freeform questions, including write-in responses when a choice question allows them. The dock listens to interview events and refreshes as questions arrive, so a parallel workflow with multiple pending prompts can be drained from one place. Cancelling the run stays in the page header while the dock stays focused on the current question. ## Principal attribution and auth routing Run events and run creation now carry clearer principal information for users, workers, systems, Slack interactions, webhooks, and agents. API clients get explicit provenance objects instead of older actor-shaped fields that could lose where a run came from. This also closes attribution gaps across web, CLI, worker-token, Slack, and human-interview paths. Runs created or advanced through different surfaces now preserve who or what took the action more consistently. ## More * Run specs now include client and server provenance shapes * Run events use unified principal shapes for user, worker, system, Slack, webhook, and agent subjects * Bounded server readiness probes so startup and status checks fail promptly instead of waiting indefinitely * Fixed principal attribution gaps during run creation and interview callbacks * Fixed auth routing regressions around worker tokens and human-in-the-loop callbacks * Preserved remaining error context in provider auth, tracker, OAuth, graph rendering, and workflow command errors # Run links and Daytona scope checks Source: https://docs.fabro.sh/changelog/2026-05-03 ## Run web URLs in API and CLI output Fabro now returns a run's web UI URL with run responses and stores the same URL on the `run.created` event. API clients and command output no longer have to reconstruct route shapes or guess which public web URL the server is using. When the web UI is enabled, `fabro run` and `fabro attach` print the link from the run event stream: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} Web UI: https://fabro.example.com/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z ``` Fresh runs and attached sessions use the same event-backed path, so replaying a run shows the same link that was available when it was created. ## Daytona scope validation Fabro now checks Daytona API key scopes during install, `DAYTONA_API_KEY` secret updates, and `fabro doctor`. Operators see a precise missing-scope message before the first sandbox run fails. The validation covers the snapshot and sandbox permissions Fabro needs to create and clean up Daytona environments. This turns what used to be a generic sandbox creation failure into an install-time or diagnostics-time fix. ## More * `RunStatusResponse` and `run.created` now include `web_url` when server web UI settings are available * Run billing responses now include non-LLM stages so the billing page can show the full run shape * `fabro run` and `fabro attach` print the run's Web UI URL when available * Snapshot pull, create, ready, and failure events now render in run progress and logs * Fixed failed exec stages losing their output tail diagnostics * Fixed JSON redaction replacing sandbox names that users need for debugging * Fixed archived run overview exit nodes losing their completion color # Smarter PRs, faster doctor checks, and GPT-5.5 Source: https://docs.fabro.sh/changelog/2026-05-04 ## Smarter pull request titles and bodies Fabro's auto-PR flow now asks the LLM for a structured pull request title and reviewer-sized body instead of deriving every title from the workflow goal. Titles are capped at 72 characters, bodies are validated as non-empty, and Fabro still appends the plan, retro, and run details programmatically so generated PRs stay useful without duplicating boilerplate. The PR writer also sizes the description to the change. Small fixes get concise summaries, while larger or architectural changes can include the context, tradeoffs, and visual aids reviewers need. ## Faster, stricter LLM diagnostics `fabro doctor` now probes configured LLM providers concurrently instead of waiting on them one by one. Provider probe failures also count as diagnostic errors, so a broken key or unreachable provider no longer looks like a successful configuration check. Doctor output now preserves the underlying LLM error chain when a provider probe fails, which makes network failures, API errors, and provider-specific terminal errors easier to distinguish: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro doctor --verbose fabro model test --provider openai ``` ## GPT-5.5 and Claude Opus 4.7 defaults The model catalog now includes `gpt-5.5` and `gpt-5.5-pro`, with `gpt-5.5` set as the OpenAI default. Anthropic's default model is now `claude-opus-4-7`. Built-in Fabro workflows were also refreshed to use the newer defaults in their own model settings. This keeps generated plans, simplify stages, and project workflows aligned with the catalog users see through `fabro model list`. ## More * `fabro doctor` now reports LLM provider probe failures as errors * `fabro doctor` probes configured LLM providers concurrently * `fabro auth status` now prints auth timestamps with seconds precision * Auto-PR generation now uses structured `{ title, body }` output with validation and title fallback rules * Built-in implementation workflows now use newer Claude and OpenAI defaults * Built-in verify gates refresh generated docs before checking them * Built-in Rust workflow checks now run clippy across all targets with the pinned nightly toolchain * Run and board event streams are coordinated across browser tabs to reduce duplicate SSE subscriptions * The run board now separates pre-execution runs from `Initializing` runs * Daytona credential probes reuse HTTP clients and avoid extra proxy setup work * Fixed OpenAI responses-stream terminal errors being hidden from agent sessions * Fixed doctor output dropping source-chain details for failed LLM probes * Fixed an LLM preflight probe regression * Fixed cross-tab SSE fallback recovery and lifecycle cleanup in the web app * Fixed the runs page briefly showing an empty-state before run data finished loading # Mid-run steering, cancellable agents, and GitHub permissions Source: https://docs.fabro.sh/changelog/2026-05-05 **Breaking API and configuration changes.** GitHub token permissions moved from `[server.integrations.github.permissions]` to `[run.integrations.github.permissions]`. `RunStage` also now uses per-visit stage identity: `id` contains values like `verify@2`, `node_id` contains the workflow node name, `visit` contains the visit number, and `dot_id` has been removed. To migrate: 1. Move GitHub permission tables to `[run.integrations.github.permissions]`. 2. Use `RunStage.id` for per-visit links and API calls. 3. Replace `RunStage.dot_id` with `RunStage.node_id` when you need graph-node identity. ## Steer running agents You can now send guidance to a running API-mode agent from the CLI, web UI, or API without stopping the run. Plain steering is queued for the agent's next turn, while interrupting steering cancels the active round first and delivers your message as the next user turn. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro steer "Focus on the failing integration test" fabro steer --interrupt "Stop that approach and try the smaller fix" ``` The web app now includes a steering composer on run detail and board views, and steering events flow through the same run event stream so accepted, delivered, and dropped messages are visible. ## Cancellation reaches active agent work Cancelling a run now reaches in-flight CLI and API agent stages instead of waiting for the stage to finish. Fabro propagates cancellation through workflow services, manager-loop child runs, sandbox streaming commands, CLI agent invocations, and API agent sessions. Cancelled CLI agents now emit typed cancellation events, while timed-out agents emit separate timeout events. That distinction keeps user cancellation, node timeouts, and stall timeouts from collapsing into the same failed-stage behavior. ## Run-level GitHub permissions GitHub `GITHUB_TOKEN` permissions are now part of run configuration, so workflow, project, and user settings can tighten or override the permissions a run requests. This makes scoped GitHub tokens usable from bundled workflows and local CLI-launched runs instead of being trapped in server-only configuration. ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.integrations.github.permissions] contents = "write" pull_requests = "write" ``` Higher layers can set `permissions = {}` to clear inherited permissions for a workflow that should not receive a token. ## More * New `POST /api/v1/runs/{id}/steer` endpoint sends append or interrupt steering to running API-mode agents * New `POST /api/v1/runs/{id}/interrupt` endpoint interrupts an active API-mode agent round without steering text * New `GET /api/v1/system/repair/runs` endpoint lists runs that cannot be loaded from durable storage * `RunStage` now exposes per-visit `id`, `node_id`, and `visit` fields so repeated workflow nodes do not collapse together * Run billing and run stage responses now read from projection data with live runtime updates * Added `fabro steer` with `--interrupt` and `--text-stdin` * Added `fabro system repair runs` for listing unreadable durable runs * `fabro rm --force ` can delete unreadable runs by ID * Stage URLs now preserve visits with IDs like `verify@1` and `verify@2` * Stage activity now renders from scoped stage event streams * Silent fallback warnings now appear in run logs and run events * Added the bundled `gh-list` workflow for verifying GitHub token injection with `gh pr list` and `gh issue list` * Files Changed now consistently uses virtualized diff rendering and Pierre worker assets in production builds * Run board metadata is cached to avoid repeated durable-store reads * The run board and run detail views can share SSE subscriptions while still handling steering toasts and query invalidation * Fixed Anthropic forced-tool calls including thinking blocks * Fixed repeated stage visits sharing sidebar links, graph selections, event streams, or turns views * Fixed large Files Changed views mounting every diff at once * Fixed run file diff workers missing from production web builds # Run events, raw logs, and refreshable GitHub tokens Source: https://docs.fabro.sh/changelog/2026-05-06 **Static `ghs_*` tokens are rejected.** GitHub installation access tokens expire quickly and should not be stored as static `GITHUB_TOKEN` values. To migrate: 1. Replace static `ghs_*` tokens with a personal access token, or configure GitHub App mode. 2. In GitHub App mode, request scoped run tokens with `[run.integrations.github.permissions]`. ## Separate run events and raw logs `fabro events` now focuses on structured workflow events, while `fabro logs` prints the raw worker tracing output for a run. Use events when you want the run timeline, assistant messages, tool activity, or follow-mode output; use logs when you need low-level worker diagnostics. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro events --pretty --follow fabro logs --tail 200 ``` The split makes the CLI match the product model more closely: events are the durable run history, and logs are the debugging stream. ## Better foreground server logs `fabro server start --foreground` now writes server logs to stdout by default, and interactive terminals get a compact colored one-line format with date-bearing timestamps. Piped stdout and file logs keep the plain tracing format, so scripts and saved logs do not need to change. ## Refreshable GitHub tokens during workflows Long-running workflows that used GitHub App installation tokens could outlive the first token minted for the run. Fabro now keeps GitHub credentials as a refreshable token source and resolves `GITHUB_TOKEN` lazily before command and API agent stages, so GitHub operations can continue after token rollover. Tokens refresh when they are close to expiry. If a refresh fails while the cached token is still valid, Fabro keeps using the cached token and logs a warning; expired tokens now fail explicitly instead of surfacing later as confusing GitHub authentication errors. ## More * Added `fabro events` for structured run event history with `--follow`, `--pretty`, `--since`, and `--tail` * `fabro logs` now prints raw worker tracing output with `--tail` * Foreground logs now show the run ID on run-scoped log lines * Failed API commands now preserve server response details instead of collapsing to a status-only error * CLI help output now wraps angle-bracket placeholders in backticks * Command stages and API agent stages resolve `GITHUB_TOKEN` from a refreshable source before use * GitHub App installation tokens refresh when they are within 15 minutes of expiry * CLI agent stages now surface their launch-time token refresh limitation * Interactive foreground server logs now use a compact colored TTY format * Worker processes preserve the terminal color environment passed by the foreground server * `--watch-web` now ignores non-source watcher events and responds faster during local development * Agent file reads now parallelize `read_many_files` work * Fixed long-running GitHub App workflows losing GitHub access after the initial installation token expired * Fixed static configuration accepting short-lived `ghs_*` installation tokens * Fixed foreground server logs defaulting away from stdout * Fixed repeated `cargo dev` rebuilds during generated CLI reference refreshes # Run debugger, sticky steering, and run filters Source: https://docs.fabro.sh/changelog/2026-05-07 **Command output streams are now merged.** Command stages expose one ordered output log instead of separate stdout and stderr streams. To migrate: 1. Replace `/api/v1/runs/{id}/stages/{stageId}/logs/{stream}` with `/api/v1/runs/{id}/stages/{stageId}/logs/output`. 2. Replace command outcome fields such as `stdout`, `stderr`, `stdout_bytes`, and `stderr_bytes` with `output` and `output_bytes`. ## Stage threads and debug events The run stages view is now a stage debugger with a `Thread` view for readable agent activity and a `Debug` view for raw stage events. You can search and filter events, inspect tool calls in a side panel, see token and duration metrics, and open formatted JSON or Markdown details without leaving the stage. Command stages now get a `Logs` tab with the real command output, so command-heavy workflows no longer require jumping to raw run logs to see what happened. Consecutive calls to the same tool are grouped together, which keeps long agent stages scannable while preserving the underlying details. ## Steering stays attached to the run The web app now keeps steering and interview controls in a bottom-attached run dock. You can move between stages, logs, source, and billing while keeping the current human action available at the bottom of the screen. Steering actions are also available from the run menu, including interrupt steering. That makes it easier to redirect a running agent from whichever run view you are already using. ## More useful run lists The runs page now supports URL-backed filters for search, repository, workflow, created time, archived runs, and list-versus-column view. Run rows split repository, workflow, and created time into separate columns, and archived runs can be shown explicitly instead of disappearing from the board. Run cards now surface pull request diff counts when available, and each terminal column can archive all archivable runs in that column from its actions menu. ## More * Board run listing now accepts `include_archived=true` and can return an `archived` column * Run projections and summaries now expose `last_event_at` for activity-aware run views * Run projections and summaries now expose `diff_summary` with changed-file, addition, and deletion counts * Command stage log responses now return a single ordered output log instead of separate stdout and stderr fields * Raw run logs are now kept free of ANSI escape sequences * Colored run span fields are parsed correctly when reading server-backed run logs * Command stages now persist one merged output stream for ordered stdout/stderr display * Workflow breadcrumbs stay visible on run detail pages * The Graph tab has moved to the run sidebar as `Graph Source` * Run Logs now support level filtering, text search, copied output, and live refresh while a run is active * Run stage rows show model, duration, token counts, and absolute timestamp tooltips * Run Billing hides stages with no billing usage while keeping active stages visible * Run headers now show workflow metadata and recent activity * The run detail action buttons are collapsed into a menu * Fixed active SSE streams blocking server shutdown * Fixed the synthetic start node being included in billing rollups * Fixed the Billing tab losing its anchored layout on run detail pages * Fixed full-height run stage layouts collapsing in the browser * Fixed the degraded sandbox banner appearing on the run files page after the sandbox is gone # Run Events, sandbox lifecycle, and typed interviews Source: https://docs.fabro.sh/changelog/2026-05-08 **Interview answer submissions now use typed request bodies.** `POST /api/v1/runs/{id}/questions/{qid}/answer` requires a `kind` discriminator instead of the previous loose answer fields. To migrate: 1. Send `{ "kind": "yes" }` or `{ "kind": "no" }` for yes/no answers. 2. Send `{ "kind": "selected", "option_key": "..." }` for single-choice answers. 3. Send `{ "kind": "multi_selected", "option_keys": ["..."] }` or `{ "kind": "text", "text": "..." }` for multi-choice and freeform answers. ## Run Events and Artifacts pages Run detail sidebars now include dedicated `Run Events` and `Artifacts` pages. `Run Events` shows the full event stream with category filters, search, and an event detail panel, while `Artifacts` groups downloadable files by stage and retry. This gives you a direct path to the two things users usually need after a run: the complete audit trail and the files produced by stages. ## Run-owned sandbox lifecycle Run sandboxes now belong to the run lifecycle instead of being detached provider resources that require manual cleanup. Terminal runs stop their sandboxes by default, resumes reconnect to persisted sandboxes, and run deletion either removes the sandbox or returns preserved provider details according to the run's preserve settings. The run settings page now shows `stop_on_terminal` alongside sandbox preservation, so you can tell whether cleanup is automatic before launching or deleting a run. ## Typed interview answers Human-in-the-loop answers now have explicit API shapes for yes/no, single-select, multi-select, and text responses. The web UI and CLI attach flow use those shapes consistently, which removes ambiguity around which field should be present for each question type. `fabro attach` also handles interview edge cases better. Invalid input re-prompts instead of dropping the interaction, and attach no longer blocks when a question was already answered from another client. ## More * `DELETE /api/v1/runs/{id}` can now return preserved sandbox details when deletion leaves a provider resource alive * `RunSandboxSettings` now includes `stop_on_terminal` * `RunStage` now includes a canonical `handler` field so clients can choose agent, command, or debug renderers without guessing from names * Run summaries now include stored pull request records with `html_url` * The web app now uses the generated TypeScript API client for auth, workflow, run, artifact, settings, and human-in-the-loop calls generated from OpenAPI * OpenAPI now documents browser auth config, current user, demo toggle, and development-token login endpoints * OpenAPI now documents workflow list, workflow detail, and workflow runs endpoints * `GET /api/v1/runs/{id}/graph` now documents the optional `direction` query parameter * `fabro attach` now re-prompts after invalid interview input * `fabro attach` now unblocks when an interview question is answered from another client * Added bundled `interview` and `sleeper` workflows for exercising human answers and interrupt handling * Added a Profile page to the user menu * Run titles now render inline Markdown in run lists and run detail headers * Run Files sidebar now shows the whole tree instead of filtering to changed files only * Run cards now show a pull request icon and number only when a stored pull request exists * Run cards now show repository names without repeating the owner prefix * Archived runs now have a Delete action in the web app * Unknown stage handler types now open in the Debug renderer by default * Fixed provider token usage normalization so OpenAI, Gemini, and Anthropic totals match provider billing semantics * Fixed terminal sandbox regressions after run-owned sandbox lifecycle changes * Fixed prompt-only stages missing completed responses in the Thread view * Fixed stage interrupt events missing from the Thread view * Fixed OpenAI reasoning metadata being lost across stateless round trips * Fixed duplicate action dividers in run menus * Fixed the steer bar not receiving focus from the actions menu # Sparse inputs, sandbox terminals, and run file diffs Source: https://docs.fabro.sh/changelog/2026-05-09 **Automatic retros have been removed.** Workflow runs now go directly from execution to finalization and optional pull request creation. The retro crate, retro events, retro docs page, PR retro section, `--no-retro`, `[run.execution].retros`, `features.retros`, and retro projection fields are no longer part of the product surface. To migrate: 1. Use Run Events, Stages, Turns, logs, and dumps for post-run analysis. 2. Remove retro-specific CLI flags, config keys, API field reads, and docs links. **Public local worktree mode has been removed.** Local sandbox runs now execute directly in the resolved working directory, and `--in-place` plus `run.sandbox.local.worktree_mode` are no longer supported. To migrate: 1. Run with `--sandbox local` from the checkout you want Fabro to use. 2. Create a separate clone or Git worktree yourself when you want local isolation. 3. Use Docker or Daytona for managed sandbox isolation. ## Sparse input overrides Workflow inputs can now be overridden one key at a time from the CLI. Repeat `-I` or `--input` on `fabro run`, `fabro create`, and `fabro preflight` to replace specific inputs while preserving unrelated inherited values from settings and workflow config. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run .fabro/workflows/check/workflow.toml -I repo_name=fabro-2 --input language=rust ``` Input-driven prompt paths, imports, and child workflow paths are rendered with the effective inputs before bundling, so sparse overrides work even when they change which files a workflow references. ## Sandbox access from run detail Run detail now has a dedicated Sandbox tab and terminal route. You can inspect the run sandbox from the web app, open an interactive terminal, copy Docker exec access commands, and see sandbox identity information without switching to a separate CLI session. This also gives long-running debug sessions a clearer place to live. Terminal framing, scroll behavior, Daytona proxying, and terminal error states were tightened so the terminal stays usable inside the run UI. ## Run file diff controls The Files Changed view can now compare committed run history and sandbox file scopes from the same toolbar. You can switch diff scope next to the file count, pick commits from the run history, and refresh patch diffs when the selected scope changes. This makes the file browser useful both during active sandbox work and after a run has committed checkpoints. ## More * Run file APIs now expose commit lists and diff scope metadata for committed history and sandbox comparisons * Run payloads, events, and mutations now support explicit run titles * Billing projections now include live per-stage token usage while a run is active * Sandbox details now expose control-plane metadata used by the run Sandbox tab * Added repeatable `-I, --input ` to `fabro run`, `fabro create`, and `fabro preflight` * Added Docker host preflight checks for Docker-based deployment and sandbox diagnostics * Run titles can now be edited inline from the run header * Run title fields now preserve explicit titles across create, fork, archive, unarchive, and attach flows * Added a demo-only Start tab for app-shell demos * Docker Compose deployment defaults now include safer sandbox-facing defaults * Added Thread DNA timeline strips to debug events and agent stage views * Added specialized stage renderers for non-agent workflow handlers * The Billing tab now shows an empty state when no models were used * Fixed deletion of unreadable runs * Fixed CLI verification failure handling * Fixed Daytona terminals to use the toolbox proxy and hide control frames * Fixed terminal dock spacing, bottom-row clipping, and overlap with the steer bar * Fixed run file diffs refreshing when the selected scope changes * Fixed scoped run file diffs to only include tracked files * Fixed deprecated project directory settings being applied to workflow discovery * Fixed embedded build Git metadata being stale on branch commits # Sandbox tools, auth sessions, and Live Events Source: https://docs.fabro.sh/changelog/2026-05-10 **Run API responses now use the canonical `Run` payload.** Run list, board, create, retrieve, and lifecycle endpoints now return the same public run shape. Archive state moved out of `status.kind = "archived"` and into run lifecycle metadata, sandbox fields distinguish planned settings from runtime state, and pull request records are separate from live pull request details. To migrate: 1. Regenerate API clients from the current OpenAPI spec. 2. Replace `RunSummary`, `RunListItem`, and `RunStatusResponse` assumptions with the canonical `Run` shape. 3. Read archive state from lifecycle metadata instead of treating `archived` as a terminal status. ## Sandbox tools in one place The run Sandbox tab now includes a read-only filesystem browser, Daytona VNC previews, discovered sandbox services, and terminal access from the same page. Empty files render cleanly, large file previews are virtualized, and directory sentinel files are hidden from the browser. Daytona sandboxes can start a signed noVNC preview from the web UI, and discovered listening TCP services are listed with their ports, bind addresses, process summaries, and preview support. ## Auth sessions and Live Events Profile now includes a Sessions page for the current browser session and active CLI session chains. CLI sessions can be revoked from the same surface, while browser sessions are listed as non-revocable in this API version. Settings now includes a Live Events page for watching server events in the web app. The page reuses the event debugger and adds the filtering and navigation needed for operational inspection. ## Automations navigation The web app now uses Automations as the product label for workflow definitions and runs. Routes, nav labels, and run pages were updated together so the main app shell reads as Automations, Runs, Settings, and Profile instead of mixing workflow terminology into the navigation. ## More * New `GET /api/v1/auth/sessions` endpoint lists browser and CLI auth sessions for the authenticated user * New `DELETE /api/v1/auth/sessions/{id}` endpoint revokes active CLI session chains * New `GET /api/v1/runs/{id}/sandbox/services` endpoint lists listening TCP services inside a run sandbox * New `POST /api/v1/runs/{id}/sandbox/vnc` endpoint creates signed noVNC preview URLs for Daytona sandboxes * Run list, board, create, retrieve, and lifecycle endpoints now return canonical `Run` objects * Added Profile sub-navigation with Overview and Sessions pages * Added Settings sub-navigation with Overview and Live Events pages * Added a full-screen terminal route and an "Open in new tab" action for embedded terminals * Added read-only filesystem and Daytona VNC modes to the run Sandbox tab * Added a Services tab to the run Sandbox page * Virtualized sandbox file previews and improved empty-file handling * Renamed the Workflows tab to Automations * Fixed sandbox service discovery so listening services are detected more reliably * Fixed sandbox VNC previews to open the noVNC viewer page * Fixed full-screen terminal toast notifications by wrapping the route in the Toast provider * Fixed filesystem directory sentinels showing in the sandbox file browser * Fixed projection cache hydration before appending later events # ACP backend and Fabro MCP server Source: https://docs.fabro.sh/changelog/2026-05-11 ## ACP agent backend Fabro introduced Agent Client Protocol as a first-class backend for agent execution. Instead of routing every agent through Fabro-owned provider adapters, workflows can hand a stage to a stdio ACP process and keep that process responsible for its own tools, context, and model auth. ACP runs also emit dedicated workflow events and projections, so run detail, logs, and stored events can distinguish protocol activity from API-backed model calls. This gives teams a clearer integration path for custom agent runtimes while keeping normal Fabro run lifecycle, checkpoint, and validation behavior. ## Fabro MCP server Fabro now ships a stdio-based Model Context Protocol server, so MCP clients can manage workflow runs through the authenticated `fabro` CLI. It reuses normal CLI server targeting, OAuth refresh, dev-token and local-server handling, proxy behavior, and storage configuration instead of requiring a separate MCP authentication flow. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro mcp init claude ``` You can also print client configuration JSON or start the server directly: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro mcp config fabro mcp start ``` ## Run management from MCP clients The MCP server exposes structured tools for creating runs, searching runs, reading run events, interacting with pending human questions, and waiting for runs to finish. MCP-created runs use the same manifest construction and override semantics as CLI-created runs, so workflow paths, goals, inputs, labels, model settings, sandbox settings, and dry-run options behave consistently. This lets agent tools orchestrate Fabro runs without scraping CLI output or hand-rolling HTTP clients. ## More * Added `fabro mcp start` to launch the MCP server over stdio * Added `fabro mcp config` to print MCP client configuration JSON * Added `fabro mcp init ` for Claude, Cursor, and Windsurf client setup * Added ACP execution events and stored projections for protocol sessions * Added validation for ACP backend configuration on workflow nodes * Added sandbox stdio support for local, Docker, and test-support providers * MCP run tools can create multiple runs, apply scalar input overrides, attach labels, start or stage runs, and return structured run summaries * MCP event tools support category, event type, text, timestamp, direction, and pagination filters * MCP interact tools support cancelling, archiving, unarchiving, inspecting questions, answering questions, and sending run messages * Fixed deleting terminal runs so successful completions are not followed by cancelled failure events # Managed run branches and validation fixes Source: https://docs.fabro.sh/changelog/2026-05-12 ## Managed run branch controls Fabro-managed Git behavior can now be controlled at the run level instead of through provider-specific sandbox switches. Workflows can opt out of automatic cloning, run branch setup, branch pushes, metadata branch writes, and metadata pushes while keeping the default behavior unchanged for existing runs. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.clone] enabled = false [run.run_branch] enabled = false [run.meta_branch] enabled = false ``` This is useful for workflows that prepare their own workspace, run against already-mounted files, or should avoid pushing Fabro-managed branches back to GitHub. Pull request creation still validates that a pushed run branch is enabled before it proceeds. ## More * Added `[run.clone]`, `[run.run_branch]`, and `[run.meta_branch]` settings for clone, branch, and metadata branch behavior * Added provider-scoped `[llm.providers..models.]` settings foundations for configurable provider and model catalog data * Added typed provider `extra_headers` settings for gateway-backed LLM providers * `fabro validate path/to/workflow.fabro` now discovers a sibling `workflow.toml` when it points back to the same graph and supplies `[run.inputs]` * Fixed local sandbox startup on NixOS by resolving `bash` through `PATH` instead of requiring `/bin/bash` * Fixed ACP stdio handling so a clean process exit after the final response is tolerated * Fixed templated `.fabro` validation by ignoring unrelated sibling `workflow.toml` files in the same directory # Configurable LLM providers, Slack reviews, and Daytona Dockerfiles Source: https://docs.fabro.sh/changelog/2026-05-13 **Run failure payloads now use structured failure objects.** Terminal run failures emit a nested `failure` value instead of relying on flat error, cause, or reason fields. To migrate: 1. Regenerate API clients from the current OpenAPI schema. 2. Read terminal diagnostics from `failure.reason`, `failure.causes`, `failure.category`, and related structured fields. 3. Treat stage-level failure strings and terminal run failures as separate API surfaces. ## Configurable LLM providers and models Fabro can now merge LLM provider and model catalog entries from settings. Teams can add OpenAI-compatible gateways, route through provider proxies, attach typed extra headers, map a Fabro model ID to a provider-specific `api_id`, declare model controls, and set per-speed pricing without waiting for a new built-in catalog entry. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.proxy] adapter = "openai_compatible" base_url = "https://llm-gateway.example.com/v1" [llm.providers.proxy.auth] credentials = ["env:ACME_GATEWAY_API_KEY"] [llm.providers.proxy.extra_headers] x-portkey-api-key = { env = "PORTKEY_API_KEY" } x-portkey-config = { literal = "@bedrock-prod" } [llm.providers.proxy.models."team-code-large"] api_id = "provider-wire-model-name" default = true ``` Provider values exposed by configuration, model routing, and the API are provider ID strings. Built-in names like `anthropic`, `openai`, and `gemini` still work, and custom IDs such as `proxy` now work wherever the selected catalog defines them. ## Slack review messages with context Slack interview prompts now include the upstream stage response and an "Open in Fabro" link when the server has a web URL. Reviewers can see the plan summary or other context directly in Slack before choosing an answer. Multi-button Slack gates also use unique button action IDs, fixing the `invalid_blocks` rejection that could prevent approval messages from appearing at all. Existing in-flight buttons remain compatible while new messages use the safer suffixed action IDs. ## Path-based Daytona Dockerfiles Daytona snapshot configuration can now reference Dockerfiles by path from project or workflow TOML. Fabro resolves the path relative to the declaring TOML file, bundles the Dockerfile into run manifests, and rewrites it to inline content before sandbox creation. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.sandbox.daytona.snapshot] dockerfile = { path = "./Dockerfile" } ``` ## More * Run failures now preserve public messages, source-chain causes, classifications, actor/signature metadata, and redacted exec output tails in one structured contract * Billing APIs now expose provider, model, and speed identity so standard and fast usage can be priced separately * Model catalog responses now use open-ended provider IDs instead of a closed provider enum * Added catalog-aware LLM request control validation and per-speed billing lookup * Added support for `dockerfile = { path = "..." }` in Daytona snapshot settings * Added structured diagnostics for terminal run failures and failed-run commit state * Fixed Graphviz parsing so multi-line DOT attribute blocks no longer require commas between every attribute * Fixed multi-button Slack interview messages being rejected by Slack because every button shared the same `action_id` * Built-in provider and model metadata now comes from settings-shaped catalog TOML files * OpenAI-compatible provider aliases can use canonical catalog model IDs for billing while preserving provider-specific `api_id` values on the wire * Public contribution docs now allow small outside pull requests as well as issue-first contribution paths # Optional LLM setup and Daytona volumes Source: https://docs.fabro.sh/changelog/2026-05-14 ## Skip LLM setup during install Fabro installs no longer require LLM credentials up front. The web installer has an explicit "Skip LLM setup" action, and the CLI supports the same flow for non-interactive installs. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro install --non-interactive --skip-llm --github-strategy token --github-username acme-dev ``` Skipping marks the LLM step complete without writing provider secrets. Later LLM-dependent workflows still fail with the normal provider-not-configured behavior until credentials are added. ## Daytona volume mounts Fabro-managed Daytona sandboxes can now attach existing Daytona volumes from run configuration. This gives workflows a provider-owned place for persistent state such as credentials, caches, datasets, or other files that should survive ephemeral sandbox lifecycles. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[run.sandbox.daytona.volumes]] volume_id = "vol_123" mount_path = "/home/daytona/.cache" ``` Fabro does not create or manage the volume lifecycle. Create the volume in Daytona first, then reference its `volume_id` from workflow, project, or user run settings. ## More * `PUT /install/llm` now accepts an empty provider list to record an intentionally skipped LLM step * `/install/finish` now accepts completed installs with no LLM vault credentials * Sandbox runtime layout metadata now records provider workspace, repository root, primary repository path, and workspace symlink information * Added non-interactive `fabro install --skip-llm` * Interactive `fabro install` now asks whether to configure LLM providers before provider selection * Docker and Daytona clone primary GitHub repositories into provider-owned repository roots and keep execution under the workspace symlink * Added `[[run.sandbox.daytona.volumes]]` passthrough for attaching pre-created Daytona volumes * Added Venice as a built-in catalog provider with OpenAI-compatible routing * Preflight now skips LLM client initialization for workflows with no LLM nodes * `/models` listing now paginates before cloning returned model rows # Agent sessions, run relationships, and sandbox visibility Source: https://docs.fabro.sh/changelog/2026-05-16 **Failure diagnostics now use the richer `{ reason, detail }` shape.** API clients reading terminal run failures should regenerate and read structured fields instead of parsing display strings. To migrate: 1. Regenerate clients from the current OpenAPI schema. 2. Read public summaries from `failure.reason`. 3. Read source chains, categories, signatures, and redacted command output from `failure.detail`. ## Server-backed agent sessions Fabro now has durable server-backed agent sessions with persistent session records, turns, transcripts, event replay, and SSE turn streaming. The first slice is local to the same-machine server target, but it gives API clients and the CLI a durable session surface instead of treating every agent interaction as a one-off process. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro session -p "Inspect this checkout and summarize the failing tests" ``` Sessions store messages and turn events under Fabro storage, reject conflicting active turns, validate local working directories, and support interrupt requests through the server. ## Run relationships and pull request links Runs can now be connected with orchestration-only parent links without merging them into fork or rewind lineage. You can create child runs, filter by parent, link or unlink parent runs, and keep historical parent references even if a parent run is deleted. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run hello --parent 01KRTKP5DJJ4EV6T7QSB081Z1N fabro parent link child-run parent-run fabro pr link run-id https://github.com/fabro-sh/fabro/pull/123 ``` Pull request associations are event-sourced too. Existing runs can link, replace, view, and unlink GitHub PR URLs while Fabro-created PR flows continue to work as before. ## Chats in the web app The web app now includes `/chats/new` and `/chats/:id` routes for the emerging agent chat surface. The implementation ports the validated prototype into the real app shell, using assistant-ui rendering, scripted streaming replies, markdown, tool-call rendering, and a StrictMode-safe first-message handoff. This is the first web-facing layer over the new session primitives. It keeps the existing `/start` entry point for now while giving the product a dedicated conversation surface. ## More * Added session create/list/read/update/delete, turn submission, event replay, interrupt, and session-scoped tool endpoints * Added parent run fields and `PUT`/`DELETE /api/v1/runs/{id}/parent` * Added pull request link and unlink endpoints for existing runs * Added `GET /api/v1/health` alongside the root `/health` probe * Sandbox details now expose provider dashboard links and network egress/ingress policy * Added `fabro session -p ` for server-backed local agent sessions * Added `fabro parent link` and `fabro parent unlink` * Added `--parent` to `fabro run`, `fabro create`, and `fabro ps` * Added `fabro pr link`, `fabro pr unlink`, and updated `fabro pr view` for stored PR associations * Template diagnostics now preserve source files, line/column spans, node IDs, and attribute context through CLI and server validation * `fabro validate` now treats undefined variables inside `@file` prompts as warnings in structural mode, matching inline prompt behavior * Custom provider and model `agent_profile` overrides now control profile-specific behavior for project memory, sessions, and runtime agent routing * Added opt-in Ollama and LiteLLM catalog providers * Sandbox tabs now show provider links and network policy where Fabro can assert them * Generated API client output is marked as generated for GitHub language and code-quality tooling * Fixed custom provider IDs in `fabro exec` and provider-filtered model listing * Fixed Daytona clone setup by moving Daytona repository roots under `/home/daytona/repos` * Fixed template validation so imported prompt files with undefined inputs produce warnings instead of hard errors # Run detail summaries and MCP parent tools Source: https://docs.fabro.sh/changelog/2026-05-17 **Session creation now requires typed permissions.** `POST /api/v1/sessions` requires `permissions` to be one of `read-only`, `read-write`, or `full`, and session records always return a non-null permissions value. To migrate: 1. Send an explicit `permissions` value in every `CreateSessionRequest`. 2. Regenerate clients so the generated type uses the `PermissionLevel` enum or union. 3. Clear old local dev session records with `"permissions": null` if they fail to deserialize. ## Run detail overview and children Run detail now surfaces more run state without forcing you to tab-hop. The Overview tab includes a summary panel for created-by, changes, sandbox, cost, and artifacts, while pull request links are promoted into a header pill that stays visible across tabs. Parent and child relationships also have a dedicated Children tab. It reuses the run list row layout, shows a zero state when no child runs exist, and includes a refresh action that updates both the child list and the parent count badge. ## MCP run orchestration parity Fabro MCP tools now understand parent and child run relationships. MCP clients can create child runs, search direct children, link or unlink an existing run's parent, and receive `parent_id` plus `children_count` in run summaries. MCP clients can also send a bare `interrupt` action through `fabro_run_interact`. This pauses an active API-mode agent round without requiring follow-up steering text in the same tool call. ## More * `POST /api/v1/sessions` now rejects missing or invalid `permissions` with `422 Unprocessable Entity` * Run summaries now include `children_count` for parent-aware UI and MCP tools * Generated TypeScript clients now expose typed `PermissionLevel` * ACP and workflow template rendering now resolve static MiniJinja `{% include %}` partials relative to the file being rendered * Manifest validation now bundles static prompt and goal include dependencies so `fabro validate` can resolve them * Provider auth mode, primary auth header policy, billing policy, agent profile, base URLs, and probe markers now live in provider catalog data * LLM readiness checks now report adapter construction failures without making catalog parsing adapter-specific * The Children tab uses the wider run detail layout and hides the count badge when the value is zero * Fixed MiniJinja include partials failing with `template not found` when the partial lived next to the rendered prompt or goal file * Fixed Children tab layout narrowing compared with the rest of run detail * Fixed zero-count Children tab badges adding visual noise # Strict agent backends and explicit credentials Source: https://docs.fabro.sh/changelog/2026-05-18 **Agent backends are now strictly `api` or `acp`.** The legacy `cli` backend and `acp_command` attribute are rejected, and ACP nodes can no longer use API-only model/provider controls. To migrate: 1. Replace `backend="cli"` with API-backed execution or an explicit ACP process. 2. Replace `acp_command="..."` with `acp.command="..."` for shell command strings. 3. Use `acp.config="..."` for JSON stdio ACP configs. 4. Move `model`, `provider`, `reasoning_effort`, `max_tokens`, and `speed` onto API-backed nodes only. **Vault credential refs and secret schemas are explicit.** Provider auth refs now use `env:` for process environment lookup and `vault:` for server-owned vault lookup. API-key secrets are stored as `token`, OAuth records as `oauth`, and file material as `file`. To migrate: 1. Replace `credential:` refs with `vault:`. 2. Re-create old `environment` or `credential` secrets with `token`, `oauth`, or `file` schemas. 3. Use `fabro secret set NAME value` for API keys and PAT-style secrets. ## Strict API and ACP backends Agent execution now has a clear two-backend contract. API-backed stages use Fabro-owned model/provider auth and request controls, while ACP-backed stages launch a user-supplied stdio process that owns its own auth, tools, and protocol behavior. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [label="Implement", backend="acp", acp.command="python3 tools/fake_acp_agent.py"] ``` This removes the old CLI runtime path and prevents ACP execution from accidentally resolving or forwarding provider credentials. ACP events and projections now report process identity such as `command` and optional `config_name` rather than provider/model metadata. ## Explicit vault-backed provider credentials Fabro now separates process environment credentials from server-owned vault credentials. Provider auth configuration can declare env and vault refs explicitly; current server runtimes use vault-backed provider credentials, while standalone library/CLI flows can opt into env-backed sources. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.proxy.auth] credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"] ``` OpenAI Codex OAuth credentials now live under `vault:OPENAI_CODEX`, and provider catalogs use the same explicit credential-source vocabulary as custom provider settings. ## More * Removed old `environment` and `credential` secret schemas in favor of `token`, `oauth`, and `file` * Session, run, and provider auth paths now consume typed credential sources and typed vault entries * ACP configuration is now split into `acp.command` and `acp.config` * ACP is restricted to `agent` nodes and rejects API-only model/provider attributes * Import and stylesheet propagation now preserve the strict backend contract * Server-created detached runs now persist inferred project and workflow names in run metadata * Manifest preparation now prefers explicit `[workflow].name`, then graph name, then workflow slug when backfilling names * Legacy vault records created before the explicit credential schema split now migrate correctly at server startup * Provider catalogs now declare process-env and vault credential refs explicitly * Web stage model extraction now follows the new API/ACP event contract * Settings are now split into General, Integrations, Security, and Storage pages instead of one oversized overview # Settings pages, resource visibility, and pair sessions Source: https://docs.fabro.sh/changelog/2026-05-20 ## Settings built for server operations The web settings area is now split into focused pages instead of one dense overview. General, Integrations, Security, Storage, Models, Secrets, and Resources screens give operators a clearer place to inspect provider readiness, Slack status, stored secrets, storage usage, and host capacity. The Resources page adds CPU, memory, and disk visibility from the server so self-hosted installs can spot capacity pressure without leaving Fabro. Storage stats now live with storage settings, values are easier to scan, and model providers collapse when they are not configured. ## Pair with running agent stages Fabro now has the first server API for live pair sessions on active agent stages. Clients can discover pairable targets, start a pair window, send messages, and read compact transcript entries for the active session. This builds on the existing steering flow while preserving the run's event stream. ACP-backed stages can also receive steering messages, so interactive control works across both API and ACP agent execution paths. ## More * Added provider listing for the Models settings page * Added system resource endpoints for CPU, memory, and disk capacity * Added run pair endpoints for pair status, start/end, messages, and transcript reads * `fabro rm` now prints the full run ID when removing a run * Added ACP steering support for active agent stages * Dotted Fabro graph attributes such as `acp.command` now render and validate correctly * Template diagnostics now resolve source locations for nested prompts, partials, and workflow-root includes * Workflow-root template partials can now be included from prompt and goal files * Fixed human gate stages ignoring configured timeout defaults * Fixed Fabro-managed storage totals missing files under nested storage roots * Fixed Daytona network policies being reported from incomplete sandbox details * Daytona sandboxes are now labeled as Fabro-managed for easier cleanup and identification * Run tables now show the creator avatar in the "Created by" cell * Slack integration status is visible from the Integrations settings page * System resource and storage values now use cleaner rounding and thousands separators # Run-aware Ask Fabro, stage timing, and context Source: https://docs.fabro.sh/changelog/2026-05-21 **Generic agent sessions are now run-bound Ask Fabro sessions.** The standalone session surface has moved behind run-scoped sessions so questions always have a run to inspect. To migrate: 1. Replace `fabro session -p "..."` with `fabro ask --prompt "..." `. 2. Create API sessions through `POST /api/v1/runs/{id}/sessions`. 3. Read session detail, events, turns, and interrupts through the returned session ID. **Timing fields now use `StageTiming`.** API clients that read old duration or elapsed fields should regenerate and use the new timing objects. To migrate: 1. Regenerate clients from the current OpenAPI schema. 2. Read elapsed clock time from `wall_time_ms`. 3. Read summed stage work from `active_time_ms`; it can exceed wall time when parallel stages run. ## Ask Fabro understands runs Ask Fabro now starts from a specific run instead of a free-floating chat session. The CLI can answer read-only questions about a run, and the web app has the run-scoped session APIs needed for the sidebar experience. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro ask --prompt "Why is this run blocked?" 01K... ``` Run-bound sessions give Ask Fabro direct access to run metadata, events, and controls while keeping the conversation attached to the run history. Session listings, details, event replay, streaming turns, and interrupt handling all use the owning run as the context boundary. ## Clearer stage timing and context Run and stage timing now separates elapsed wall time from active work time. This makes parallel workflows easier to reason about: a run can spend ten seconds on the clock while accumulating more than ten seconds of active stage work across branches. The Stages view also has a Context tab. You can inspect context values, stage notes, and structured stage output next to the thread or logs that produced them. ## More * Added run-bound Ask Fabro session endpoints and session metadata on run summaries * Added `StageTiming` objects with `wall_time_ms` and `active_time_ms` * Pair sessions now target active stages and expose the `fabro_run_pair` MCP tool * Added `fabro ask --prompt ` for run-scoped questions * Added `[run.agent] fabro_tools = true` as the opt-in for built-in Fabro run-management tools * Configured artifact globs are captured once per run so later config changes do not affect cleanup * Artifact collection now follows symlinked artifact roots * Fixed active stage duration displays measuring from the wrong timestamp * Fixed Ask Fabro sidebar user bubbles and composer spacing * Fixed Daytona network policies being derived from the wrong layer * Run headers now include detail popovers for workflow, repository, and related metadata * Billing views now compute in-flight LLM costs on read and show token cache breakdowns on hover * Run overview shows a sandbox status dot * Storage, models, and resources settings pages now keep related values grouped together # Ask Fabro sidebar, scoped run tools, and agent todos Source: https://docs.fabro.sh/changelog/2026-05-22 **Fabro run-management tools now require an explicit per-run opt-in.** Existing workflows that expected agents to create, search, inspect, or control Fabro runs must enable the tools in run configuration. To migrate: 1. Add `[run.agent]` to the workflow, project, or user run settings that should expose these tools. 2. Set `fabro_tools = true`. 3. Leave the setting unset or `false` for runs that should not grant run-management capabilities. ## Ask Fabro ships in the run sidebar Ask Fabro is now wired into run pages end to end. The sidebar uses real run session APIs, streams assistant responses, can call scoped run tools, and starts with a welcome state that offers concrete example prompts. Each turn is oriented with a fresh run snapshot so answers start from the current run status, stage state, and recent activity. The panel is resizable, persists its session per run tab, renders tool calls in the narrow sidebar, and hides internal reasoning deltas from the conversation. ## Scoped Fabro tools for workflow agents Built-in Fabro run tools are now gated twice: first by the resolved `[run.agent] fabro_tools` setting, then by the worker JWT scope. The server remains authoritative for authorization, while workers only register the tool schemas when their token includes the run-tool scope. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent] fabro_tools = true ``` Ask Fabro also uses an effective read-only tool policy so it only advertises tools it can actually call. Hidden or denied tools are blocked before execution, which keeps model-visible capabilities aligned with server policy. ## Event-sourced agent todos OpenAI and Anthropic agent profiles now persist todo and task changes as run events. OpenAI `update_plan` calls and Anthropic task tools emit `todo.created`, `todo.updated`, and `todo.deleted`, then replay into the run projection. This gives Fabro a durable view of agent task state instead of treating todo updates as transient model-side bookkeeping. The web app refreshes run state when todo events arrive, so projected task lists can update live. ## More * Run projections now include event-sourced todo lists from agent task tools * Ask Fabro readiness and default model data are exposed on run summaries * Fabro workers now register run-management tools only when their worker token carries `agent:run_tools` * `fabro_run_create` now supports `goal_file` and preserves it as a file-backed run goal * `fabro_run_create` now accepts the advertised workflow-string shorthand in MCP clients * New `fabro_run_get` tool provides read-only run inspection without mutating workflow state * `run.checkpoint.skip_git_hooks` can bypass Git hooks during checkpoint commits * Pair user and system messages now appear in the stage Thread tab and activity timeline * Fixed Ask Fabro session responses not rendering in the sidebar * Fixed Ask Fabro markdown spacing and heading sizes in narrow panels * Fixed reasoning deltas leaking into Ask Fabro output * Fixed archived run board updates not refreshing active and archived queries consistently * Fixed retryable mid-stream LLM failures being treated as terminal agent errors * Fixed agent edit operations by reading raw sandbox file contents before applying patches * The run stage sidebar can now collapse to an icon rail while preserving status indicators * Ask Fabro example prompts now use fuller, action-oriented text * Agent context observability now records memory, activated skills, and MCP tools as run events * Anthropic agent sessions now support task lookup and task reminders * Run board archived columns now stay in a predictable order after archive and unarchive actions * OpenAI `apply_patch` handling is now compatible with Codex-style patch grammar # Runs list, Slack notifications, and structured outputs Source: https://docs.fabro.sh/changelog/2026-05-23 **Run lifecycle status values changed for API and CLI consumers.** The old `queued` state is now split into `pending` and `runnable`, and approval state is represented explicitly. To migrate: 1. Update clients that switch on `queued` to handle `pending` and `runnable`. 2. Use the approval fields on run summaries and projections when deciding whether a run is waiting for human action. ## Runs are easier to scan, sort, and recover The runs list now uses a denser table layout with server-side sorting, pagination, and a redesigned toolbar. You can sort by repo, title, workflow, and changes without waiting for the browser to process the full list. Failed and dead runs can now be retried from the run UI and API instead of being recreated by hand. The same run lifecycle work also separates pending runs from runnable runs, so approvals and blocked start conditions are clearer in the product and the API. ## Batch run actions The runs list now supports multi-select archive and unarchive, with matching batch lifecycle endpoints for API clients. Workspace preferences remember the runs view choices you make, so list layout and filtering stay consistent across visits. Run size is now exposed on summaries and projections, which gives the UI and API a shared way to distinguish small, medium, and large runs. The web app uses that signal in the run header and list surfaces where scale matters for triage. ## Agent stages expose more of what is happening Agent stage pages now include a stage insights sidebar, context-window snapshots, and richer stage projections for todos, subagents, skills, MCP tools, reasoning effort, speed, and model usage. This makes it easier to see why a stage is behaving a certain way without digging through raw events. Structured output validation is also stricter. Agent and prompt stages with `output_schema` now validate model output in the same context and ask the model to repair invalid responses before the workflow moves on. ## Slack run lifecycle notifications Workflows can now send run lifecycle updates to Slack through `[run.notifications]`. Teams can keep a channel informed when a run starts, finishes, or fails without wiring a separate webhook workflow. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.notifications.deploys] enabled = true provider = "slack" events = ["run.started", "run.completed", "run.failed"] [run.notifications.deploys.slack] channel = "#deploys" ``` ## More * New `POST /api/v1/runs/archive` and `POST /api/v1/runs/unarchive` endpoints apply lifecycle actions to multiple runs at once * Run summaries and projections now expose run size buckets * Stage projections now include agent todos, subagents, skills, MCP tools, context-window data, and model usage * `output_schema` validation errors now include structured repair context for agent and prompt stages * Named environments replace run-scoped sandbox config, with automatic migration for confidently supported legacy `[run.sandbox]` files * Legacy sandbox config migration writes a backup next to the original file before rewriting * Ambiguous legacy sandbox config now fails with a targeted migration message * Interview options now carry metadata through the API, console, Slack blocks, and agent question tools * Fixed the Quick Start screen flashing when navigating to `/runs` with archived preferences * Fixed implement-plan pull requests advancing before CI checks completed * Fixed an event append race covered by a regression test * Added a Waterfall view for run events with hover popovers and duration breakdowns * Added live timing overlays for in-flight runs * Generated run titles now use the lighter `small_default` model role asynchronously * Settings pages now show provider and integration logos, including upcoming Teams, Discord, and Jira integrations * Stage insights sidebar polish improved visit syntax, spacing, and quiet-state presentation # Run tools, sandbox controls, and richer run triage Source: https://docs.fabro.sh/changelog/2026-05-24 **Context-window data moved into `agent.message`.** The standalone `agent.context_window.snapshot` event was removed, and context-window details now travel with the assistant message that produced them. To migrate: 1. Stop listening for `agent.context_window.snapshot`. 2. Read `context_window` from `agent.message` properties or from the stage projection. ## Workflow agents get the full Fabro tool catalog Workflow agents that opt in to Fabro run tools now receive the same run-management catalog exposed to human MCP clients. That includes `fabro_run_pair`, so an agent can start a pairing session, send messages, and read transcripts without needing a separate user-only route. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent] fabro_tools = true ``` The run projection now also reports the effective agent tools for a stage. The UI can show which native, MCP, and skill tools were actually available instead of guessing from configuration alone. ## Sandbox providers can be managed centrally Operators can now enable or disable individual sandbox providers at the server level. Fabro enforces the policy during run admission, preflight, and launch, so a disabled provider fails before a run starts work. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.sandbox.providers.docker] enabled = true [server.sandbox.providers.daytona] enabled = false ``` The web app also gained a Settings > Sandboxes page for provider visibility. That gives teams a clearer place to inspect the configured sandbox backends without digging through server config files. ## Run triage is faster from list and stage views The runs list bulk toolbar now includes Delete for selected runs, with a matching batch API that returns per-run results. Run detail pages gained more direct actions, including Copy run ID, and cancelled runs can be resumed or retried instead of being recreated by hand. ```http theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} POST /api/v1/runs/delete ``` Stage status, model, and event details are now available from hover popovers across the run overview graph, stage sidebar, and stage events toolbar. Loaded stage events also gained Copy and Download actions, which makes it easier to move raw event output into an issue, support thread, or local investigation. ## More * New `POST /api/v1/runs/delete` endpoint deletes multiple runs and returns per-run results plus a summary * Stage projections now include `agent_tools` with the effective native, MCP, and skill tools for the stage * `AgentMessageProps.context_window` now carries context-window data directly on `agent.message` * Pair routes now accept run-management actors, allowing workflow agents to use `fabro_run_pair` * Added legacy SSE MCP transport support for MCP servers that still expose SSE instead of streamable HTTP * `[run.agent] fabro_tools = true` now exposes all seven Fabro run-management tools to workflow agents * Server sandbox provider policy now blocks disabled providers at admission, preflight, and launch * Fixed unfinished stages being left open after a run failure * Fixed local server runs being unable to access project skills * Fixed root-stage todo projections being overridden by child OpenAI plan events * Fixed cancelled projections for failed commands * Fixed stale run conclusions persisting after resume * Fixed cancelled runs being blocked from resume and retry flows * Removed a non-functional demo-mode Connect dropdown * Suppressed the redundant Starting pill on the runs list * Added Settings > Sandboxes * Added the New Automation form and refreshed the Secrets form layout * Added a run size badge to the run header * Added tooltips and a more compact presentation for the stage Tools list * Improved Slack setup and lifecycle notification messages * Unconfigured provider rows now link directly to a prefilled secret form * Removed retired OpenAI models from the catalog * Improved muted stage status pill contrast * Collapsed the stage events search field behind an icon by default * Updated the Automations nav icon * Moved event export actions to the Debug sub-tab # Sandbox inventory, model tests, and run approvals Source: https://docs.fabro.sh/changelog/2026-05-25 **Optional integration secrets are now vault-only in the server runtime.** GitHub App, Slack, Daytona, Brave Search, and LLM provider secrets no longer fall back to process environment variables or `server.env` when Fabro is running as a server. To migrate: 1. Store optional integration credentials with `fabro secret set`. 2. Re-run the GitHub App install flow if you want Fabro to write GitHub App secrets into the vault for you. 3. Remove any server-runtime dependency on optional integration secrets coming from process env or `server.env`. **`POST /api/v1/demo/toggle` was removed.** Demo mode remains available through the `X-Fabro-Demo: 1` request header or a manually set `fabro-demo=1` browser cookie. To migrate: 1. Stop calling `POST /api/v1/demo/toggle`. 2. Send `X-Fabro-Demo: 1` on demo requests that should use fixture-backed behavior. ## Provider-backed sandbox inventory Fabro can now list managed sandboxes directly from configured providers instead of relying only on run projections. The new inventory API queries Docker and Daytona, returns partial provider failures in metadata, and can retrieve a specific provider-native sandbox ID when exactly one provider owns it. ```http theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} GET /api/v1/sandboxes GET /api/v1/sandboxes/{id} ``` This gives operators a live view of the sandbox fleet even when a run record is missing, stale, or no longer the easiest way to answer "what is running right now?" ## Settings can test models in place Settings > Models now has a real model table, default and priority provider labels, and a Test models sweep that checks configured providers from the UI. The server exposes a provider test endpoint that returns per-provider results without turning one provider failure into a failed request. ```http theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} POST /api/v1/providers/test ``` The Settings area also gained a linked overview page and a cleaner navigation structure. Model configuration, integrations, monitoring, security, storage, and sandboxes now sit in a more predictable operations surface. ## Run approvals are available everywhere Pending runs can now be approved or denied from the CLI and MCP run tools, not only from the web app. That lets operators and agents move approval-gated workflows forward from automation scripts, terminals, or MCP clients. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro approve fabro deny --reason "Not approved for execution" ``` The runs list also exposes approval and delete actions closer to the rows you are already reviewing. Children now use the shared runs list view, with size sorting, per-row actions, and a simpler sub-tab toolbar. ## More * New `GET /api/v1/sandboxes` endpoint lists Fabro-managed sandboxes directly from configured providers * New `GET /api/v1/sandboxes/{id}` endpoint retrieves a managed sandbox by provider-native ID * New `POST /api/v1/providers/test` endpoint tests every configured LLM provider with provider-level failures returned in the response body * Removed `POST /api/v1/demo/toggle`; demo mode is now header or cookie driven * Added `fabro approve` and `fabro deny` commands for pending workflow runs * Added `fabro graph --allow-invalid` for rendering DOT graphs while keeping validation diagnostics visible * Added the bundled `goal` workflow for auditing and continuing active work * Structured output docs now clarify fallback behavior when a model cannot produce valid schema-conforming output * Fixed stage inference and tool timing values always reporting as zero * Fixed Settings > Monitoring showing active runs instead of scheduler slots used * Fixed the runs list pager appearing when pagination was not relevant * Fixed run diff stats and run size chips displaying inconsistently across board and list surfaces * Added `gemini-3.5-flash` to the model catalog * Added size sorting, per-row actions, and a bulk More menu to run lists * Added priority-provider and default-model labels to Settings > Models * Removed the redundant time filter and archived toggle from the run Children sub-tab # Integration readiness and run filters Source: https://docs.fabro.sh/changelog/2026-05-26 ## Integrations show runtime readiness Settings > Integrations now reflects what the server can actually use at runtime instead of only showing static config flags. Fabro checks server settings, vault credential presence, and live Slack Socket Mode connection state, then reports whether each integration is disabled, missing credentials, configured, connecting, connected, or in error. ```http theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} GET /api/v1/system/integrations ``` This makes setup issues easier to diagnose. A vault-configured Slack integration no longer appears disabled just because the old settings-only check could not see the runtime connection state. ## Runs list filters get more precise The `/runs` toolbar now supports selecting multiple statuses at once, so you can keep related states visible without repeatedly changing one filter. The list also gained a Created by avatar column, which helps separate user-created, agent-created, and system-created work when a workspace is busy. Together with the recent size and action columns, the runs list is becoming a denser triage surface. You can narrow the list by lifecycle state, scan ownership, and act on rows without opening each run first. ## More * New `GET /api/v1/system/integrations` endpoint returns runtime integration status for GitHub and Slack * Fixed content security policy headers for the hosted web app * Fixed GitHub avatars being blocked by the content security policy * Fixed terminal origin checks when a browser omits the default port * Fixed GitHub App post-install copy so it works across installation origins * Fixed agent session compaction dropping accumulated usage totals * Fixed the runs board not filling the viewport, which made horizontal scrolling work only inside the board itself * Removed retired GPT-5.2 and GPT-5.3 catalog entries * Added a multi-select run status filter to the `/runs` toolbar * Added a Created by avatar column to the runs list * Rebuilt tooltips, hover cards, and toasts on Radix UI and Sonner for real keyboard accessibility and smarter positioning * Compacted the Test column on Settings > Models # Run variables and the Automations API Source: https://docs.fabro.sh/changelog/2026-05-27 **Inbound IP allowlisting removed.** Fabro no longer includes an in-process inbound source-IP allowlist for the web UI, API, static assets, or GitHub webhook routes. This is a hard removal: existing `settings.toml` files that still contain `[server.ip_allowlist]` or `[server.integrations.github.webhooks.ip_allowlist]` now fail as unknown fields. Move any source-IP restrictions to deployment-layer controls such as a reverse proxy, firewall, VPN, Tailscale ACLs, or platform ingress policy. GitHub webhook HMAC signature verification, GitHub username allowlists, and sandbox egress CIDR allow lists are unchanged. **Devcontainer support removed.** Fabro no longer reads repository devcontainer metadata to derive sandbox snapshots, environment variables, or lifecycle commands. Define sandbox environments directly with `image.dockerfile` or `image.docker` instead. **`image.ref` renamed to `image.docker`.** Docker image selection in environment settings now uses `image.docker`. Daytona only accepts `image.dockerfile` for custom snapshots and continues to default to `daytona-medium` when no Dockerfile is configured. ## Server-managed variables Workflows often need non-sensitive values — regions, repository names, feature flags — that previously had to live in environment variables or be hardcoded into config. Fabro now has a first-class variables store: manage variables in Settings > Variables or via the new `fabro variable` CLI namespace, then reference them as `{{ vars.NAME }}` in run-owned config fields alongside the existing `{{ env.NAME }}` interpolation. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro variable set REGION us-east-1 fabro variable list ``` Unlike secrets, variables are intentionally readable — `list` and `get` show stored values — while `fabro secret` remains write-only. ## Automations API Automations now have a full REST API. `GET/POST /api/v1/automations` and `GET/PUT/DELETE /api/v1/automations/{id}` manage automation definitions with revision-checked writes, and `GET/POST /api/v1/automations/{id}/runs` lists an automation's runs or triggers a new one through its API trigger. Runs created by an automation record a reference to it, so you can trace where a run came from. ## More * New `/api/v1/variables` CRUD endpoints for server-managed variables * `Run.sandbox` now reports an explicit lifecycle state (`planned`, `initializing`, `ready`, `failed`), and sandbox detail, files, services, terminal, and VNC endpoints return a clear 404 until a sandbox instance actually exists * Succeeded runs can now be retried; manual retry covers all unarchived terminal runs * OpenAI default model selection now resolves to `gpt-5.5` and Gemini to `gemini-3.5-flash` * Daytona snapshot names are now deterministic identifiers computed by Fabro instead of user-supplied references, so Dockerfile contents never leak into snapshot names * Fixed the run timeline leaving the Initializing phase open when a run failed before execution started * Fixed the runs empty state being pushed to the bottom of the page # Automations in the web UI Source: https://docs.fabro.sh/changelog/2026-05-28 **Environment catalogs are now server-owned.** Environment definitions move out of project and workflow TOML into server-owned files, and config validation now rejects environment catalogs defined in project, workflow, or user config. ## Automations in the web UI The Automations area is now functional end-to-end and visible outside demo mode. The list page shows your real automations with create, edit, and delete actions (saves are revision-checked, so concurrent edits fail loudly instead of silently overwriting), and a Run button on each card triggers the automation and takes you to the new run. The detail page is rebuilt like a run page, with status chips, Edit and Run actions, and a runs panel showing the automation's history. On the backend, runs created through an automation's API trigger now start automatically instead of sitting in Submitted forever. ## More * Added `claude-opus-4-8` to the model catalog; the `opus` and `claude-opus` aliases now resolve to Opus 4.8 * Fixed OpenAI-compatible providers dropping raw freeform tool-call arguments, which left tools like `apply_patch` receiving `{}` instead of the patch text * Fixed context compaction separating tool results from the tool calls that produced them, which OpenAI rejects as orphaned outputs * Fixed compacted conversation content also appearing in the compaction summary at the preserve boundary # Scheduled automations Source: https://docs.fabro.sh/changelog/2026-05-29 **Top-level automation `enabled` flag removed.** Automations no longer have a master `enabled` gate — trigger-level `enabled` is now the sole activation control. The field is removed from the `Automation`, `CreateAutomationRequest`, and `ReplaceAutomationRequest` API schemas, and automation TOML with a top-level `enabled` key is rejected. ## Scheduled automations Automation schedule triggers now fire on their own. The server tracks each enabled schedule trigger's UTC cron expression and creates and starts a normal Fabro run whenever one comes due, using the same materialization path as API-triggered runs. Creating, editing, or deleting an automation takes effect immediately — no restart needed. A failed run attempt waits for the next cron occurrence rather than retrying in a loop. ## Create an automation from a run A run that worked is often the automation you wanted. The run actions menu now includes **Create automation from run**, which opens the new-automation form pre-populated from the run's workflow, repository, and settings. Runs that were already created by an automation show **View automation** instead. ## More * New `/api/v1/environments` CRUD endpoints manage server-side environment definitions; Dockerfiles must be inline (path references are rejected) * Automation runs start 5–15 seconds faster: repositories are cached as bare clones on the server instead of being recloned for every run * Added status, time, and repo filters to the runs list on the automation detail page * Restyled toasts to match the app theme * Removed the non-editable slug field from the automation edit page * Aligned automation toolbar controls and centered the Size column in the runs list * Fixed Dockerfile-based environments defined in the `[environments.*]` catalog failing at sandbox creation on Daytona * Fixed `/automations/{id}` links so the plural path is the canonical detail route # Workflows settings section Source: https://docs.fabro.sh/changelog/2026-05-30 ## Workflows settings section Settings navigation now groups Variables and Secrets under a new **Workflows** section between General and Administration, and Security moved under Administration next to Server. Run-facing configuration and server administration are now separated in the sidebar instead of interleaved. ## More * Fixed scheduled automation triggers not honoring their trigger-level enabled state # Installer sandbox provider selection Source: https://docs.fabro.sh/changelog/2026-06-02 **Slack now requires explicit configuration.** Slack no longer reports enabled or starts from vault tokens alone — an explicit `[server.integrations.slack]` table must be present in server settings. If you relied on vault tokens without a config table, add the table to re-enable Slack. ## Installer sandbox provider selection The web installer previously enabled all three sandbox providers (Docker, Daytona, and local) regardless of which runtime you picked. The wizard now enables only the runtime you select, plus local execution if the new **Allow local sandboxes** checkbox (checked by default) stays on. The unselected runtime is written to `settings.toml` as `enabled = false`, so it stays off instead of being defaulted back on by the config resolver. ## More * Fixed the automations page showing `No automations match ""` while loading or when the trigger filter excluded everything; loading, error, empty, and no-match states are now distinct, and a fresh workspace gets a "Create your first automation" panel # Embedded VNC session fix Source: https://docs.fabro.sh/changelog/2026-06-03 ## Embedded VNC session fix Fixed embedded sandbox VNC sessions failing to load: the viewer reads browser local storage, which the iframe sandbox policy was blocking. VNC previews inside run pages work again. # Workflow templates scoped to prompt and goal Source: https://docs.fabro.sh/changelog/2026-06-05 **Template rendering removed from most DOT attributes.** Node `label`, `model`, `provider`, `speed`, edge `label`, and `condition` are no longer rendered as templates — `{{ ... }}` and `{% ... %}` in those attributes is now literal text. Workflows that still use template syntax in a demoted attribute get a `detemplated_attribute` warning so you can migrate. `condition` routing expressions are unaffected; only the template pre-render step is gone. ## Workflow templates scoped to prompt and goal Full template rendering in DOT workflows is now limited to the two content fields that legitimately need it: the node `prompt` and the graph `goal`, which can reference `{{ inputs.* }}` and `{{ goal }}`. Every other graph, node, and edge attribute is a plain value. This makes workflow files easier to reason about — an attribute like `model` or `condition` always means exactly what it says, instead of potentially being a template that resolves to something else at run time. `output_schema` is now a plain string or `@file` reference rather than a template; `@file` references still inline the file's contents. # Workflow playground Source: https://docs.fabro.sh/changelog/2026-06-09 ## Workflow playground Building a workflow used to mean writing DOT by hand. The new `/playground` page lets you build one by describing it: chat with Ask Fabro on the right while a live canvas re-renders the workflow graph on the left. The canvas supports pan, zoom, fit-to-window, click-to-inspect node details, and a Play button that walks through the graph so you can sanity-check the flow before running anything. When you're happy with it, download the workflow as a `.fabro.zip` — including `workflow.fabro`, `workflow.toml`, and a README — or use **Run for real** to create an actual run and jump straight to its run page. ## More * Custom-named LLM providers now report their configured name in responses and error details instead of a hardcoded `anthropic`/`openai`/`gemini` label # Claude Fable 5 support Source: https://docs.fabro.sh/changelog/2026-06-10 ## Claude Fable 5 support Fabro now includes Anthropic's `claude-fable-5` in the built-in model catalog, with `fable` and `claude-fable` aliases. It supports the model's 1M context window, 128K max output, vision, tools, prompt caching, and native `reasoning_effort` levels without changing the default Anthropic model from `claude-sonnet-4-6`. Fable refusals are now handled through Fabro's provider-neutral fallback system. When Anthropic returns `stop_reason: "refusal"`, Fabro surfaces a content-filter LLM error with the refusal detail preserved and retries through `run.model.fallbacks` when configured. ## More * Added `claude-fable-5` to the Anthropic catalog at $10 / $50 per MTok input/output pricing * Added aliases `fable` and `claude-fable` * Kept `claude-sonnet-4-6` as the Anthropic default * Omitted deprecated Anthropic sampling parameters for Fable requests * Fable refusal responses now participate in configured model fallback chains * Refusals without a configured fallback surface as clear LLM content-filter errors # Completion cost estimates Source: https://docs.fabro.sh/changelog/2026-06-11 ## More * Completion responses now include catalog-estimated `cost_usd` and `cost_source` fields, including when a model alias is used # OpenRouter support Source: https://docs.fabro.sh/changelog/2026-06-12 ## OpenRouter support Using OpenRouter previously required custom provider configuration, and aggregator-specific cache and cost details were lost. OpenRouter is now an opt-in built-in provider with a curated model catalog, cached and reasoning token accounting, and authoritative provider-reported costs for blocking and streaming responses. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.openrouter] enabled = true ``` # Environment management in Settings Source: https://docs.fabro.sh/changelog/2026-06-13 **Environment `volumes` settings were removed.** Remove `volumes` from managed environment definitions; the field is no longer accepted or applied. ## Environment management in Settings Server-managed environments previously required API or file-level administration. The new **Settings → Environments** pages let you create, edit, and delete environments while configuring images, resources, environment variables, networking, and lifecycle behavior. Built-in environments are seeded during installation instead of every server start, and `default` is now an ordinary environment that you can delete. # Local environment working directories Source: https://docs.fabro.sh/changelog/2026-06-14 ## More * Added `cwd` to server-managed local environments so remote servers can choose an absolute host working directory; Docker and Daytona ignore this setting * Local runs now keep a submitted source directory only when it exists on the server and otherwise report how to configure environment `cwd` instead of trying to recreate a client-only path # Graph rendering for templated comments Source: https://docs.fabro.sh/changelog/2026-06-15 ## More * Fixed workflow graph rendering when a leading DOT comment contains template braces such as `{{ goal }}` # Amazon Bedrock support Source: https://docs.fabro.sh/changelog/2026-06-16 **Identifier and path settings no longer interpolate template tokens.** Model/provider selectors, Git author fields, SCM owner/repository fields, CLI target addresses, and `run.working_dir` are now literal strings. Replace any `{{ vars.* }}` or `{{ env.* }}` tokens in those fields with explicit values. ## Amazon Bedrock support Running AWS-hosted models previously required a custom gateway. Fabro now includes an opt-in Amazon Bedrock provider for the Converse API, with streaming, tools, prompt caching, reasoning, and models from Anthropic, Amazon, Meta, Mistral, DeepSeek, and other Bedrock families. Authentication supports the AWS default credential chain with per-request SigV4 refresh or a Bedrock API key. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.bedrock] enabled = true base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" ``` # MCP transport interpolation Source: https://docs.fabro.sh/changelog/2026-06-18 **Control-plane settings no longer interpolate template tokens.** Server listen, API/web URL, storage, object-store, and GitHub App identity fields are now literal strings. Use native overrides such as `FABRO_WEB_URL` and `FABRO_STORAGE_DIR` where deployment-time values are required. **A workflow goal can no longer reference itself.** A `{{ goal }}` token inside the graph goal now fails validation instead of passing through as literal text. Prompts may continue to reference the rendered goal. ## MCP transport interpolation Environment-dependent MCP commands, URLs, headers, and environment values previously carried unresolved tokens into the launched server. `fabro run` and `fabro exec` now resolve `{{ env.NAME }}` at the boundary where the MCP server starts, and a missing variable fails clearly instead of leaking the token as text. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent.mcps.search] type = "http" url = "{{ env.SEARCH_MCP_URL }}" ``` # Variables in workflow prompts and goals Source: https://docs.fabro.sh/changelog/2026-06-24 ## Variables in workflow prompts and goals Stored run variables previously worked in settings but were unavailable inside DOT prompts and goals. Node prompts, graph goals, imported subgraphs, and `@file` prompt/goal contents can now reference `{{ vars.NAME }}`; Fabro snapshots the server variable store when the run is created. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Deploy { graph [goal="Deploy {{ vars.SERVICE }}"] work [prompt="Deploy to {{ vars.REGION }}"] } ``` ## More * `{{ inputs.* }}` outside prompts and goals now reports a specific error explaining that inputs are template-only * Inline MCP entries now honor `enabled = false`, an explicitly empty `cli.exec.agent.mcps` set no longer falls back to run MCPs, and per-server `tool_timeout` values now apply # OpenRouter branding in model settings Source: https://docs.fabro.sh/changelog/2026-06-25 ## More * OpenRouter now displays its provider logo in **Settings → Models** instead of a letter fallback # Docker diagnostics and remote provider login Source: https://docs.fabro.sh/changelog/2026-06-26 ## More * `fabro doctor` now pings the Docker daemon when the Docker sandbox provider is enabled and reports actionable failure or timeout details * `fabro provider login --server ...` now reads the target server's catalog and tests credentials there, so you can log into providers that the local CLI does not know # Runtime security updates Source: https://docs.fabro.sh/changelog/2026-06-27 ## More * Updated the Rust `tar` runtime and React Router to patched releases that resolve disclosed security vulnerabilities # Safer hooks and managed MCP servers Source: https://docs.fabro.sh/changelog/2026-06-30 **Hook interpolation now fails closed.** A missing or unavailable `{{ env.* }}` or `{{ secrets.* }}` token in a command, URL, header, prompt, or model blocks the hook instead of firing it with an empty or partially resolved value. Ensure every referenced value is available where the hook runs. ## Safer hook interpolation Hook commands, URLs, headers, prompts, and models now carry typed interpolation through to execution instead of being flattened and reparsed. HTTP headers use the same narrow token syntax as other hook fields, and resolution failures consistently block command, HTTP, prompt, and agent hooks before they can act on incomplete data. ## More * New `GET`, `POST`, `PUT`, and `DELETE /api/v1/mcp-servers` endpoints manage a shared MCP catalog with ETag concurrency; read responses expose configured key names without returning stored values * Run configurations can reference a server-managed MCP definition with `id = "..."` instead of repeating its transport settings * Workflow variables now live in SQLite; existing `variables.json` data is imported transactionally and renamed to a timestamped backup # MCP server settings and prepare-step environments Source: https://docs.fabro.sh/changelog/2026-07-01 ## Manage MCP servers in Settings Server-managed MCP definitions previously required direct API calls. The new **Settings → MCP Servers** pages provide list, create, edit, and delete flows for stdio, HTTP, and sandbox transports. Credential-like values get a nudge toward `{{ secrets.NAME }}` references, while stored header and environment values remain write-only in the UI. ## Prepare-step environments Per-step environment values in `run.prepare.steps` were parsed but dropped before execution. Prepare commands and their environment values now reach the sandbox, resolve `{{ env.* }}` at the run boundary, and fail clearly when a required value is missing; argv-style commands are also shell-quoted correctly. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[run.prepare.steps]] command = ["npm", "install"] env = { NPM_TOKEN = "{{ env.NPM_TOKEN }}" } ``` ## More * `Sandbox::glob` now applies consistent `*`, `**`, and path-segment matching across local, Docker, and Daytona environments, restoring remote skill discovery and common agent glob searches * Newly created GitHub Apps now include the organization Projects V2 permission required by project-tracker workflows * Newly created GitHub Apps now request Dependabot alert read/write permission; existing apps require a manual permission update and installation approval * Server-managed environments now live in SQLite, with legacy TOML definitions imported and renamed to a timestamped backup # Faster web loading and run-time secret interpolation Source: https://docs.fabro.sh/changelog/2026-07-02 ## Faster web loading Remote web sessions previously downloaded about 13.5 MB of uncompressed JavaScript on every refresh, delaying first render by roughly 11–14 seconds on a 1 MB/s connection. Hashed assets now cache correctly, responses use Brotli or gzip compression, and dynamic chunks load only when needed, reducing the measured initial transfer to about 0.8 MB and making refreshes effectively instant. ## Run-time secret interpolation Workflow configuration can now reference vault tokens with `{{ secrets.NAME }}` in MCP transports, prepare steps, and run environment values. Secrets resolve only in the worker at run start, are never persisted in expanded form, and a missing or non-token secret aborts startup instead of passing through literally. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment.env] DEPLOY_TOKEN = "{{ secrets.DEPLOY_TOKEN }}" ``` ## More * The stages sidebar now scrolls independently on run overview and stages pages, so long runs no longer push the graph off screen # Workflow graph direction in run views Source: https://docs.fabro.sh/changelog/2026-07-03 ## More * Run overview graphs now honor the workflow's `rankdir` instead of always rendering with the default direction # Private deployments with Tailscale Services Source: https://docs.fabro.sh/changelog/2026-07-07 ## Private deployments with Tailscale Services Self-hosted Fabro can now stay private to a tailnet using the new loopback-only `docker-compose.tailscale.yaml` deployment. Set `FABRO_WEB_URL` to the Tailscale Service HTTPS origin, start the compose stack, and publish it with `tailscale serve`; Tailscale handles TLS without Caddy, public DNS, or a public Fabro port. Because a Tailscale Service is private, GitHub.com cannot deliver webhooks to it—use a public webhook relay, `server_url`, or Tailscale Funnel when webhooks are required. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} docker compose -f docker-compose.tailscale.yaml up -d tailscale serve --service=svc:fabro --https=443 http://127.0.0.1:32276 ``` ## More * Automation workflow slugs now preserve kebab-case, preventing names such as `patch-cves` from being saved as the non-existent `patch_cves`; previously saved snake-case selectors require manual correction # Trackpad navigation for run graphs Source: https://docs.fabro.sh/changelog/2026-07-08 ## Trackpad navigation for run graphs Run overview graphs now use familiar canvas controls: two-finger scrolling pans, while Command/Ctrl-scroll or trackpad pinch zooms smoothly around the cursor. Drag-to-pan, toolbar zoom, fit-to-window, node interaction, and browser back-navigation protection continue to work with the shared viewport state. ## More * Added `[run.checkpoint] commit_timeout` so repositories with long-running commit hooks can raise the per-node checkpoint timeout above its 30-second default * Runs created before the prepare-step schema change now load in the run list again instead of disappearing during projection rebuild # Provider header interpolation Source: https://docs.fabro.sh/changelog/2026-07-09 **Provider `extra_headers` use a new value syntax.** Replace `{ literal = "value" }` with `"value"`, `{ env = "NAME" }` with `"{{ env.NAME }}"`, and `{ vault = "NAME" }` with `"{{ secrets.NAME }}"`. ## Provider header interpolation Provider headers can now mix literal text with environment or secret values in one string, making bearer prefixes and gateway-specific formats straightforward. Secret tokens resolve only token-style vault entries, so missing, file, and OAuth secrets fail closed; put credentials in secrets rather than literal header values. ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.gateway.extra_headers] Authorization = "Bearer {{ secrets.GATEWAY_TOKEN }}" ``` Provider `base_url` remains a literal string and is not interpolated. ## More * Run graph zoom and pan now survive switching between the Overview and Stages tabs # GPT-5.6 models Source: https://docs.fabro.sh/changelog/2026-07-10 **`server.integrations.slack.default_channel` is now literal text.** Replace any `{{ env.NAME }}` token with an explicit channel name. Per-run Slack notification and interview channels still support interpolation. ## GPT-5.6 models Fabro now includes GPT-5.6 Sol, Terra, and Luna in the built-in OpenAI catalog, routed through the Responses API with a Codex-safe 272K context policy. GPT-5.6 Sol becomes the OpenAI default, while all three models expose their current capabilities, reasoning controls, limits, aliases, and pricing. ## More * Long ACP agent turns now refresh GitHub App push credentials at turn entry and every 45 minutes, preventing late `git push` operations from failing after installation tokens expire # Unified SQLite storage Source: https://docs.fabro.sh/changelog/2026-07-11 ## Unified SQLite storage Server-managed secrets, MCP servers, automations, and run summaries now share Fabro's SQLite database instead of separate JSON or TOML stores. Existing data is imported during startup, with legacy sources renamed to timestamped backups after a successful transaction; the existing APIs and settings UI remain unchanged. ## More * Restored process-environment LLM credentials for standalone CLI and agent sessions after the secrets storage migration * Fixed avatars and principal icons being squeezed into ovals in the runs list **By** column # Poolside Laguna and safer database migrations Source: https://docs.fabro.sh/changelog/2026-07-22 ## Poolside Laguna models Fabro now includes Poolside as a built-in OpenAI-compatible provider for Laguna S 2.1 and Laguna XS 2.1, with native reasoning and tool use. Both models are available directly from Poolside and through the opt-in OpenRouter provider. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider login --provider poolside fabro run workflow.fabro --model laguna-s-2.1 ``` ## Safer database migrations Schema upgrades now create a consistent `fabro.sqlite3.pre-migration.bak` snapshot before applying a new migration. If you need to roll back to an older binary, you have a known-good database from immediately before the latest schema change instead of having to repair migration metadata by hand. ## More * `fabro repo init` now leaves the sample workflow environment unset so the target server can supply its configured default * Parallel branch stages now reach a terminal state with their own duration instead of remaining **Running** after the fan-in and run complete * OpenAI-compatible agent providers now receive compatible object-schema tools and an `edit_file` tool instead of the OpenAI-only custom patch tool * Fixed legacy provider token details producing negative billing buckets in run summaries * Added Kimi K3 through the direct Moonshot AI provider and OpenRouter * Added GLM 5.2 through Z.AI and OpenRouter * Left-to-right run graphs now zoom to 400% and preserve separate viewport positions for left-to-right and top-to-bottom layouts # Provider-aware models and reliable agent control Source: https://docs.fabro.sh/changelog/2026-07-23 **Ineffective agent execution-limit SDK fields were removed.** `SessionOptions.max_turns`, `SessionOptions.max_tool_rounds_per_input`, and the `TurnLimitReached` event no longer exist. Use session wall-clock timeouts or interruption when you need an execution bound. ## Provider-aware model selection Model aliases used to be globally unique, which made portable model names ambiguous across direct providers and aggregators. Providers can now expose the same canonical slug and aliases: Fabro selects the highest-priority ready offering for an unqualified name, treats an explicit provider as a pin, and persists the chosen offering when the run is created. Custom offerings now nest under their provider; legacy top-level `[llm.models]` rows and historical built-in selectors remain accepted for compatibility. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.proxy.models."team-code-large"] api_id = "provider-wire-model-name" aliases = ["team-code"] ``` ## Reliable interrupts and cancellation Interrupting an agent now settles active inference, tools, and subagent waits before the stage enters a durable **waiting for steering** state shown in the web UI. Steering resumes from that state without orphaning child agents, while live run cancellations are durably recorded and remain visibly **Cancelling…** until the run reaches its terminal cancelled state. ## More * LLM requests made during a run now carry `x-session-id: ` so compatible gateways can group them into one trace session * `fabro validate` now warns when handler-specific attributes are placed on node or parallel-branch types that never read them, including clearer diagnostics for multiple parallel parents, custom types, and inherited thread settings * Run manifests now bundle `@file` references used by `output_schema` * Provider-reported completion costs now survive agent and workflow event projection, and billing accumulation saturates safely * Explicit provider pins are preserved through run materialization and request-time routing instead of being redirected to a higher-priority provider * OpenRouter Claude requests now emit cache-control breakpoints, enabling the prompt-cache reads and writes advertised by those catalog entries * Agent stage events are flushed before stage completion, preventing final tool and usage events from appearing after the stage is marked done * Agent routing's last-file fallback now accepts only `.json` and `.md` files whose final standalone object contains routing fields * Generated run titles update atomically and no longer overwrite a title changed while generation was in flight * Child OpenAI plans no longer replace the root agent's TODO list in the stage sidebar * Run deletion failures now show delete-specific error messages * OpenRouter now includes Claude Fable 5, Claude Opus 4.8, and GPT-5.6 Sol, Terra, and Luna offerings # Shared-checkout parallelism, Fireworks, and agent chat Source: https://docs.fabro.sh/changelog/2026-07-24 **Parallel branches now share one checkout, and `join_policy` was removed.** Remove `join_policy` from parallel nodes; fan-out now waits for every branch. Keep file-writing branches read-only or assign disjoint paths, and update fan-in logic to consume `parallel.results` instead of a selected branch workspace or `parallel_results.json`. **Sandbox commands no longer load login-shell profiles.** Environment set in `/etc/profile.d/*.sh`, `~/.bash_profile`, or `nvm`/`rbenv`/`sdkman` initializers is gone, so commands that relied on it now fail mid-run with command-not-found. Move those exports into your Dockerfile's `ENV` (or your Daytona snapshot image). ## Sandbox commands are Bash everywhere Every sandbox now evaluates command strings as **non-login Bash** — `bash -c`, with no `sh` fallback and no ambient provider shell. Previously the three backends disagreed: Daytona ran commands through `sh`, and Docker's streaming, stdio, and setup paths used a login shell. Bash-only syntax such as `[[ ... ]]` and arrays now behaves identically across local, Docker, and Daytona, through both buffered and streaming execution. The `shell` tool's name, argument names and types, and required fields are unchanged; its descriptions now make explicit that `command` is Bash source. Fabro still adds no shell options of its own — there is no implicit `errexit` or `pipefail`, so `false | true` still succeeds. A workflow that wants different semantics writes them into the command (`sh -c ...`, a `#!/bin/sh` shebang, an explicit `set -o pipefail`). Both fresh initialization and resume now verify Bash before reporting the sandbox usable, so a missing or non-Bash interpreter fails at the lifecycle boundary with provider-specific remediation instead of on the first command. Fabro also clears `BASH_ENV` at each provider boundary so ambient worker or image configuration cannot execute a startup file before sandbox commands. Docker and Daytona require `/bin/bash`; local sandboxes resolve `bash` through the worker's `PATH`, which keeps NixOS working. Prepare-step `script` values and sandbox MCP `script` values run under the same contract. Hooks, host-side stdio MCP scripts, and interactive terminals are separate executors and keep their existing behavior. ## Shared-checkout parallel execution Parallel branches now run concurrently in the run's existing sandbox and working directory instead of creating branch-specific Git worktrees. Each branch still receives isolated context updates, returned in outgoing-edge order under `parallel.results`, while a prompted fan-in can synthesize the complete typed result array. Promptless fan-in nodes act as barriers and never select, restore, or merge workspace state. ## Fireworks AI Fireworks AI is now an opt-in built-in provider with a curated serverless catalog for Kimi, DeepSeek, GLM, MiniMax, Qwen, and GPT-OSS models. The integration uses the existing OpenAI-compatible path, supports cached-input accounting, and works with the normal provider login, secret storage, diagnostics, and model-testing flows. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.fireworks] enabled = true ``` ## Model-native Kimi agents and agent chat Kimi K3 sessions now use a model-specific agent profile with Kimi Code tool names, descriptions, paging, and TODO behavior. The stage activity view also has a Chat tab that groups prompts, assistant messages, tool calls, and disclosed provider reasoning into a readable conversation. ## More * `GET /api/v1/runs/{id}/events` now supports descending cursor pagination with `order=desc` and `before_seq`; `fabro run events --tail` uses it to fetch only the newest events * Completion usage now reports disjoint input, output, reasoning, cache-read, and cache-write token buckets * Model responses now advertise exact `controls.reasoning_effort` values, and completion requests—including structured completions—validate and forward reasoning effort with a `400` response for unsupported values * Event history scans now use seek-based pagination with bounded cursors, avoiding full-history work and incorrect results for oversized sequence values * Preflight now prefers providers that are actually ready while preserving explicit provider pins and useful diagnostics for unavailable offerings * OpenAI-compatible agent providers now receive compatible file-edit and tool schemas * Large values inside parallel branch results now stay available to fan-in prompts through normal artifact storage * Agent compaction now preserves history when a summary is empty or exceeds the model's reasoning budget * Agent tool secrets now reach model-specific profiles, and OpenAI-compatible streaming usage is requested consistently * Resumed nodes now start a new execution such as `work@2`, preserving the earlier execution's events, output, timing, billing, and terminal state * Added `claude-opus-5` to the first-party Anthropic and optional OpenRouter model catalogs; the `opus` and `claude-opus` aliases now resolve to Opus 5 * Added `gpt-sol`, `gpt-terra`, and `gpt-luna` aliases for GPT-5.6 offerings * Added portable `glm`, `glm52`, `glm5.2`, `deepseek`, and `deepseek-flash` aliases across direct and OpenRouter offerings # Consistent workspace globs and model-native agents Source: https://docs.fabro.sh/changelog/2026-07-25 **Glob semantics changed.** Artifact patterns are now rooted at the sandbox working directory, and the agent's `glob` tool roots patterns at its `path` argument. Both use the same path-segment semantics. * Replace `*.trace.zip` with `**/*.trace.zip` when traces may be nested. * Replace `test-results/**` with `**/test-results/**` only when `test-results` may appear below any directory instead of at the workspace root. * Absolute patterns, backslashes, and patterns containing a `..` segment are now rejected. * Agent `glob` results are now sorted lexicographically; local results are no longer ordered by modification time. ## One glob language in every sandbox Fabro now compiles workspace globs once and applies them to normalized relative paths in Rust. Local, Docker, and Daytona sandboxes only enumerate files; provider commands and APIs no longer interpret user patterns. `*` and `?` stay within one path segment, bracket expressions match one character, and a complete `**` segment crosses directories. Matching is case-sensitive, leading dots are ordinary characters, and `/` is the separator everywhere. Artifact capture and the agent `glob` tool therefore agree on patterns such as `.ai/reports/*.md`, `.ai/reports/**/*.md`, and `.ai/plans/????-??-??-*.md`. Artifact traversal also uses structured provider metadata for file-size limits and prunes dependency, cache, and build directories before matching. Artifact collection now treats each post-stage workspace state as authoritative instead of relying on modification timestamps. Fabro records the same path and content hash only once per run and captures the path again when its content changes. ## Model-native Claude 5 and GPT-5.6 agents Claude 5 and GPT-5.6 Sol, Terra, and Luna now use profiles that match each model family's preferred tools and instructions. Child agents inherit the same task runtime, so background work and completion notifications behave consistently across profiles. ## More * Fixed active-time totals for stages that are still running * Fixed background-agent completion notifications and event delivery * Fixed Daytona streaming completion and cleanup after failed probes * Removed the read-before-write guard that could reject valid agent edits * The stage activity panel now opens on Chat and shows disclosed reasoning in Thread details * Open browser tabs now report when a newer Fabro web build is available # CLI auth sessions move to SQLite Source: https://docs.fabro.sh/changelog/2026-07-26 **Everyone signs in again after this upgrade.** Existing refresh tokens are not migrated, so every signed-in browser and CLI is logged out the moment the new server binary starts. Run `fabro auth login` again on each machine. There is no staged rollout for this — avoid upgrading mid-task. ## Sessions are their own record A CLI login is a chain of refresh tokens that rotate on every use. Fabro previously stored the identity and profile on each token in that chain, so the chain itself had no record of its own. It now does: an `auth_sessions` row per login, with its tokens in `refresh_tokens`, both in `/db/fabro.sqlite3`. Two dates on **Settings → Sessions** were wrong as a result and are now correct. A session's **created** date came from its newest token, so it moved forward every time the CLI refreshed, and **last seen** showed the same value rather than the last time the session was actually used. Listing and revoking sessions no longer reads every refresh token the server has ever issued, so both stay fast as a workspace accumulates logins. Revoking a session removes its tokens in the same operation. ## Pending CLI logins Pending CLI authorization codes now live in SQLite as SHA-256 hashes and are consumed atomically on the first exchange attempt. A login that is already between browser approval and token exchange when the server upgrades cannot carry across the storage cutover; run `fabro auth login` again. These codes expire after 60 seconds, and completed logins are unaffected. ## Refresh token replay Replaying a refresh token still revokes its whole chain immediately. One detail changed: when several requests present the same already-rotated token at once, later ones now report `refresh_token_expired` where they previously reported `refresh_token_revoked`. The CLI treats both the same way — it discards the stored credentials and prompts you to sign in again. ## More * `fabro validate` now stays offline and no longer requires a server model catalog * Child agents now share the parent task runtime across every model profile # Runtime fan-out and live parallel runs Source: https://docs.fabro.sh/changelog/2026-07-27 **Run configuration no longer reads `{{ env.NAME }}` from the server process.** Replace environment interpolation with `{{ vars.NAME }}` for non-sensitive values or `{{ secrets.NAME }}` for vault-backed values. Hook `allowed_env_vars` was also removed. ## Runtime arrays with `for_each` Parallel workflows can now create one branch per item in a runtime context array. Each branch receives its item and index, while the fan-in receives the ordered results without merging branch updates into top-level context. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} batch [shape=component, for_each="context.candidates"] batch -> reviewer ``` ## Live parallel status and artifact history The run view now shows parallel branches as they start and finish. The artifacts view groups captures by file and shows version history, while stage model popovers report token usage and cost. ## More * Validation diagnostics now include their suggested fixes * Command `script` values now interpolate `{{ goal }}`, `{{ inputs.NAME }}`, and `{{ vars.NAME }}` * A node with `script` now infers the command handler when `shape` and `type` are omitted * Validation now rejects edge targets that were never declared as nodes * Pull request publish failures now fail the run instead of leaving a successful terminal state * Default small-model selection now skips providers that do not offer a small model # Modal inference and structured review targets Source: https://docs.fabro.sh/changelog/2026-07-28 ## Run Kimi K3 through Modal Fabro now includes an optional Modal provider for Kimi K3 Shared API and Auto Endpoints. It supports Modal's endpoint URL, two-part proxy-token authentication, model capabilities, and estimated token costs. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.modal] enabled = true base_url = "https://your-endpoint.modal.run/v1" ``` ## Structured review targets Human gates can now present an external document as the primary review link. Set `review_target=true` on the gate and provide a validated `review_target` object in workflow context. ## More * `fabro run --dry-run` now walks runtime `for_each` fan-out, and fan-out memory is bounded * Provider-qualified fallback selectors such as `openrouter:kimi-k3` are now supported * Fixed live parallel branches appearing late or failing to refresh * Fixed Docker and Daytona runs failing to reactivate stopped or paused sandboxes * Fixed a pipe character inside a Slack link label being parsed as a table separator * Fixed structured-output validation so it selects the outermost final JSON object * Run-side panels now use a shared collapsible layout # Context-fed commands and portable fallbacks Source: https://docs.fabro.sh/changelog/2026-07-29 ## Feed workflow context to command input Command nodes can now read a flat runtime context value through `stdin_source`. Strings pass through unchanged, while other values use compact JSON, which makes deterministic fan-in commands easier to build. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} merge [script="python3 scripts/merge.py", stdin_source="context.parallel.results"] ``` ## Portable model fallback chains Fallback chains can now mix bare providers, model aliases, and provider-qualified selectors. Model stylesheets also accept comments, so routing policy can stay readable next to the workflow. ## More * Raised the `stdin_source` value limit from 10 MiB to 30 MiB for wide fan-in results * Node class lists now accept whitespace-separated names consistently * Fixed artifacts from repeated visits to the same stage * Fixed parent tool hooks not reaching child agent sessions * Fixed concurrent CLI token refreshes racing across processes * Fixed command-node inference when legacy attributes are present * Removed the nonfunctional run agent permissions setting from run configuration and the API schema # Model-keyed fallback policies Source: https://docs.fabro.sh/changelog/2026-07-30 ## Fallback policy follows the requested model You can now define a separate ordered fallback chain for each requested model. Fabro selects the chain once from the original request and does not jump into another model's policy while failing over. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.model.fallbacks] "kimi-k3" = ["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"] ``` ## More * Forked runs now create a fresh sandbox when they resume * Stored run events from older Fabro releases remain readable after event schema changes * Modal reasoning-token usage is now included in billing totals * Fallback resolution errors now report the underlying unavailable model or provider # DeepSeek, reusable subagents, and artifact downloads Source: https://docs.fabro.sh/changelog/2026-07-31 **The built-in `kimi` provider is now named `moonshot`.** Rename provider pins and `[llm.providers.kimi]` configuration to `moonshot`. Use `MOONSHOT_API_KEY`; `KIMI_API_KEY` remains a legacy credential fallback. ## Direct DeepSeek support Fabro now has an opt-in direct DeepSeek provider with DeepSeek V4 Flash, model aliases, reasoning-effort controls, and catalog pricing. DeepSeek aliases also work through Fireworks and OpenRouter when those providers are enabled. ## Reuse completed subagent sessions Parent agents can now resume a completed child session instead of starting a new one. The run keeps the child history and continues forwarding its events to the stage view. ## Download current artifacts as a ZIP The artifacts page can now download the latest version of every run artifact in one ZIP archive. The server builds the archive from the current file set and tolerates artifacts that disappear during collection. ## More * Human-input waits now pause workflow stall timeouts * Output schemas are now included in agent task instructions * Fixed stale MCP server processes after Fabro upgrades * Fixed Bedrock tool identifiers that contain unsupported characters * Structured-output repair failures now include actionable validation details * Provider quota errors now enter the correct failover path * Fixed canceled parallel branch durations * The steering bar now starts collapsed and expands when you select it # Durable pull request creation Source: https://docs.fabro.sh/changelog/2026-08-01 **Pull request creation is now asynchronous, and run IDs are server-owned.** `POST /api/v1/runs/{id}/pull_request` now returns `202` with a `PullRequestCreation` record and a `Location` to poll. Remove `run_id` from run manifests and stop using the removed CLI run-ID override; the server always allocates the run ID. ## Pull request creation survives restarts Explicit pull request creation is now stored before GitHub work begins. The request returns immediately, and you can poll its durable `pending`, `succeeded`, or `failed` state even if the server restarts. ## More * New `GET /api/v1/runs/{id}/pull_request/creation` endpoint returns the latest explicit pull request request * Run projections now include the latest `pull_request_creation` state * Runs now fail when routing bypasses a required goal gate * Reused child agents continue forwarding events after broadcast lag * Artifact archives batch storage reads and tolerate files that vanish during download * Structured-output repair errors now identify unexpected properties in a stable order # Leaner run manifests and Qwen3.8 Max Source: https://docs.fabro.sh/changelog/2026-08-03 **Unused run-manifest fields were removed from the API schema.** `GitContext.push_outcome`, `ManifestGoal.path`, and `ManifestTarget.identifier` are no longer generated in clients. Servers still accept older request bodies that contain these fields. ## More * Added Qwen3.8 Max to the optional OpenRouter model catalog * Agent task reminders now remain consistent when a turn is interrupted # Faster Daytona edits and Kimi K3 Fast Source: https://docs.fabro.sh/changelog/2026-08-04 ## More * Increased the `fabro doctor` server health-check timeout to avoid false failures on slower local servers * Agent file edits in Daytona now skip an unnecessary folder lookup * Added Kimi K3 Fast to the optional Fireworks model catalog # Correct node visit limits Source: https://docs.fabro.sh/changelog/2026-08-05 ## More * A node may now execute exactly `max_visits` times before the cycle guard stops the run * Server diagnostics now stay within the client timeout instead of leaving `fabro doctor` waiting on a late response # Run filters and live billing totals Source: https://docs.fabro.sh/changelog/2026-08-06 ## Faster run browsing The runs list now fills its repository and workflow filters from available runs. Billing totals for active runs also update from live events instead of waiting for the run to finish. ## More * Event redaction no longer mutates the executable run specification used to resume, retry, or fork a run # Safer workflow root paths Source: https://docs.fabro.sh/changelog/2026-08-12 ## More * Workflow bundling once again normalizes relative root paths before reading them, rejects `~`-prefixed roots, and prevents `..` segments from resolving through a symlink to a different file # Immutable workflow versions Source: https://docs.fabro.sh/changelog/2026-08-13 ## Store validated workflow packages by content The API can now validate and store an immutable workflow package with its entrypoint, files, and exact child-workflow dependencies. Repeating the same canonical package returns the same SHA-256 workflow version ID. ```http theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} POST /api/v1/workflow-versions ``` ## More * Invalid workflow-version content and missing or non-canonical dependencies return `422` errors * Workflow version IDs are accepted case-insensitively and returned in canonical lowercase form * Workflow-version validation now detects file path collisions that were previously hidden by sort order # Exact workflow lineage Source: https://docs.fabro.sh/changelog/2026-08-14 ## More * Run specifications and projections now preserve the exact immutable `workflow_version_id` used at admission * Workflow-version goals now include and validate the complete closure of child workflow dependencies * Clone-based sandboxes now verify an admitted exact commit instead of silently using a newer branch head # Content-addressed blob hashes Source: https://docs.fabro.sh/changelog/2026-08-16 **The blob write response field changed from `id` to `hash`.** Regenerate API clients from version `0.2.0` of the OpenAPI spec. Older generated clients fail when the response no longer contains `id`. ## More * Blob read routes now name the path parameter `blobHash`, and blob write responses return the SHA-256 value in `hash` * Run dump hydration now shares a blob cache across JSON and text output # Reliable workflow dependency discovery Source: https://docs.fabro.sh/changelog/2026-08-17 ## More * SHA-256 blob and workflow-version hashes now use canonical lowercase values across API responses and generated clients * Template dependencies are now found when their paths overlap workflow discovery roots * Template discovery errors now identify the source file that introduced the invalid dependency * File-based workflow-version goals now reject broken transitive includes before a run starts # Fireworks account failover Source: https://docs.fabro.sh/changelog/2026-08-18 ## More * Fireworks HTTP `412` account suspension responses now activate configured model fallbacks instead of being treated as invalid requests # Reliable sandbox pushes and Daytona recovery Source: https://docs.fabro.sh/changelog/2026-08-20 ## Git pushes survive token rotation Sandbox pushes now use a cached GitHub installation-token source and retry with pinned credentials. Long runs can refresh expired tokens without changing credentials in the middle of one push attempt. ## More * Daytona activation, start, and stop now wait and retry during provider state transitions * Clone-based sandboxes now verify exact commits after checkout * Sandbox state-change conflicts are now treated as transient infrastructure failures * Daytona sandboxes now default to a 120-minute auto-stop interval; set `auto_stop = "0s"` to disable it # Run intents, additional repositories, and Venice search Source: https://docs.fabro.sh/changelog/2026-08-21 ## Version-backed run creation Fabro's canonical `POST /api/v1/runs` endpoint now also accepts a strict `RunIntent` body that creates a submitted run from an immutable workflow version, a named server environment, and an explicit public GitHub repository target. Creation remains separate from execution; call the existing start endpoint when the run should begin. Git targets require a branch and may pin a full commit SHA. Fabro normalizes a submitted SHA to lowercase and uses that exact commit during sandbox setup, without resolving it during admission or falling back to a newer branch HEAD. The environment ID defaults to `default`; this first target slice supports clone-enabled Docker and Daytona environments. Existing manifest callers continue to work unchanged and can migrate independently. Intent runs do not consume repository `project.toml` settings: the selected server environment owns provider, working directory, and image, while workflow versions may still overlay resources, network, lifecycle, labels, and environment variables. Project metadata therefore no longer adds run labels on this path, and repository-wide settings must move into workflow versions or server defaults before a caller migrates. ## One token for the whole repository set A run can now declare additional GitHub repositories that its stages may access through the managed `GITHUB_TOKEN`: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.integrations.github] additional_repositories = ["fabro-sh/keystone"] permissions = { contents = "read" } ``` The run origin stays implicit, and Fabro mints one installation token scoped to the origin plus every declared repository with the shared permission map. Inside stages, `gh` commands, raw GitHub API calls, plain Git over HTTPS, and the common SSH URL spellings (`git@github.com:owner/repo` and `ssh://git@github.com/owner/repo`) all work against the declared set — the SSH forms are transparently rewritten to authenticated HTTPS with no secret placed in Git configuration. Every repository must share one owner and be reachable by the origin's GitHub App installation. Preflight resolves each repository's installation, mints the scoped token once, and probes every repository with `git ls-remote`, naming the exact repository when something is not accessible; run initialization enforces the same checks. A declared-but-inaccessible repository fails the run before its first stage. Declaring additional repositories requires `contents = "read"` or `contents = "write"`. With `contents = "write"`, any stage can push to any declared repository — declare the smallest set and weakest permissions that work. See [Additional repositories](/integrations/github#additional-repositories) for details, including layering rules and `GH_TOKEN` precedence. ## Venice search backend for `web_search` The built-in `web_search` tool now supports Venice as an automatic alternative to direct Brave Search. Fabro uses `BRAVE_SEARCH_API_KEY` when present. Otherwise it uses `VENICE_API_KEY` with Venice's Brave search engine. If neither key is present, the tool is not registered. Failed calls do not fall back between providers. See [Venice Search](/integrations/venice-search) and [Brave Search](/integrations/brave-search). ## More * Docker and Daytona clones now default to 100 commits; set `[run.clone] depth = 0` for full history or a smaller positive depth for a shallower clone * Runs now fail if durable event persistence is lost instead of continuing with incomplete history * Venice model responses now include provider-reported top-level costs in billing totals * Daytona post-clone setup now has a bounded wait * Large context values moved out of agent prompt preambles now render as concise file references with bounded previews * Added the Venice built-in model provider catalog and integration guide # Empty run workspaces and more reliable Daytona activation Source: https://docs.fabro.sh/changelog/2026-08-23 Version-backed run intents can now use `{ "kind": "none" }` when a workflow should start without a repository. The target creates an empty Docker or Daytona workspace and suppresses cloning even when the resolved workflow settings enable it. Local environments reject the `none` target. Server-managed Local scratch workspaces remain a separate future capability. Run intents can also use `{ "kind": "folder", "path": "/absolute/server/path" }` with a Local environment to execute in an existing server directory. Fabro resolves the submitted path to an existing canonical directory, persists that path, and uses it instead of the environment's `cwd`. Folder targets are intended for trusted single-tenant deployments and are rejected by Docker and Daytona. They execute in place without Fabro Git checkpoints, so retries retain the folder target while fork and rewind remain unavailable. ## More * Daytona snapshot activation now allows the provider more time to finish before Fabro reports a timeout # Model stylesheet templates and failure routing Source: https://docs.fabro.sh/changelog/2026-08-25 Workflows can now set graph-level `on_failure="exit"` to stop after a failed node when no explicit recovery route matches. Fabro skips the unconditional edge, checks configured retry targets, and ends the run as failed if no retry target exists. The default `on_failure="route"` preserves existing workflow behavior. A node can also set its own `on_failure` to override the graph policy in either direction: a best-effort node can use `on_failure="route"` inside an `exit` graph, or a single critical node can use `on_failure="exit"` while the rest of the graph keeps the default. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Build { graph [on_failure="exit"] start [shape=Mdiamond] exit [shape=Msquare] plan [prompt="Plan the work"] implement [prompt="Implement the plan"] verify [prompt="Verify the implementation"] start -> plan -> implement -> verify -> exit } ``` ## Model stylesheet templates The root graph's `model_stylesheet` now supports MiniJinja templates. A stylesheet can use typed run inputs and server-managed variables through `inputs` and `vars`. Conditions, loops, filters, macros, local values, and static includes use the same template engine as workflow goals and prompts. Fabro renders the stylesheet before parsing and applying its rules. Undefined values produce the existing `template_undefined_variable` diagnostic. Offline validation skips stylesheet syntax checks until those values are available, which avoids a second error from incomplete generated stylesheet text. Stylesheet templates do not expose `goal`, `env`, or `secrets`. Stylesheets on imported graphs remain ignored and now produce an `imported_model_stylesheet_ignored` warning. # Explicit succeed failure policy Source: https://docs.fabro.sh/changelog/2026-08-26 `on_failure` now accepts a third policy, `succeed`, alongside `route` and `exit`. A failed node with an effective `succeed` policy and no explicit recovery route finishes as `succeeded` and follows normal success routing. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion. Set it on a node to mark a best-effort step inside a strict graph, or on the graph to apply it everywhere: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Review { graph [on_failure="exit"] start [shape=Mdiamond] exit [shape=Msquare] required_check [script="./required-check"] optional_scan [script="./optional-scan" on_failure="succeed"] start -> required_check -> optional_scan -> exit } ``` An explicit `condition="outcome=failed"` edge still takes priority over the promotion. A promoted outcome satisfies goal gates, and a failed parallel branch with a `succeed` policy counts as succeeded in its parent's result. `auto_status=true` is now a deprecated alias for `on_failure="succeed"`. Existing workflows keep working, and validation reports a new `auto_status_deprecated` warning with the replacement. The alias no longer promotes `partially_succeeded` outcomes; only `failed` outcomes are affected. # Docker images for Daytona snapshots Source: https://docs.fabro.sh/changelog/2026-08-27 Daytona environments now accept `image.docker`. Fabro creates or reuses a Daytona snapshot from that image, so an existing image no longer needs a wrapper Dockerfile. `image.dockerfile` remains available for custom builds. Set one image source per Daytona environment: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.cloud] provider = "daytona" [environments.cloud.image] docker = "python:3.11-slim" ``` Fabro rejects a Daytona environment that sets both `image.docker` and `image.dockerfile`. # Workflow Playground removed Source: https://docs.fabro.sh/changelog/2026-09-03 The unsupported Workflow Playground and `POST /api/v1/playground/chat` have been removed. Requests to that endpoint now return 404, and generated clients no longer expose it. There is no direct replacement. Workflows can still be defined in normal workflow files and executed with the Fabro CLI. Existing chat, completion, and workflow graph features are unchanged. # Agents Source: https://docs.fabro.sh/core-concepts/agents Core agent concepts in Fabro An agent in Fabro is an LLM session with access to tools. When a workflow reaches an agent node, Fabro creates a session, sends the prompt and prior context to the model, and lets the agent work autonomously — reading files, running commands, editing code, spawning sub-agents — until it decides the task is complete. ## The agent loop Each agent turn follows the same cycle: 1. **Send** — Fabro sends the conversation history (system prompt, prior messages, tool results) to the LLM 2. **Receive** — The model responds with text, tool calls, or both 3. **Execute** — Fabro executes any tool calls in the sandbox and appends the results to the conversation 4. **Repeat** — If the model made tool calls, go back to step 1. If it responded with only text, the agent is done. This loop continues until the model stops calling tools, indicating it considers the task complete. Fabro also enforces guardrails: token budgets, turn limits, and loop detection to prevent runaway agents. ## Backends Every agent node uses a **backend** that determines how Fabro interacts with the LLM or external agent process. Prompt nodes always use the API backend. Agent nodes support two backend values: `api` and `acp`. ### API backend (default) Fabro manages the agent loop directly — it calls the LLM provider's API, executes built-in tools in the sandbox, and tracks file changes via tool call events. This is the default and supports the full feature set: * [Session caching](/execution/context) via `fidelity="full"` + `thread_id` * [Sub-agents](/agents/subagents) * Provider failover * All [built-in tools](/agents/tools) and [MCP](/agents/mcp) integrations ### ACP backend Fabro can run Agent Client Protocol (ACP) stdio agents with `backend="acp"`. ACP agents run inside the active Fabro sandbox, so local, Docker, and Daytona runs keep the same workspace isolation, cancellation, and file-change tracking behavior as other agent stages. ACP stages do not use Fabro model/provider credentials. The ACP process owns its auth, tools, and model behavior. Configure the process with exactly one of: * `acp.command`: a shell command string * `acp.config`: a JSON stdio ACP config ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [label="Implement", backend="acp", acp.command="python3 tools/fake_acp_agent.py"] ``` ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [ label="Implement" backend="acp" acp.config="{\"type\":\"stdio\",\"name\":\"agent\",\"command\":\"python3\",\"args\":[\"tools/fake_acp_agent.py\"]}" ] ``` Fabro does not install ACP agents, Node.js, npm, or `npx` at runtime. Commands must already be available in the sandbox image, repository, or setup steps. You can use `npx ...@latest` as an explicit `acp.command` if that is the behavior you want, but Fabro will treat it like any other user-supplied command. The legacy `acp_command` attribute is rejected; use `acp.command` for shell commands or `acp.config` for JSON stdio configs. ACP is supported with local, Docker, and Daytona sandboxes. ### Comparison | Capability | API backend | ACP backend | | - | - | - | | Tools | Fabro built-in tools + MCP | ACP agent's own tool set | | Session caching | Supported (`fidelity` + `thread_id`) | Agent-dependent | | Sub-agents | Supported | Not supported through Fabro tools | | Provider failover | Supported | Not supported | | Model/provider credentials | Resolved by Fabro | Not used by Fabro | | File tracking | Tool call events | `git diff` before/after | ### When to use the ACP backend * **Protocol adapters** — run ACP-compatible coding agents through a stable stdio protocol * **Sandbox parity** — keep agent process execution inside Fabro's local or Docker sandbox * **Command-owned auth** — let the ACP command manage its own provider login, model selection, and credentials * **Custom agents** — use `acp.command` or `acp.config` for a checked-in or preinstalled ACP adapter ## Tools Agents have access to a set of built-in tools for interacting with the codebase and environment: | Tool | Description | | - | - | | `shell` | Run commands as Bash source via `bash -c` | | `read_file` | Read file contents with optional offset and limit | | `write_file` | Create or overwrite a file | | `edit_file` | Make targeted edits to an existing file | | `grep` | Search file contents with regex patterns | | `glob` | Find files by name pattern | | `web_search` | Search the web | | `web_fetch` | Fetch and summarize a URL | Additional tools can be added via [MCP servers](/agents/mcp) for integrations like databases, APIs, or custom services. See [Tools](/agents/tools) for the full reference. ## Prompts The agent's behavior is shaped by its prompt — the task instructions set in the `prompt` attribute of the workflow node. Prompts can be inline strings or references to external Markdown files: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} // Inline prompt plan [label="Plan", prompt="Analyze the codebase and write a step-by-step plan."] // External file reference simplify [label="Simplify", prompt="@prompts/simplify.md"] ``` Fabro also injects a system prompt with context about the workflow goal, prior stage outputs, available tools, and the agent's role. See [Prompts](/agents/prompts) for details. ## Sub-agents An agent can spawn **sub-agents** to delegate subtasks. Sub-agents run in their own session with their own tool access, and return results to the parent. This is useful for parallelizing research, isolating risky operations, or breaking complex tasks into manageable pieces. See [Sub-agents](/agents/subagents) for details. ## Skills Skills are reusable prompt templates that extend an agent's capabilities for common tasks — code review, test writing, refactoring, and more. They're discovered automatically from the project and can be invoked by the agent during its session. See [Skills](/agents/skills) for details. ## Hooks Hooks are shell commands that run in response to agent lifecycle events (e.g. before a tool executes, after a stage completes). They enable custom validation, notifications, and guardrails without modifying the workflow graph. See [Hooks](/agents/hooks) for details. ## Further reading How prompts are constructed and injected. Built-in tools and custom tool registration. Extend agents with Model Context Protocol servers. Delegate subtasks to child agent sessions. # How Fabro Works Source: https://docs.fabro.sh/core-concepts/how-fabro-works Understand the Fabro architecture and execution model Fabro is a workflow engine that reads a graph definition, executes nodes one at a time (or in parallel), and uses edge selection rules to decide what happens next. Here's the end-to-end flow. How Fabro works: author-time inputs flow into the workflow engine, which dispatches to handlers that interact with LLMs, sandboxes, and humans ## Two ways to use Fabro Fabro has two interfaces, both backed by the same workflow engine: * **Direct CLI runs** (`fabro run`) — Run a single workflow synchronously in your terminal. Best for local development, one-off runs, and CI/CD. * **Server interface** (`fabro server start`) — Start an HTTP API server with a web UI, concurrent run scheduling, and team access. Best for production use and running at scale. Both interfaces parse the same Graphviz files, use the same execution engine, and support the same sandbox providers. See [Deployment](/administration/deployment) for where the server runs and [Server Operations](/reference/server-operations) for operating it, or [Architecture](/reference/architecture) for internals. ## Author time You provide three inputs: 1. **Workflow graph** (`.fabro`) — A Graphviz file defining nodes, edges, and their attributes. This is the core of what Fabro executes. See [Workflows](/core-concepts/workflows). 2. **Run config** (`.toml`, optional) — Overrides for the default model, sandbox provider, setup commands, and variables. See [Run Configuration](/execution/run-configuration). 3. **Credentials** — Provider credentials from the server-owned secret store or the invoking shell environment. See [Quick Start](/getting-started/quick-start). ## Parse and validate When you run `fabro run` or submit a run to the server, Fabro: 1. Parses the Graphviz file into an in-memory graph of nodes and edges 2. Validates the graph structure (exactly one start node, one exit node, all edges point to valid nodes) 3. Applies the [model stylesheet](/workflows/stylesheets) to resolve which LLM model each node uses 4. Merges run config defaults with CLI flags (CLI flags override the config, config overrides graph defaults) ## The execution loop The engine walks the graph starting from the start node. For each node it: 1. **Resolves context** — Assembles the node's input from prior stage outputs, run context, and the workflow goal. The [fidelity](/workflows/stages-and-nodes#agent) setting controls how much prior context is included. 2. **Dispatches to a handler** — Each [node type](/workflows/stages-and-nodes) has a handler: the agent handler runs an LLM tool loop, the command handler runs a shell script, the human handler waits for input, and so on. 3. **Collects the outcome** — The handler returns a [stage outcome](/execution/outcomes) (`succeeded`, `failed`, `partially_succeeded`, or `skipped`), optional routing directives, and any context updates. 4. **Selects the next edge** — Fabro evaluates outgoing edges using conditions, labels, and weights to pick the next node. See [Transitions](/workflows/transitions). 5. **Checkpoints** — After each stage, Fabro writes a checkpoint so the run can be resumed if interrupted. This loop repeats until execution reaches the exit node or an unrecoverable error occurs. Fabro web UI run detail showing stages and workflow graph ## Retries and error handling When a node fails, Fabro consults its [retry policy](/workflows/stages-and-nodes#common-node-attributes). If retries remain, the node re-executes with exponential backoff. If all retries are exhausted, the failure propagates to edge selection — a `condition="outcome=failed"` edge can route to a fix node, creating a recovery loop. For workflows with loops, Fabro tracks failure signatures to detect infinite retry cycles. If the same failure repeats beyond the configured limit, the workflow aborts rather than looping forever. ## Goal gates Before completing, Fabro checks all nodes marked with `goal_gate=true`. If any goal gate node didn't succeed, the workflow fails — even though execution reached the exit node. This ensures critical quality checks can't be skipped by routing around them. ## Parallel execution When the engine hits a [parallel fan-out node](/workflows/stages-and-nodes#parallel-fan-out), it spawns concurrent branches, each with an isolated copy of the context. A [merge node](/workflows/stages-and-nodes#merge-fan-in) collects the results according to the join policy before the workflow continues on a single path. ## Sandboxes Node handlers execute tools (bash commands, file edits) inside a **sandbox**. Fabro supports three sandbox providers: | Sandbox | Description | | - | - | | `docker` | Tools run inside a Docker container (default) | | `local` | Tools run directly on the host machine | | `daytona` | Tools run in a cloud VM with SSH access | Runs select a named environment via CLI flags (`--environment ci`) or run config TOML (`[run.environment] id = "ci"`). Fabro then creates a concrete sandbox from that environment. See [Environments](/execution/environments) for details. ## Events and observability Every significant action — stage starts, LLM calls, tool invocations, edge selections, stage completions — is emitted as a structured event. These events power: * The **web UI** for real-time run monitoring * **Run summaries** built from durable events, checkpoints, conclusions, and stage outputs * **DuckDB queries** via `fabro insights` for SQL-based analysis across runs See [Observability](/execution/observability) for more on querying run data. ## Resuming runs Because Fabro checkpoints after every stage, interrupted runs can be resumed from where they left off: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro resume ``` The engine restores the full context, node visit counts, and retry state from the run directory, then continues execution from the next node. # Models Source: https://docs.fabro.sh/core-concepts/models How Fabro routes tasks to LLM models and providers No single model is best at everything. Fabro lets you assign the right model to each workflow step — cheap, fast models for boilerplate, frontier models for hard reasoning, and a different provider for cross-critique so the reviewer brings fresh eyes. When a provider goes down, Fabro can fail over automatically. Ensemble workflow: fan out to Opus and Gemini Pro, merge, then synthesize ## Model catalog Fabro keeps five related concepts separate: * A **provider** serves requests, such as `openai` or `openrouter`. * A **model slug** is Fabro's canonical, human-facing model ID, such as `gpt-5.6-sol`. * An **alias** is another user-facing selector, such as `gpt-56-sol`. * A **family** is display and matching metadata. It does not affect routing identity. * An **API ID** is the opaque model string sent on the provider wire. Workflows should never reference it. One provider's route to one model slug is an **offering**, identified by `(provider, model slug)`. The same slug and alias may appear on several providers. Within one provider, however, every slug or alias must identify exactly one offering. For an unqualified selector, Fabro checks canonical slugs before aliases, filters the candidates to providers whose adapters are ready, and then chooses the highest provider priority. Equal priorities use canonical provider ID order. An explicit provider restricts selection to that provider and is a pin: if it is unavailable, Fabro reports the error instead of silently switching. For example, the shared `gpt-56-sol` alias can be portable across direct OpenAI and OpenRouter offerings: | Ready providers | Selector | Selected offering | | - | - | - | | OpenAI only | `gpt-56-sol` | `openai/gpt-5.6-sol` | | OpenRouter only | `gpt-56-sol` | `openrouter/gpt-5.6-sol` | | OpenAI and OpenRouter | `gpt-56-sol` | OpenAI, because it has higher priority | | Both, with `provider = "openrouter"` | `gpt-56-sol` | OpenRouter, because the provider is pinned | Fabro performs this selection once when creating a run and persists the chosen provider and canonical slug in the run settings and graph. Resuming that run does not reconsider provider priority when credentials change. Runtime [model fallbacks](/execution/failures#model-fallbacks) are the separate mechanism for handling a later provider failure. | Model | Provider | Aliases | Context | Cost (in/out per Mtok) | Speed | | - | - | - | - | - | - | | `claude-fable-5` | anthropic | `fable`, `claude-fable` | 1M | $10.00 / $50.00 | n/a | | `claude-opus-5` | anthropic | `opus`, `claude-opus` | 1M | $5.00 / $25.00 | n/a | | `claude-opus-4-8` | anthropic | | 1M | $5.00 / $25.00 | 25 tok/s | | `claude-opus-4-7` | anthropic | | 1M | $5.00 / $25.00 | 25 tok/s | | `claude-opus-4-6` | anthropic | | 1M | $5.00 / $25.00 | 25 tok/s | | `claude-sonnet-4-6` | anthropic | `sonnet`, `claude-sonnet` | 200K | $3.00 / $15.00 | 50 tok/s | | `claude-sonnet-4-5` | anthropic | | 200K | $3.00 / $15.00 | 50 tok/s | | `claude-haiku-4-5` | anthropic | `haiku`, `claude-haiku` | 200K | $0.80 / $4.00 | 100 tok/s | | `gpt-5.6-sol` | openai | `sol`, `gpt-sol`, `gpt56-sol`, `gpt-56-sol`, `gpt-5.6`, `gpt56`, `gpt-56` | 272K | $5.00 / $30.00 | n/a | | `gpt-5.6-terra` | openai | `terra`, `gpt-terra`, `gpt56-terra`, `gpt-56-terra` | 272K | $2.50 / $15.00 | n/a | | `gpt-5.6-luna` | openai | `luna`, `gpt-luna`, `gpt56-luna`, `gpt-56-luna` | 272K | $1.00 / $6.00 | n/a | | `gpt-5.4` | openai | `gpt54`, `gpt5`, `codex` | 272K | $2.50 / $15.00 | 70 tok/s | | `gpt-5.5` | openai | `gpt55` | 272K | $5.00 / $30.00 | 70 tok/s | | `gpt-5.5-pro` | openai | `gpt55-pro` | 1M | $30.00 / $180.00 | 20 tok/s | | `gpt-5.4-mini` | openai | `gpt54-mini`, `codex-spark` | 272K | $0.75 / $4.50 | 140 tok/s | | `gpt-5.4-pro` | openai | `gpt54-pro` | 1M | $30.00 / $180.00 | 20 tok/s | | `gemini-3.1-pro-preview` | gemini | `gemini-pro` | 1M | $2.00 / $12.00 | 85 tok/s | | `gemini-3.1-pro-preview-customtools` | gemini | `gemini-customtools` | 1M | $2.00 / $12.00 | 85 tok/s | | `gemini-3.5-flash` | gemini | `gemini-35-flash` | 1M | $1.50 / $9.00 | 150 tok/s | | `gemini-3-flash-preview` | gemini | `gemini-flash` | 1M | $0.50 / $3.00 | 150 tok/s | | `gemini-3.1-flash-lite` | gemini | `gemini-flash-lite`, `gemini-3.1-flash-lite-preview` | 1M | $0.25 / $1.50 | 200 tok/s | | `kimi-k2.5` | moonshot | | 262K | $0.60 / $3.00 | 50 tok/s | | `kimi-k3` | moonshot | `kimi` | 1M | $3.00 / $15.00 | n/a | | `kimi-k3-fast` | venice | `kimi-fast` | 1M | $4.50 / $22.50 | n/a | | `deepseek-v4-flash` | deepseek | `deepseek`, `deepseek-v4`, `deepseek-flash` | 1,048,576 | $0.14 / $0.28 | n/a | | `deepseek-v4-pro` | deepseek | | 1,048,576 | $0.435 / $0.87 | n/a | | `grok-4.6` | venice | `grok`, `grok46`, `grok-46` | 500K | $2.27 / $6.80 | n/a | | `laguna-s-2.1` | poolside | `laguna`, `laguna-s` | 1M | $0.10 / $0.20 | n/a | | `laguna-xs-2.1` | poolside | `laguna-xs` | 262K | $0.10 / $0.20 | n/a | | `glm-5.2` | zai | `glm`, `glm5`, `glm52`, `glm5.2` | 1M | $1.40 / $4.40 | n/a | | `glm-5.3` | venice | `glm`, `glm5`, `glm53`, `glm5.3`, `glm-5-3` | 1M | $1.75 / $5.50 | n/a | | `minimax-m2.5` | minimax | `minimax` | 197K | $0.30 / $1.20 | 45 tok/s | | `mercury-2` | inception | `mercury` | 131K | $0.25 / $0.75 | 1000 tok/s | | `qwen3.8-max` | venice | `qwen`, `qwen-max`, `qwen3.8`, `qwen-3.8`, `qwen38`, `qwen-3.8-max`, `qwen38-max` | 1M | $2.50 / $7.50 | n/a | | `qwen3.8-27b` | venice | `qwen-27b`, `qwen-3.8-27b`, `qwen38-27b` | 262K | $0.45 / $3.20 | n/a | Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup. Claude Fable 5 is available as an explicit model but is not the default Anthropic model. If Fable refuses a request, Fabro reports the refusal as a content-filter LLM error and applies the configured `run.model.fallbacks` chain when one is present. ## Configuring providers and models Fabro's catalog is the [lithos-llm](https://docs.rs/lithos-llm) built-in catalog. The `[llm]` table in settings is a second layer over it: a lithos catalog overlay that adds providers and models or changes existing entries. Later layers win. Tables merge key by key and every other value replaces. Models are nested under their provider, so two providers can expose the same model id without overwriting each other. Provider and model facts use lithos field names: `adapter`, `codecs`, `base_url`, `auth`, `enabled`, `limits`, `capabilities`, `pricing`, `small_default`, `probe`, `family`, and the cutoffs. The coding harness a model expects lives under `metadata.agent`, a namespace lithos ships and other agents such as Pebble read too. See [Settings Configuration](/reference/user-configuration#llm) for every key. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.proxy] display_name = "Acme Gateway" base_url = "https://llm-gateway.example.com/v1" auth = { type = "bearer" } aliases = ["gateway"] default_model = "team-code-large" [llm.providers.proxy.default_headers] x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}" x-portkey-config = "@bedrock-prod" [llm.providers.proxy.metadata.agent] profile = "anthropic" [llm.providers.proxy.models."team-code-large"] display_name = "Team Code Large" aliases = ["team-code"] api_model = "provider-wire-model-name" limits = { context_tokens = 200000, max_output_tokens = 32000 } capabilities = { text = true, tools = true, reasoning = true, caching = true, reasoning_effort = { low = true, medium = true, high = true } } protocol_options = { reasoning_effort_levels = true } pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000, cached_input_usd_micros_per_million = 300000 } family = "team-code" small_default = true estimated_output_tps = 80 ``` The gateway's API key is `PROXY_API_KEY`: lithos derives the secret name from the provider id (upper case, `-` and `.` as `_`, then `_API_KEY`). Store it with `fabro secret set PROXY_API_KEY ...`. For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Enable it in settings and declare the models your proxy exposes: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.litellm] base_url = "http://localhost:4000/v1" default_model = "litellm-gpt-5" enabled = true [llm.providers.litellm.models."litellm-gpt-5"] display_name = "LiteLLM GPT-5" api_model = "gpt-5" limits = { context_tokens = 128000, max_output_tokens = 8192 } capabilities = { text = true, tools = true } ``` `api_model` is the model name sent to that provider's API. It defaults to the exact model id, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value. A `provider/model` selector such as `openai/gpt-5.6-sol` pins the provider and names the model by id, alias, or wire id. A bare selector with no provider pin picks the highest-priority ready offering; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Providers with `allow_passthrough = true` also accept `provider/model` selectors for models the catalog does not list. Model roles are separate: the provider's `default_model` controls normal model selection for workflow execution, while `small_default = true` on a model row marks the provider's small utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's default model. Provider auth has two parts. The lithos `auth` scheme says how a credential is sent: `{ type = "bearer" }`, `{ type = "header", name = "x-api-key" }`, `{ type = "headers" }` for providers that take several secret headers, `{ type = "none" }`, or `{ type = "aws" }`. lithos also says which secret names a provider reads: `OPENAI_API_KEY` for `openai`, `GEMINI_API_KEY` then `GOOGLE_API_KEY` for `gemini`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` for `modal`, and `_API_KEY` for a provider you define. Fabro looks each name up in the process environment first and the server vault second. Custom headers for any provider go in `default_headers` as literal text or `{{ secrets.NAME }}` tokens; put credentials in secrets and reference them with `{{ secrets.NAME }}` instead of a bare literal. Workflow runs also add `x-session-id: ` to every LLM request so compatible gateways can group requests from the same run. An explicitly configured `x-session-id` in provider `default_headers` takes precedence. Provider `metadata.agent.profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `claude-5`, `openai`, `gemini`, `kimi`, `gpt56`, and `gpt6`; model-level values override provider-level values. Three profiles are selected per model rather than per provider, because they follow the model wherever it is served: `claude-5` for Claude 5 models, `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna); `gpt6` for GPT-6 Astra runs on the same harness as `gpt56`. The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional credential-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately. Costs come from the lithos `pricing` table on each model row. Each token bucket (input, output, reasoning, cache read, cache write) prices at its own rate, with optional long-context and speed tiers. Providers that return an authoritative charge, such as OpenRouter, override the catalog estimate; the billing record says which source it came from. Provider fields in configuration, APIs, and model routing are provider ID strings. Built-in names like `anthropic`, `openai`, and `gemini` still work, but custom IDs like `proxy` work anywhere a provider ID is accepted. ### Venice Fabro ships a built-in [Venice](/integrations/venice) provider with a curated catalog of Venice-hosted Kimi, Grok, GLM, DeepSeek, and Qwen models. Store its API key with `fabro provider login --provider venice`. Pin `provider = "venice"` when a shared model slug must use Venice instead of a higher-priority direct provider. ### Poolside Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_model` values. ### OpenRouter Fabro ships an [OpenRouter](/integrations/openrouter) provider definition with a curated model catalog, disabled by default. Enable it in settings and store an API key with `fabro provider login --provider openrouter`: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.openrouter] enabled = true ``` ### Modal Fabro ships a [Modal](/integrations/modal) provider definition for Kimi K3, disabled by default. Modal assigns the endpoint URL and authenticates requests with a two-part proxy token: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.modal] base_url = "https://your-endpoint.modal.run/v1" enabled = true ``` Store both token values in the Fabro server vault: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set MODAL_TOKEN_ID wk-... fabro secret set MODAL_TOKEN_SECRET ws-... ``` ### Amazon Bedrock Fabro ships an [Amazon Bedrock](/integrations/bedrock) provider definition with a curated multi-vendor catalog over Bedrock's Converse API, disabled by default. Enable it and authenticate with a Bedrock API key or AWS SigV4 credentials: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.bedrock] base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" enabled = true ``` ### Ollama Fabro ships an Ollama provider definition that is disabled by default. Enable it in settings when you want Fabro to route through a local Ollama server: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.ollama] enabled = true ``` Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.providers.ollama.models.""]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`. ## Default models When no model or provider is specified, Fabro chooses the default offering on the highest-priority ready provider. If no provider adapter is ready, run creation reports that no eligible offering is available. Each provider has its own default model: | Provider | Default model | | - | - | | `anthropic` | `claude-sonnet-5` | | `openai` | `gpt-5.6-sol` | | `gemini` | `gemini-3.5-flash` | | `moonshot` | `kimi-k3` | | `poolside` | `laguna-s-2.1` | | `zai` | `glm-5.2` | | `venice` | `deepseek-v4-flash` | | `minimax` | `minimax-m2.5` | | `inception` | `mercury-2` | ## Using models in workflows Assign models to workflow nodes using [model stylesheets](/workflows/stylesheets), which use a CSS-like syntax: ```dot title="example.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [ model_stylesheet=" * { model: claude-haiku-4-5; } .coding { model: claude-sonnet-4-5; reasoning_effort: high; } #review { model: gemini-3.1-pro-preview; } " ] spec [label="Write Spec"] implement [label="Implement", class="coding"] review [label="Review"] } ``` This routes the spec node to Haiku (the default), implementation to Sonnet, and review to Gemini Pro. ## Overriding the default model Model stylesheets set per-node models inside the workflow graph, but you can also override the default model for an entire run. This is useful for quick experimentation or when you want to swap models without editing the Graphviz file. ### CLI flags Pass `--model` and optionally `--provider` to `fabro run`: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/01-hello.fabro --model claude-opus-4-6 fabro run docs/internal/demo/04-pipeline.fabro --model gemini-3.1-pro-preview ``` These flags set the default model for all nodes that don't have an explicit model assigned via a stylesheet. Without `--provider`, Fabro selects among ready offerings by priority. Add `--provider` to pin an exact provider, including for an uncatalogued provider model string. ### Run config TOML For repeatable runs, set the model in a run config file: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [workflow] graph = "implement.fabro" [run] goal = "Implement the feature" [run.model] name = "claude-sonnet-4-5" [run.model.fallbacks] "claude-sonnet-4-5" = ["gemini", "openai"] ``` Then launch with: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run run.toml ``` The `[run.model.fallbacks]` table is optional. Each key names the originally requested model. Its value is the ordered list to try after that model's active provider fails. An entry may be a bare provider token (like `"gemini"`), a bare model ID or alias (like `"gpt-terra"`), or a qualified `"provider:selector"` reference. A qualified selector may be the provider's canonical model ID, alias, or API ID, including API IDs with slashes such as `"openrouter:moonshotai/kimi-k3"`. Fabro selects one chain from the original request. It does not switch to the chain configured for a fallback target. When the requested reasoning level is unavailable on a fallback target, Fabro uses the nearest supported level. Equal-distance choices round up. The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win. ## CLI commands ### List models View all available models, or filter by provider: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list fabro model list --provider anthropic fabro model list --query codex ``` ### Test models Verify that your API keys are working by sending a test prompt to each configured provider: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model test fabro model test --model claude-sonnet-4-5 fabro model test --provider openai ``` This is useful for confirming connectivity after setup or when adding a new provider key. # Workflows Source: https://docs.fabro.sh/core-concepts/workflows Core workflow concepts in Fabro A workflow is a directed graph that defines a repeatable process for AI agents, shell commands, and human decisions. Unlike a DAG (directed acyclic graph), a Fabro workflow can and often does include loops — for example, implement-test-fix cycles that repeat until tests pass. You write workflows in [Graphviz DOT](/reference/dot-language), check them into version control, and run them with `fabro run`. ## Anatomy of a workflow Every workflow is a `digraph` with a `goal`, a `start` node, an `exit` node, and one or more processing nodes connected by edges: Simple workflow: Start → Scan Files → Analyze → Exit ```dot title="my-workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph MyWorkflow { graph [goal="Describe the project"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] scan [label="Scan Files", shape=parallelogram, script="find . -maxdepth 2 -type f | head -30"] analyze [label="Analyze", prompt="Review the file listing. Summarize the project structure.", shape=tab] start -> scan -> analyze -> exit } ``` The `goal` attribute describes what the workflow accomplishes. Fabro uses it to guide agent behavior. ## Key node types Each node's Graphviz **shape** determines how it executes. The three most important types are: **Agents** (default `box` shape) run an LLM with access to tools — bash, file editing, sub-agents — looping autonomously until the task is complete: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [label="Implement", prompt="Read plan.md and implement every step."] ``` **Commands** (`parallelogram`) run shell scripts and capture output for downstream nodes: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} validate [label="Run Tests", shape=parallelogram, script="cargo test 2>&1 || true"] ``` **Human gates** (`hexagon`) pause the workflow and wait for a person to choose a path. Edge labels define the options: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve Plan"] approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] ``` Fabro supports additional node types for one-shot prompts, conditional branching, parallel fan-out/fan-in, and more. See [Stages and Nodes](/workflows/stages-and-nodes) for the full reference. ## Branching and loops Branching and loop workflow Edges can have **conditions** that route execution based on outcomes: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate [shape=diamond, label="Tests passing?"] gate -> exit [label="Pass", condition="outcome=succeeded"] gate -> implement [label="Fix"] ``` Loops are natural — just point an edge back to an earlier node. Use `max_visits` on a node to prevent infinite loops: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fix [label="Fix Failures", prompt="Fix the failing tests.", max_visits=3] ``` ## Parallel execution Parallel fan-out and merge workflow Fan out to run branches concurrently, then merge the results: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fork [label="Fan Out", shape=component] merge [label="Merge", shape=tripleoctagon] fork -> security fork -> architecture fork -> quality security -> merge architecture -> merge quality -> merge merge -> report -> exit ``` ## Goal gates Mark critical nodes with `goal_gate=true`. The workflow fails if any goal gate doesn't succeed — even if execution reaches the exit node: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} validate [label="Validate", prompt="Run the test suite and verify all tests pass.", goal_gate=true] ``` ## Running a workflow From the CLI: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro ``` Or from a [run config TOML](/execution/run-configuration) for repeatable, parameterized runs: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run run.toml ``` In the web UI, the Workflows page lists all available workflows. Click into a workflow to view its Graphviz definition, rendered graph diagram, and run history. Fabro web UI Workflows list showing Fix Build, Implement Feature, Sync Drift, and Expand Product workflows Fabro web UI workflow detail showing the Graphviz source for Fix Build Fabro web UI workflow diagram showing the Fix Build workflow graph Fabro web UI workflow runs tab showing run history for Fix Build See the [Quick Start](/getting-started/quick-start) to try it out, or browse the [example workflows](/examples/repl-handoff) for real-world patterns. ## Select workflow source and run target `fabro run` and `fabro create` accept the same source and target options. `create` registers the workflow and leaves a submitted run for you to start with `fabro start RUN`. `run` also starts it, then attaches unless you pass `--detach`. The required positional argument selects the workflow. Local names are found in the current checkout, then a marked project, then installed user workflows. Explicit local paths retain their usual package roots. ```sh theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run review fabro create ./review.toml --target-from ../app fabro run acme/workflows@v1.2:review --target acme/app@release # Equivalent explicit flags: fabro run review --workflow-repo acme/workflows --workflow-ref v1.2 \ --target-repo acme/app --target-branch release ``` In the last example, workflow instructions come from `acme/workflows`, and the run works on `acme/app`. Neither selection changes the other. Local workflow paths, `--goal-file`, and other caller inputs still resolve from the invocation context. Without target flags, Fabro keeps its existing cwd/environment-based target inference. Remote workflow shorthand is `OWNER/REPO[@REF]:WORKFLOW`. The workflow selector is required: `acme/workflows:review` selects a named workflow, and `acme/workflows@v1.2:./reviews/security.toml` selects a file. Repository default workflows are not supported; without `:WORKFLOW`, the positional argument retains local lookup behavior. Prefix local paths containing a colon with `./`, `../`, or `/` to avoid shorthand parsing. Shorthand cannot be combined with `--workflow-repo` or `--workflow-ref`. Repository slugs currently imply GitHub.com. `--workflow-repo OWNER/REPO` acquires source using native Git on your machine. A workflow name selects `.fabro/workflows/NAME/workflow.toml` in that repository; you can also supply an explicit repository-relative `.toml` or `.fabro` file. Absolute paths, traversal, and directory selectors are rejected. A missing remote workflow never falls back to a local or installed workflow. `--workflow-ref` requires `--workflow-repo`. Omit it, or use `HEAD`, to select the remote default branch. You can select a branch, tag, or full 40-hex commit SHA. If a branch and tag share a name, qualify it with `refs/heads/` or `refs/tags/`. Fabro resolves the revision once, fetches that exact commit into a temporary checkout, and registers its workflow-version closure. A moved or unavailable commit never causes a fallback to a newer revision. Checkout hooks, content filters, and implicit Git LFS expansion are disabled; submodules are not fetched. Temporary files are removed after collection or failure. Interrupting acquisition stops owned Git processes before cleanup; collection already in progress must finish before its files can be removed. For local workflows without target flags, the existing `run.scm` repository configuration in `workflow.toml` or `.fabro/project.toml` still participates in target inference, with workflow values overriding project values field by field. For clone-based environments, the configured repository must match the checkout's origin. Without that configuration, omission is equivalent to `--target-from .`. Explicit `--target-from`, `--target-repo`, and `--target` selections take precedence over the configured repository. Target selection depends on the environment: | Selection | Local environment | Clone-based environment (Docker, Daytona, or plugin) | | - | - | - | | Default cwd or `--target-from PATH` | Clones the enclosing Git repository's committed `HEAD`; uncommitted files are not included, and a non-Git directory selects an empty workspace | Uses the enclosing Git repository and exact available commit; a non-Git directory selects an empty workspace | | `--target-repo OWNER/REPO` or `--target OWNER/REPO[@BRANCH]` | Rejected | Uses the selected repository and exact observed branch commit; cloning must be enabled | For clone-based execution, a target path selects a repository, not a subdirectory working-directory override. Local target files are not uploaded. Existing target observation may push committed local changes to origin; dirty changes are excluded from clone targets and produce a warning. Detached or unavailable exact commits fail. Folder targets require the directory to be accessible to the server and its Local execution environment; passing a caller-local path does not transfer it to a remote server. `--target-branch` requires `--target-repo` and accepts a working branch name, not a tag or SHA. Without it, Fabro resolves the repository's default branch. The CLI only looks up target metadata; the execution sandbox clones the target. The shorthand `--target acme/app@release/v2` selects the working branch `release/v2`; omit `@BRANCH` to use the remote default branch. Target suffixes accept working branches, while workflow suffixes accept branches, tags, or SHAs. `--target`, `--target-from`, and `--target-repo` are mutually exclusive. `--target-branch` cannot be combined with `--target`. Local Git credential helpers, SSH-agent access through configured URL rewrites, and user network configuration govern source acquisition and remote target lookup. Fabro server login does not grant local Git access. The execution sandbox still needs its own target-clone credentials. `--dry-run` simulates execution; it can still fetch and upload workflow source, and existing local target observation can still publish committed changes. # Clone Substack Source: https://docs.fabro.sh/examples/clone-substack Build an entire application from a detailed specification using debate planning, a staged verify chain, ensemble review, and a postmortem repair loop The Clone Substack workflow takes a detailed specification document and autonomously builds a complete, working application — in this case, a Substack-like newsletter creation tool. It uses ensemble planning (two independent plans debated into one), a multi-stage verification chain, parallel code review with consensus, and a postmortem repair loop that feeds failures back into the next iteration. This pattern is adapted from [Kilroy](https://github.com/danshapiro/kilroy)'s `substack-spec-v01.fabro`, which builds a full React application from a natural language spec with acceptance criteria. ## When to use this * You have a detailed spec and a definition of done with concrete acceptance criteria * The deliverable is large enough that a single agent pass won't get it right * You want automated verification (build, format, tests, browser checks) with human-free repair loops * You want independent review perspectives before accepting the result ## The workflow Clone Substack workflow: Start → Bootstrap → Plan Fan-Out → Plan A and Plan B → Debate → Implement → Verify Chain → Review Fan-Out → Review A and Review B → Consensus → Exit, with Fix loop from Verify back to Implement, Rejected path from Consensus to Postmortem, and Replan loop from Postmortem back to Plan Fan-Out ```dot title="clone-substack.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph CloneSubstack { graph [ goal="Build the Substack Creator Newsletter Engine — a pure React frontend \ (no backend) for brand-driven Substack content creation. Client-side Gemini LLM \ integration (gemini-3-flash-preview for fast tasks, gemini-3.1-pro-preview with \ extended thinking for important tasks, gemini-2.5-flash-lite for tests). IndexedDB \ persistence via idb library. Setup flow with API key, company identity, voice \ definition, and guardrails — each confirmed by gemini-3.1-pro-preview. Dashboard \ with post history, draft management, New Post and Trending Topics access. Trending \ Topics uses gemini-3-flash-preview with search grounding for research, \ gemini-3.1-pro-preview synthesizes 3 writing prompts. New Post pipeline: Topic \ (rich input) then Research (gemini-3-flash-preview search grounding, source \ metadata) then Outline (gemini-3.1-pro-preview one-shot) then Write/Edit/Guardrails \ (3 automatic gemini-3.1-pro-preview cycles) then Complete (serif footnoted citations \ with attribution lineage). Demo mode replays recorded sessions through production \ code path with fade-in prefills; ships with one bundled P&G session; cache miss \ shows error, no API fallback. Visual design matches Substack (serif fonts, horizontal \ dot step indicators, card primitives, accent progress bars, numbered footnotes). Test \ infrastructure: integration tests with canned data, smoke tests with live \ gemini-2.5-flash-lite, manual test option with real models. Deployment readiness for \ GitHub to Railway validated by code review only — no live deployment execution.", rankdir=LR, default_max_retries=3, retry_target="plan_fanout", fallback_retry_target="plan_fanout", model_stylesheet=" * { model: claude-opus-4-6; } .hard { model: gpt-5.4; } .verify { model: claude-opus-4-6; } .branch-a { model: claude-opus-4-6; } .branch-b { model: gemini-3-flash-preview;} " ] exit [shape=Msquare, label="Exit"] // ========================================================================= // Bootstrap // ========================================================================= subgraph cluster_bootstrap { label="Bootstrap" start [shape=Mdiamond, label="Start"] check_toolchain [ shape=parallelogram, label="Check Toolchain", max_retries=0, script="command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 \ && node --version && npm --version" ] expand_spec [ label="Expand Spec", prompt="Goal: {{ goal }}\n\n\ The project specification is at substack-spec-v01.md and the Definition of Done \ is at substack-dod-v01.md. The UI flow diagram is at substack-spec-v01-ui.gv.\n\n\ Read all three files. Scratch artifacts go under .workflow/.\n\n\ If .workflow/spec.md does not exist or is a placeholder, copy the spec verbatim \ to .workflow/spec.md, appending a reference to the UI flow diagram. If \ .workflow/definition_of_done.md does not exist or is a placeholder, copy the DoD \ verbatim to .workflow/definition_of_done.md. If both already exist and are \ adequate, skip." ] } // ========================================================================= // Planning Fanout // ========================================================================= subgraph cluster_planning { label="Planning Fanout" node [shape=box] plan_fanout [shape=component, label="Plan Fan-Out"] plan_a [ label="Plan A", class="branch-a", prompt="Goal: {{ goal }}\n\n\ Read .workflow/spec.md and .workflow/definition_of_done.md. If those files do not \ exist, fall back to reading substack-spec-v01.md and substack-dod-v01.md directly. \ If .workflow/postmortem_latest.md exists, incorporate its lessons.\n\n\ Create an implementation plan for the Substack Creator Newsletter Engine covering \ all deliverables and acceptance criteria from the DoD. Be specific about:\n\ - React project setup (Vite + TypeScript + idb + @google/generative-ai SDK)\n\ - Module decomposition with file paths and estimated sizes (~200-500 lines each)\n\ - Core infrastructure: LLM client with structured JSON output and retry/backoff, \ IndexedDB persistence layer, shared UI components (rich input, card, progress bar, \ step indicators)\n\ - Feature modules: Setup flow, Dashboard, Trending Topics, New Post pipeline, \ Demo mode\n\ - Test infrastructure: integration (canned data), smoke (gemini-2.5-flash-lite), \ manual (real models)\n\ - Deployment config: Railway config, validation scripts\n\ - Build/validate scripts: validate-build.sh, validate-fmt.sh, validate-test.sh, \ validate-browser.sh, fix-fmt.sh, validate-artifacts.sh\n\ - Visual design: Substack-like serif styling, no spinners\n\n\ Specify dependency ordering between modules.\n\n\ Write to .workflow/plan_a.md." ] plan_b [ label="Plan B", class="branch-b", prompt="Goal: {{ goal }}\n\n\ Read .workflow/spec.md and .workflow/definition_of_done.md. If those files do not \ exist, fall back to reading substack-spec-v01.md and substack-dod-v01.md directly. \ If .workflow/postmortem_latest.md exists, incorporate its lessons.\n\n\ Create an implementation plan for the Substack Creator Newsletter Engine covering \ all deliverables and acceptance criteria from the DoD. Be specific about:\n\ - React project setup (Vite + TypeScript + idb + @google/generative-ai SDK)\n\ - Module decomposition with file paths and estimated sizes (~200-500 lines each)\n\ - Core infrastructure: LLM client with structured JSON output and retry/backoff, \ IndexedDB persistence layer, shared UI components (rich input, card, progress bar, \ step indicators)\n\ - Feature modules: Setup flow, Dashboard, Trending Topics, New Post pipeline, \ Demo mode\n\ - Test infrastructure: integration (canned data), smoke (gemini-2.5-flash-lite), \ manual (real models)\n\ - Deployment config: Railway config, validation scripts\n\ - Build/validate scripts: validate-build.sh, validate-fmt.sh, validate-test.sh, \ validate-browser.sh, fix-fmt.sh, validate-artifacts.sh\n\ - Visual design: Substack-like serif styling, no spinners\n\n\ Specify dependency ordering between modules.\n\n\ Write to .workflow/plan_b.md." ] debate [ label="Debate & Consolidate", prompt="Synthesize the two implementation plans into a single best-of-breed \ final plan.\n\n\ Review every branch result in parallel.results from the prompt context, then read \ .workflow/plan_a.md and .workflow/plan_b.md from the shared checkout.\n\n\ If .workflow/postmortem_latest.md exists, read it FIRST. The postmortem contains \ root-cause analysis and concrete fixes from the previous iteration. The final plan \ MUST be adjusted to address every issue identified in the postmortem — add new \ steps, change approaches, or reorder work as needed. Do not simply re-emit the \ same plan that failed.\n\n\ Also read .workflow/implementation_log.md and .workflow/verify_fidelity.md if they \ exist, to understand what worked and what failed in the previous iteration.\n\n\ Read .workflow/spec.md and .workflow/definition_of_done.md for context. If those \ files do not exist, fall back to reading substack-spec-v01.md and \ substack-dod-v01.md directly. Resolve conflicts between plans. Ensure dependency \ order is correct. Pick the most detailed and actionable approach for each module. \ The final plan must produce a work queue of bounded modules (~200-500 lines each), \ ordered so core infrastructure items come first (lower IDs), features next, and \ tests/deployment last.\n\n\ Write the final plan to .workflow/plan_final.md." ] } // ========================================================================= // Implement // ========================================================================= subgraph cluster_implement { label="Implement" implement [ class="hard", max_tokens=32768, label="Implement", prompt="Goal: {{ goal }}\n\n\ Read .workflow/plan_final.md, .workflow/spec.md, and \ .workflow/definition_of_done.md. If the spec or DoD files do not exist at those \ paths, fall back to reading substack-spec-v01.md and substack-dod-v01.md directly.\n\n\ BEFORE ANYTHING ELSE: check if .workflow/verify_errors.log exists. If it does, \ read it — it contains the exact commands that failed and their error output from \ the verify chain. Fix every error listed in that file, then delete \ .workflow/verify_errors.log when all fixes are applied. Do NOT regenerate working \ code — only fix the specific errors.\n\n\ Also check if .workflow/verify_fidelity.md exists. If it does, read it — it \ contains per-AC pass/fail verdicts from the fidelity check. Fix every failing AC \ listed in that file.\n\n\ If .workflow/postmortem_latest.md exists, read it and fix ONLY identified gaps — \ do NOT regenerate working code. On repair passes, read and fix existing files \ rather than skipping them.\n\n\ Implement the complete Substack Creator Newsletter Engine as a single pass. On a \ fresh pass (no postmortem), check if target files already exist on disk and are \ non-empty — if so, skip those files. Implement each module with complete, \ functional code — no stubs, no placeholders, no TODO comments. Follow the plan \ and spec precisely.\n\n\ Implementation order (core infrastructure first, then features, then tests/deploy):\n\n\ 1. Project scaffold — package.json, vite.config.ts, tsconfig.json, index.html, \ src/main.tsx, src/App.tsx. Install dependencies: react, react-dom, \ react-router-dom, idb, @google/generative-ai. Write ALL validation scripts:\n\ - scripts/validate-build.sh: runs npm run build, checks dist/ exists\n\ - scripts/validate-fmt.sh: runs npx prettier --check src/\n\ - scripts/validate-test.sh: runs integration scenarios first, then smoke, \ writes evidence + .workflow/test-evidence/latest/manifest.json even on failure\n\ - scripts/validate-browser.sh: runs browser verification and captures artifacts\n\ - scripts/fix-fmt.sh: runs npx prettier --write src/\n\ - scripts/validate-artifacts.sh: verifies manifest scenario IDs match DoD \ integration scenarios\n\ All scripts: #!/bin/sh, set -e, POSIX sh failure trap.\n\n\ 2. LLM client — src/lib/llm.ts, src/lib/llm-schemas.ts: structured JSON output \ with schema enforcement, retry with error feedback and intelligent backoff, model \ switching (gemini-3-flash-preview/gemini-3.1-pro-preview/gemini-2.5-flash-lite), \ client-side API key.\n\n\ 3. Persistence — src/lib/db.ts, src/lib/types.ts: IndexedDB via idb with stores \ for configuration (API key, company, voice, guardrails), drafts, sessions (all \ inputs/LLM responses/intermediate state), post history (Markdown + attribution). \ All data persists unless user resets.\n\n\ 4. Shared UI — src/components/RichInput.tsx, Card.tsx, ProgressBar.tsx, \ StepIndicator.tsx, src/styles/global.css.\n\n\ 5. Setup flow — src/pages/Settings.tsx and step components: API key, company \ (rich input + gemini-3.1-pro-preview confirm + back), voice, guardrails. Parallel \ completion, status icons. Reset everything with confirmation.\n\n\ 6. Dashboard — src/pages/Dashboard.tsx: New Post button, Trending Topics button, \ Settings link, post history, draft resume.\n\n\ 7. Trending Topics — src/pages/TrendingTopics.tsx: parallel gemini-3-flash-preview \ search grounding queries, trend visualization, 3 gemini-3.1-pro-preview writing \ prompts, navigate to New Post prefilled.\n\n\ 8. New Post — src/pages/NewPost.tsx with step components: Topic (rich input), \ Research (gemini-3-flash-preview search grounding, source cards with \ URL/title/author/date, highlight/delete), Outline (gemini-3.1-pro-preview one-shot, \ accept/back), Write (3 automatic gemini-3.1-pro-preview cycles: Write with \ citations, Edit for style, Guardrails-only), Complete (serif post with numbered \ footnotes, linked sources, attribution lineage).\n\n\ 9. Demo mode — src/lib/demo.ts, src/pages/DemoMode.tsx, \ src/demo/bundled-session.json: session recording, replay through production path \ (fade-in prefills, highlight next button), bundled P&G session, cache-miss error \ with no API fallback.\n\n\ 10. Test infrastructure — src/__tests__/: integration with canned data, smoke with \ gemini-2.5-flash-lite, manual mode option.\n\n\ 11. Deploy config — railway.json or equivalent, deployment docs.\n\n\ Ensure App.tsx routing includes all pages. Verify imports/exports are consistent. \ Run npm install. Fix TypeScript errors.\n\n\ Log progress to .workflow/implementation_log.md.\n\n\ PRE-EXIT VERIFICATION: if .workflow/postmortem_latest.md exists, run \ sh scripts/validate-build.sh and re-read targeted files to confirm fixes." ] } // ========================================================================= // Verify Chain // ========================================================================= subgraph cluster_verify { label="Verify" fix_fmt [ shape=parallelogram, label="Fix Format", max_retries=0, script="sh scripts/fix-fmt.sh 2>&1 || { printf '\\n=== VERIFY FAILURE: \ fix-fmt ===\\n%s\\n' \"$(cat /tmp/fix-fmt.log 2>/dev/null || echo 'script missing \ or produced no output')\" >> .workflow/verify_errors.log; exit 1; }" ] verify_fmt [ shape=parallelogram, label="Check Format", max_retries=0, script="sh scripts/validate-fmt.sh 2>&1 | tee /tmp/validate-fmt.log; \ test ${PIPESTATUS[0]} -eq 0 || { printf '\\n=== VERIFY FAILURE: validate-fmt \ ===\\n%s\\n' \"$(tail -30 /tmp/validate-fmt.log)\" >> .workflow/verify_errors.log; \ exit 1; }" ] gate_fmt [shape=diamond, label="Fmt OK?"] verify_build [ shape=parallelogram, label="Check Build", script="sh scripts/validate-build.sh 2>&1 | tee /tmp/validate-build.log; \ test ${PIPESTATUS[0]} -eq 0 || { printf '\\n=== VERIFY FAILURE: validate-build \ ===\\n%s\\n' \"$(tail -50 /tmp/validate-build.log)\" >> .workflow/verify_errors.log; \ exit 1; }" ] gate_build [shape=diamond, label="Build OK?"] verify_test [ shape=parallelogram, label="Run Tests", script="sh scripts/validate-test.sh 2>&1 | tee /tmp/validate-test.log; \ test ${PIPESTATUS[0]} -eq 0 || { printf '\\n=== VERIFY FAILURE: validate-test \ ===\\n%s\\n' \"$(tail -50 /tmp/validate-test.log)\" >> .workflow/verify_errors.log; \ exit 1; }" ] gate_test [shape=diamond, label="Tests OK?"] verify_browser [ shape=parallelogram, label="Check Browser", script="sh scripts/validate-browser.sh 2>&1 | tee \ /tmp/validate-browser.log; test ${PIPESTATUS[0]} -eq 0 || { printf '\\n=== VERIFY \ FAILURE: validate-browser ===\\n%s\\n' \"$(tail -50 /tmp/validate-browser.log)\" \ >> .workflow/verify_errors.log; exit 1; }" ] gate_browser [shape=diamond, label="Browser OK?"] verify_artifacts [ shape=parallelogram, label="Check Artifacts", max_retries=0, script="sh scripts/validate-artifacts.sh 2>&1 | tee \ /tmp/validate-artifacts.log; test ${PIPESTATUS[0]} -eq 0 || { printf '\\n=== \ VERIFY FAILURE: validate-artifacts ===\\n%s\\n' \"$(tail -30 \ /tmp/validate-artifacts.log)\" >> .workflow/verify_errors.log; exit 1; }" ] gate_artifacts [shape=diamond, label="Artifacts OK?"] verify_fidelity [ label="Verify Fidelity", class="verify", prompt="Read .workflow/spec.md, .workflow/definition_of_done.md, \ .workflow/verify_fidelity.md (if present), \ .workflow/test-evidence/latest/manifest.json, and relevant implementation files.\n\n\ Evaluate these grouped acceptance checks and map each to concrete file paths:\n\ AC1: src/**/settings* and src/**/router* and src/**/indexeddb* - first-run routing, \ setup flow, persistence.\n\ AC2: src/**/dashboard* and src/**/history* and src/**/draft* - dashboard actions \ and resume/view flows.\n\ AC3: src/**/trending* and src/**/research* - grounded research, deterministic \ trends, prompt handoff.\n\ AC4: src/**/new-post* and src/**/outline* and src/**/write* and src/**/complete* - \ full Topic->Complete pipeline with automatic write cycles.\n\ AC5: src/**/citation* and src/**/markdown* - citation lineage, footnote rendering, \ attribution persistence.\n\ AC6: src/**/demo* and src/demo/** - session picker/replay, bundled P&G demo, \ cache-miss no-fallback behavior.\n\ AC7: src/**/llm* and src/**/schema* - structured outputs, retry/backoff, \ production/test model intent.\n\ AC8: scripts/validate-build.sh and railway.json/Procfile/README* - \ build/deploy-readiness by static review only, no live deployment execution.\n\ AC9: scripts/validate-test.sh and scripts/validate-browser.sh and test sources - \ integration before smoke, manual mode option, browser evidence capture.\n\ AC10: .workflow/test-evidence/latest/manifest.json and \ .workflow/test-evidence/latest/** - IT-1..IT-12 manifest coverage and required \ artifact types.\n\ AC11: src/**/style* and src/**/card* and src/**/progress* - Substack-like visual \ contract including bars, cards, and serif post preview.\n\n\ Write .workflow/verify_fidelity.md with pass/fail verdict and evidence per \ AC1..AC11.\n\n\ On ANY failure: also append to .workflow/verify_errors.log with the header \ '=== VERIFY FAILURE: verify_fidelity ===' followed by the list of failing ACs \ and their specific issues, so the implement node can read the consolidated \ error log.\n\n\ If all ACs pass, include in your response:\n\ {\"context_updates\": {\"all_acs_pass\": \"true\"}}\n\n\ If any AC fails, include in your response:\n\ {\"context_updates\": {\"all_acs_pass\": \"false\"}}" ] gate_fidelity [shape=diamond, label="Fidelity OK?"] } // ========================================================================= // Review Fanout // ========================================================================= subgraph cluster_review { label="Review Fanout" node [shape=box] review_fanout [shape=component, label="Review Fan-Out"] review_a [ label="Review A", class="branch-a", prompt="Review the Substack Creator Newsletter Engine implementation \ against .workflow/definition_of_done.md.\n\n\ Read the DoD for acceptance criteria. Read all implementation source files and \ .workflow/test-evidence/latest/manifest.json.\n\n\ ## MANDATORY: Browser verification\n\ You MUST verify the app works in a real browser. Do not trust code reading alone.\n\ 1. Run: npm run build (must exit 0)\n\ 2. Start the preview server: npx vite preview --port 4567 &\n\ 3. Wait 2 seconds, then use curl to fetch http://localhost:4567/ and verify it \ returns HTML with a root div\n\ 4. Check that the HTML references JS and CSS bundles\n\ 5. Kill the preview server when done\n\ 6. Check browser artifacts in .workflow/test-evidence/latest/ — screenshots must \ be real rendered pages (not 1x1 placeholders). If screenshot files are under 5KB, \ they are fake. REJECT.\n\ 7. Check that playwright-report or equivalent browser test output exists and shows \ real test execution\n\n\ If browser verification fails or artifacts are fake, REJECT immediately.\n\n\ ## Code and AC verification\n\ Check every AC group (AC1 through AC11):\n\n\ AC1: Build exits 0, static assets produced, deployment config present and coherent \ (review only, no live deploy)\n\ AC2: IndexedDB persistence for API key, config, posts, drafts, sessions, \ attribution mappings across reloads\n\ AC3: Structured JSON output with retry/backoff, correct model routing \ (gemini-3-flash-preview/gemini-3.1-pro-preview/gemini-2.5-flash-lite), \ client-side key\n\ AC4: Parallel setup (any order), status icons, rich input (text/upload/link), \ gemini-3.1-pro-preview confirmation, back button\n\ AC5: Dashboard with New Post and Trending Topics buttons, Settings link, post \ history, draft resume\n\ AC6: Trending Topics: parallel gemini-3-flash-preview search research, trend \ visualization, 3 gemini-3.1-pro-preview writing prompts, navigate to New Post\n\ AC7: Full post pipeline with source metadata, highlight/delete, one-shot outline, \ 3 automatic write cycles, citations with attribution lineage\n\ AC8: Demo replay through production path, fade-in/highlight, bundled P&G session, \ cache-miss error\n\ AC9: Validation scripts and runtime evidence contract for build/test/browser checks\n\ AC10: IT-1..IT-12 evidence manifest coverage and artifact completeness\n\ AC11: Substack visual design: serif fonts, step dots, card primitive, accent \ progress bars, no spinners, footnoted post\n\n\ Verdict: APPROVED (all criteria met with evidence) or REJECTED (specific gaps \ by AC ID).\n\ Write to .workflow/review_a.md." ] review_b [ label="Review B", class="branch-b", prompt="Review the Substack Creator Newsletter Engine implementation \ against .workflow/definition_of_done.md.\n\n\ Read the DoD for acceptance criteria. Read all implementation source files and \ .workflow/test-evidence/latest/manifest.json.\n\n\ ## MANDATORY: Browser verification\n\ You MUST verify the app works in a real browser. Do not trust code reading alone.\n\ 1. Run: npm run build (must exit 0)\n\ 2. Start the preview server: npx vite preview --port 4568 &\n\ 3. Wait 2 seconds, then use curl to fetch http://localhost:4568/ and verify it \ returns HTML with a root div\n\ 4. Check that the HTML references JS and CSS bundles\n\ 5. Kill the preview server when done\n\ 6. Check browser artifacts in .workflow/test-evidence/latest/ — screenshots must \ be real rendered pages (not 1x1 placeholders). If screenshot files are under 5KB, \ they are fake. REJECT.\n\ 7. Check that playwright-report or equivalent browser test output exists and shows \ real test execution\n\n\ If browser verification fails or artifacts are fake, REJECT immediately.\n\n\ ## Code and AC verification\n\ Check every AC group (AC1 through AC11):\n\n\ AC1: Build exits 0, static assets produced, deployment config present and coherent \ (review only, no live deploy)\n\ AC2: IndexedDB persistence for API key, config, posts, drafts, sessions, \ attribution mappings across reloads\n\ AC3: Structured JSON output with retry/backoff, correct model routing \ (gemini-3-flash-preview/gemini-3.1-pro-preview/gemini-2.5-flash-lite), \ client-side key\n\ AC4: Parallel setup (any order), status icons, rich input (text/upload/link), \ gemini-3.1-pro-preview confirmation, back button\n\ AC5: Dashboard with New Post and Trending Topics buttons, Settings link, post \ history, draft resume\n\ AC6: Trending Topics: parallel gemini-3-flash-preview search research, trend \ visualization, 3 gemini-3.1-pro-preview writing prompts, navigate to New Post\n\ AC7: Full post pipeline with source metadata, highlight/delete, one-shot outline, \ 3 automatic write cycles, citations with attribution lineage\n\ AC8: Demo replay through production path, fade-in/highlight, bundled P&G session, \ cache-miss error\n\ AC9: Validation scripts and runtime evidence contract for build/test/browser checks\n\ AC10: IT-1..IT-12 evidence manifest coverage and artifact completeness\n\ AC11: Substack visual design: serif fonts, step dots, card primitive, accent \ progress bars, no spinners, footnoted post\n\n\ Verdict: APPROVED (all criteria met with evidence) or REJECTED (specific gaps \ by AC ID).\n\ Write to .workflow/review_b.md." ] review_consensus [ label="Review Consensus", goal_gate=true, retry_target="postmortem", prompt="Synthesize the two reviews into a consensus verdict.\n\n\ Review every branch result in parallel.results from the prompt context, then read \ .workflow/review_a.md and .workflow/review_b.md from the shared checkout.\n\n\ Read .workflow/definition_of_done.md for acceptance criteria reference.\n\n\ Consensus rules:\n\ - Both APPROVED with no critical gaps: the implementation passes\n\ - Any critical gap identified by either reviewer: rejected with specific AC IDs\n\ - Mixed verdicts: rejected with gaps enumerated\n\n\ Write to .workflow/review_consensus.md.\n\n\ If approved, respond with:\n\ {\"preferred_next_label\": \"approved\"}\n\n\ If rejected, respond with:\n\ {\"preferred_next_label\": \"rejected\"}" ] } // ========================================================================= // Postmortem // ========================================================================= subgraph cluster_postmortem { label="Postmortem" postmortem [ label="Postmortem", prompt="Analyze the failure and guide the next repair iteration.\n\n\ Read (if they exist):\n\ - .workflow/review_consensus.md\n\ - .workflow/verify_fidelity.md\n\ - .workflow/implementation_log.md\n\ - .workflow/test-evidence/latest/manifest.json\n\ - Evidence files referenced by manifest entries for failed or suspicious IT \ scenarios\n\ - Parallel branch review status and context updates from parallel.results (if available)\n\n\ Output to .workflow/postmortem_latest.md (overwrite previous):\n\ - Root causes of failure\n\ - What works and must be preserved\n\ - What failed and must be fixed\n\ - Concrete next changes (specific files, specific fixes)\n\ - Evidence file paths read (or explicit reason each was skipped)\n\ - Do NOT direct from-scratch restart — preserve working code\n\n\ PROGRESS DETECTION (required):\n\ Extract current failing AC IDs from verify_fidelity.md or review outputs. \ Compare with previous iteration and note whether progress was made \ (fewer/different failing ACs) or zero progress (identical set).\n\n\ OUTCOME CLASSIFICATION:\n\ - replan: default — always routes back through planning so the plan can be \ adjusted based on this postmortem\n\ - needs_toolchain: environment/bootstrap/toolchain issue detected (routes to \ check_toolchain)\n\n\ Respond with exactly one of:\n\ {\"preferred_next_label\": \"replan\", \"context_updates\": \ {\"last_failing_acs\": \"AC1,AC7\"}}\n\ {\"preferred_next_label\": \"needs_toolchain\", \"context_updates\": \ {\"last_failing_acs\": \"AC1,AC7\"}}" ] } // ========================================================================= // Edges // ========================================================================= // Bootstrap start -> check_toolchain check_toolchain -> expand_spec [condition="outcome=succeeded"] check_toolchain -> check_toolchain [condition="outcome=failed && context.failure_class=transient_infra", loop_restart=true] check_toolchain -> postmortem [condition="outcome=failed && context.failure_class!=transient_infra"] check_toolchain -> postmortem expand_spec -> plan_fanout // Planning plan_fanout -> plan_a plan_fanout -> plan_b plan_a -> debate plan_b -> debate debate -> implement // Implement -> Verify chain implement -> fix_fmt // Verify chain — failures go directly back to implement (errors logged to .workflow/verify_errors.log) fix_fmt -> verify_fmt verify_fmt -> gate_fmt gate_fmt -> verify_build [condition="outcome=succeeded"] gate_fmt -> implement verify_build -> gate_build gate_build -> verify_test [condition="outcome=succeeded"] gate_build -> implement verify_test -> gate_test gate_test -> verify_browser [condition="outcome=succeeded"] gate_test -> implement verify_browser -> gate_browser gate_browser -> verify_artifacts [condition="outcome=succeeded"] gate_browser -> implement verify_artifacts -> gate_artifacts gate_artifacts -> verify_fidelity [condition="outcome=succeeded"] gate_artifacts -> implement verify_fidelity -> gate_fidelity gate_fidelity -> review_fanout [condition="context.all_acs_pass=true"] gate_fidelity -> implement // Review review_fanout -> review_a review_fanout -> review_b review_a -> review_consensus review_b -> review_consensus review_consensus -> exit [label="Approved", condition="preferred_label=approved"] review_consensus -> postmortem [label="Rejected"] // Postmortem recovery routing postmortem -> check_toolchain [label="Toolchain", condition="preferred_label=needs_toolchain"] postmortem -> plan_fanout [label="Replan"] } ``` ## Key patterns ### Debate planning with independent providers Instead of a single plan, the workflow generates two independent plans using different models, then synthesizes them: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} plan_fanout -> plan_a // Claude Opus (Anthropic) plan_fanout -> plan_b // Gemini Flash (Google) plan_a -> debate plan_b -> debate ``` Plan A runs on Claude Opus and Plan B on Gemini Flash. A different provider for each plan means genuinely independent perspectives — different training data, different reasoning patterns, different blind spots. The `debate` node reads both plans and produces a single best-of-breed plan that resolves conflicts and picks the strongest approach for each module. When a postmortem exists from a prior iteration, both planners and the debate node read it and adjust the plan to address every identified issue. This creates a feedback loop where each planning round is informed by the failures of the previous one. ### Six-stage verify chain After implementation, the workflow runs a gauntlet of six automated checks. Each check is a command node followed by a conditional gate. If any check fails, execution loops back to `implement` where the agent reads the consolidated error log and fixes the specific failures: ``` implement → fix_fmt → verify_fmt → gate_fmt → verify_build → gate_build → verify_test → gate_test → verify_browser → gate_browser → verify_artifacts → gate_artifacts → verify_fidelity → gate_fidelity → review ``` The checks are ordered by cost — format fixing is nearly instant, builds take seconds, tests take longer, browser checks need a running server, artifact validation cross-references evidence files, and the LLM-based fidelity check is the most expensive. By failing fast on cheap checks, the workflow avoids wasting tokens on fidelity verification when the code doesn't even compile. Each verify step appends errors to `.workflow/verify_errors.log`. When the `implement` node runs again, it reads this error log and makes targeted fixes rather than regenerating from scratch. ### Fidelity verification The `verify_fidelity` node is an LLM-based acceptance test. It reads every acceptance criterion (AC1–AC11) from the definition of done, maps each one to concrete files in the implementation, and produces a per-criterion pass/fail verdict. This catches semantic gaps that automated tests miss — a test suite can pass while the app is missing entire features. The fidelity node communicates its result via [context updates](/workflows/transitions#agent-transitions). It sets `all_acs_pass` to `"true"` or `"false"`, and the downstream gate routes based on that context value: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate_fidelity -> review_fanout [condition="context.all_acs_pass=true"] gate_fidelity -> implement ``` ### Ensemble review with consensus After all automated checks pass, two independent reviewers evaluate the implementation using different providers: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review_fanout -> review_a // Claude Opus (Anthropic) review_fanout -> review_b // Gemini Flash (Google) review_a -> review_consensus review_b -> review_consensus ``` Both reviewers are instructed to perform **mandatory browser verification** — they must build the app, start a preview server, and verify it serves real HTML. Screenshot artifacts under 5KB are rejected as fake. This prevents the pattern where code passes static analysis but doesn't actually render. The `review_consensus` node applies strict consensus rules: * Both APPROVED with no critical gaps: pass * Any critical gap from either reviewer: rejected with specific AC IDs * Mixed verdicts: rejected with gaps enumerated ### Postmortem repair loop When the review consensus rejects the implementation, the workflow runs a postmortem before replanning: ``` review_consensus → [Rejected] → postmortem → plan_fanout → ... → implement → verify → review ``` The postmortem node reads all available evidence (review outputs, fidelity checks, implementation logs, test evidence) and produces a structured analysis: root causes, what works and must be preserved, what failed and must be fixed, and concrete next changes. Critically, it directs targeted repair, not a from-scratch restart. The postmortem also classifies the failure. Most failures route back through planning (`replan`), but environment issues route to `check_toolchain` for bootstrap repair: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} postmortem -> check_toolchain [label="Toolchain", condition="preferred_label=needs_toolchain"] postmortem -> plan_fanout [label="Replan"] ``` ### Bootstrap self-heal The `check_toolchain` node has a self-heal edge for transient infrastructure failures. If Node.js or npm fail to respond due to a temporary issue, the workflow retries via a `loop_restart` edge. Deterministic failures (toolchain not installed) route to the postmortem for diagnosis: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} check_toolchain -> expand_spec [condition="outcome=succeeded"] check_toolchain -> check_toolchain [condition="outcome=failed && context.failure_class=transient_infra", loop_restart=true] check_toolchain -> postmortem [condition="outcome=failed && context.failure_class!=transient_infra"] ``` ### Model routing strategy The stylesheet assigns models based on task difficulty: ``` * { model: claude-opus-4-6; } // Default: spec expansion, debate, postmortem .hard { model: gpt-5.4; } // Implementation: optimized for code generation .verify { model: claude-opus-4-6; } // Fidelity verification: careful analysis .branch-a { model: claude-opus-4-6; } // Plan A, Review A: Anthropic perspective .branch-b { model: gemini-3-flash-preview; } // Plan B, Review B: Google perspective ``` The `.branch-b` class uses a different provider for both planning and review. This ensures the second opinion is genuinely independent — not just a second run of the same model. The `.hard` class routes implementation to OpenAI's Codex, which is optimized for high-throughput code generation. ## Adapting this pattern This pattern generalizes beyond React applications: * **API implementation** — spec is an OpenAPI document, verify chain runs contract tests * **CLI tool** — spec is a man page or usage doc, verify chain runs integration tests * **Infrastructure** — spec is a Terraform design doc, verify chain runs `terraform plan` and policy checks * **Library** — spec is an API surface doc, verify chain runs unit tests and type checks The core structure is always the same: debate plan, implement, verify with escalating checks, ensemble review, postmortem on failure, loop. To adapt for your project: 1. **Write your spec** with concrete acceptance criteria (the more specific, the better the fidelity check) 2. **Write validation scripts** for each stage of the verify chain (format, build, test, browser/integration, artifacts) 3. **Adjust the model stylesheet** for your budget and quality requirements 4. **Customize prompts** with your project's language, conventions, and file paths 5. **Tune the verify chain** — add or remove stages to match your project's quality gates ## Further reading A simpler implement-test-fix loop for spec-driven development. Multi-provider fan-out and synthesis. Retry policies, loop detection, goal gates, and circuit breakers. CSS-like rules for assigning models to workflow nodes. # Definition of Done Source: https://docs.fabro.sh/examples/definition-of-done Audit a codebase against specification checklists, triage failures, implement fixes, and verify — with optional multi-model consensus The Definition of Done (DoD) pattern reads one or more specification documents with explicit checklists, audits the current implementation against every checkbox, triages failures, implements fixes, and verifies the result. It's a structured way to close the gap between "spec says X" and "code does X." This pattern is useful when you have detailed specs with acceptance criteria (Definition of Done sections) and want an agent to systematically verify and fix compliance — rather than hoping it remembered every requirement. ## When to use this * You have specification documents with explicit checklists or acceptance criteria * The implementation exists but may have gaps or incomplete features * You want structured, auditable verification rather than ad-hoc testing * The codebase is large enough that a single-pass "fix everything" approach would miss things ## Single-model variant Definition of Done workflow: Start → Audit LLM → Audit Agent → Triage → Fix → Build → Verify → Review → Exit, with Fix self-loop, Build/Build Fix loop, and Verify back to Triage The simpler variant uses one model throughout, with sequential audits across multiple specs: ```dot title="spec-dod.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph SpecDoD { graph [ goal="Satisfy every Definition of Done checkbox across both specs (unified-llm-spec.md, coding-agent-loop-spec.md). The implementation is in Rust under crates/. Do NOT modify the spec files. Only modify implementation code.", default_max_retries="3", retry_target="triage", model_stylesheet=" * { model: claude-opus-4-6;} .audit { reasoning_effort: high; } .fix { model: claude-opus-4-6; reasoning_effort: high; } #final_audit { reasoning_effort: high; } " ] start [shape=Mdiamond] exit [shape=Msquare] /*------------------------------------------------------------------------ * Phase 1: Baseline audit — read every DoD checkbox, check the code *----------------------------------------------------------------------*/ audit_llm [ label="Audit: Unified LLM Client DoD", shape=box, class="audit", prompt="Read docs/specs/unified-llm-spec.md Section 8 (Definition of Done) in full. Then read every source file under crates/llm/src/. For EACH checkbox in sections 8.1 through 8.10, evaluate whether the current Rust implementation satisfies it. Be strict — a checkbox is only checked if the feature is fully implemented and would work correctly at runtime. Respond with ONLY a JSON object (no prose) -- don't write it out as a file: { \"spec\": \"unified-llm\", \"sections\": { \"8.1\": { \"title\": \"Core Infrastructure\", \"items\": [ {\"text\": \"...\", \"pass\": true/false, \"reason\": \"...\"} ] }, ... }, \"total\": N, \"passed\": M, \"failed\": K, \"failed_items\": [ {\"section\": \"8.2\", \"text\": \"...\", \"reason\": \"...\"} ] } Be thorough. Check every single checkbox." ] audit_agent [ label="Audit: Coding Agent Loop DoD", shape=box, class="audit", prompt="Read docs/specs/coding-agent-loop-spec.md Section 9 (Definition of Done) in full. Then read every source file under crates/agent/. For EACH checkbox in sections 9.1 through 9.13, evaluate whether the current Rust implementation satisfies it. Be strict. Respond with ONLY a JSON object (no prose) -- don't write it out as a file: { \"spec\": \"coding-agent-loop\", \"sections\": { ... }, \"total\": N, \"passed\": M, \"failed\": K, \"failed_items\": [ {\"section\": \"9.1\", \"text\": \"...\", \"reason\": \"...\"} ] } Be thorough. Check every single checkbox." ] /*------------------------------------------------------------------------ * Phase 2: Triage — merge results, prioritize failures, decide next step *----------------------------------------------------------------------*/ triage [ label="Triage & Prioritize", shape=box, prompt="You have two audit reports in context (from audit_llm, audit_agent). Merge them into a single prioritized list of ALL failing DoD checkboxes. Group failures into: 1. IMPLEMENTABLE — can be fixed by writing/modifying Rust code (functions, handlers, logic) 2. STRUCTURAL — requires new files, new modules, or significant architecture changes 3. DEFERRED — requires external resources (real API keys, network access, interactive testing) that cannot be done in a code-only pass For each IMPLEMENTABLE item, identify the exact file(s) to modify and briefly describe the fix. Respond with JSON (not by writing out a file): { \"total_failing\": N, \"implementable\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"files\": [\"...\"], \"fix\": \"...\"} ], \"structural\": [ ... ], \"deferred\": [ ... ], \"verdict\": \"all_clear\" | \"has_fixes\" | \"only_deferred\" } If total_failing == 0 or verdict == \"only_deferred\", set preferred_next_label to \"Done\". Otherwise set preferred_next_label to \"Fix\"." ] /*------------------------------------------------------------------------ * Phase 3: Fix — implement the highest-priority fixes *----------------------------------------------------------------------*/ fix_batch [ label="Implement Fixes", shape=box, class="fix", goal_gate=true, prompt="The triage report identified IMPLEMENTABLE DoD failures. Pick the top 5 most impactful items (or all if fewer than 5) and implement the fixes in Rust. Begin by making sure the build is green with `cargo test` For each fix: 1. Read the relevant source file(s) 2. Make the minimal change needed to satisfy the DoD checkbox 3. Write the modified file(s) -- update tests as needed 4. Verify the fix and the tests pass (`cargo test`) Constraints: - Do NOT modify any files under docs/specs/ (those are the specs) - Do NOT add external dependencies beyond what's already used - Keep changes minimal and focused — one checkbox per fix - Maintain the existing code style Respond with JSON (not by writing out a file): { \"fixes_applied\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"files_changed\": [\"...\"], \"description\": \"...\"} ], \"count\": N, \"remaining_implementable\": M } If remaining_implementable > 0, set preferred_next_label to \"more_fixes_needed\". Otherwise set preferred_next_label to \"Re-audit\"." ] /*------------------------------------------------------------------------ * Phase 4: Build verification *----------------------------------------------------------------------*/ build_check [ label="Build & Smoke Test", shape=parallelogram, script="cargo build 2>&1 && echo '---BUILD OK---' && cargo test 2>&1 && echo '---ALL TESTS PASSED---'", timeout="120s" ] /*------------------------------------------------------------------------ * Phase 5: Build failure recovery *----------------------------------------------------------------------*/ build_fix [ label="Fix Build Errors", shape=box, class="fix", prompt="The build or smoke tests failed. Read the build output from context (command.output key). Diagnose the compilation errors or test failures and fix them. Read the relevant source files, identify the issue, and write corrected versions. Common issues: - Missing includes - Type mismatches - Undeclared functions Output the fixes applied and ensure the code will compile cleanly with: cargo build" ] /*------------------------------------------------------------------------ * Phase 6: Final audit to confirm fixes worked *----------------------------------------------------------------------*/ final_audit [ label="Final Verification Audit", shape=box, prompt="This is a verification pass. Re-read both spec DoD sections: - docs/specs/unified-llm-spec.md Section 8 - docs/specs/coding-agent-loop-spec.md Section 9 And re-read the implementation files that were changed in this iteration. Check ONLY the items that were previously failing. Have they been fixed? Respond with JSON (not by writing out a file): { \"verified_fixed\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\"} ], \"still_failing\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"reason\": \"...\"} ], \"newly_broken\": [ ... ], \"remaining_total\": N } If remaining_total == 0 (ignoring DEFERRED items), set preferred_next_label to \"Complete\". Otherwise set preferred_next_label to \"more_work_needed\"." ] /*------------------------------------------------------------------------ * Phase 7: Human gate — confirm completion or push for another round *----------------------------------------------------------------------*/ review_gate [ label="A) Accept & finish\nB) Push for another round", shape=hexagon ] /*------------------------------------------------------------------------ * Edges *----------------------------------------------------------------------*/ start -> audit_llm /* Sequential audit chain */ audit_llm -> audit_agent audit_agent -> triage /* Triage decision */ triage -> exit [label="Done", condition="preferred_label=Done"] triage -> fix_batch [label="Fix", condition="preferred_label=Fix", weight=10] triage -> exit [label="Only deferred remain"] /* Fix -> build check */ fix_batch -> build_check /* Build check outcomes */ build_check -> final_audit [label="Build OK", condition="outcome=succeeded"] build_check -> build_fix [label="Build failed"] /* Build fix loops back to build check */ build_fix -> build_check /* Fix batch can loop for more fixes */ fix_batch -> fix_batch [label="More fixes needed", condition="preferred_label=more_fixes_needed", loop_restart=true] /* Final audit outcomes */ final_audit -> review_gate [label="Complete", condition="preferred_label=Complete"] final_audit -> triage [label="More work needed"] /* Human review gate */ review_gate -> exit [label="A) Accept"] review_gate -> triage [label="B) Another round"] } ``` ### How it works **Sequential audits** — each spec gets its own audit node so the agent can focus on one spec at a time. The prompts ask for structured JSON output with pass/fail per checkbox, making the results machine-parseable for the triage phase. **Three-category triage** — failures are classified as IMPLEMENTABLE (can fix now), STRUCTURAL (needs architecture work), or DEFERRED (needs external resources). This prevents the agent from wasting cycles on items it can't address in a code-only pass. **Batched fixes** — the `fix_batch` node tackles up to 5 failures per iteration. The self-loop (`fix_batch -> fix_batch` with `loop_restart=true`) allows it to keep going when more fixes remain, while `goal_gate=true` ensures the workflow only succeeds if fixes were actually applied. Because `loop_restart` begins each round with a fresh, empty context, every iteration re-derives the remaining work from the repository state rather than from accumulated conversation history — see [Failures — Loop restart edges](/execution/failures#loop-restart-edges). **Build gate** — after each fix batch, a script node runs `cargo build` and `cargo test`. If the build breaks, a dedicated `build_fix` node diagnoses and repairs compilation errors before retrying. **Re-audit after fixes** — the `final_audit` node re-checks only the previously-failing items, catching regressions without re-auditing the entire spec. If items remain, the workflow loops back to triage for another round. **Human gate** — before declaring victory, a human reviews the results and can push for another round if the automated audit missed something. ## Multi-model variant Multi-model Definition of Done workflow: Start → Dual Audit (Opus+GPT) → Critique → Consensus → Triage (Opus+GPT) → Fix Cycle (Codex↔Opus) → Build → Verify (Opus+GPT) → Review → Exit, with Fix Cycle self-loop, Build/Build Fix loop, and Verify back to Triage The multi-model variant applies the same audit-triage-fix-verify structure but uses independent assessments from two models (Claude Opus and GPT-5.4) at each phase, with cross-critique and consensus merging. This catches blind spots that a single model might miss. ```dot title="spec-dod-multimodel.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph SpecDoDMultiModel { graph [ goal="Satisfy every Definition of Done checkbox across both specs (unified-llm-spec.md, coding-agent-loop-spec.md). The implementation is in Rust under crates/. Do NOT modify the spec files. Only modify implementation code. Uses multi-model consensus: Opus 4.6 and GPT-5.4 compete on audits and planning, GPT-5.4 and Opus 4.6 alternate on implementation.", default_max_retries="3", retry_target="triage_merge", default_fidelity="full", model_stylesheet=" * { model: claude-opus-4-6;} .opus { model: claude-opus-4-6;reasoning_effort: high; } .gpt { model: gpt-5.4; reasoning_effort: high; } .codex { model: gpt-5.4; reasoning_effort: high; } .merge { model: claude-opus-4-6; reasoning_effort: high; } " ] start [shape=Mdiamond] exit [shape=Msquare] /*======================================================================== * PHASE 1 — Dual Independent Audits (interleaved, fidelity-isolated) * * Each spec is audited by both models before moving to the next. * Audit nodes use fidelity="truncate" so they only see the graph goal * and NOT each other's responses — prevents anchoring bias. * Full responses are still stored as response. for later use. *======================================================================*/ /* ---- LLM spec: both models ---- */ audit_llm_opus [ label="Opus: Audit LLM DoD", shape=box, class="opus", fidelity="truncate", prompt="Read docs/specs/unified-llm-spec.md Section 8 (Definition of Done) in full. Then read every source file under crates/llm/src/. For EACH checkbox in sections 8.1 through 8.10, evaluate whether the current Rust implementation satisfies it. Be strict — a checkbox is only checked if the feature is fully implemented and would work correctly at runtime. Respond with ONLY a JSON object (no prose) -- don't write it out as a file: { \"spec\": \"unified-llm\", \"model\": \"opus\", \"sections\": { \"8.1\": { \"title\": \"Core Infrastructure\", \"items\": [ {\"text\": \"...\", \"pass\": true/false, \"reason\": \"...\"} ] }, ... }, \"total\": N, \"passed\": M, \"failed\": K, \"failed_items\": [ {\"section\": \"8.2\", \"text\": \"...\", \"reason\": \"...\"} ] } Be thorough. Check every single checkbox." ] audit_llm_gpt [ label="GPT-5.4: Audit LLM DoD", shape=box, class="gpt", fidelity="truncate", prompt="Read docs/specs/unified-llm-spec.md Section 8 (Definition of Done) in full. Then read every source file under crates/llm/src/. For EACH checkbox in sections 8.1 through 8.10, evaluate whether the current Rust implementation satisfies it. Be strict — a checkbox is only checked if the feature is fully implemented and would work correctly at runtime. Respond with ONLY a JSON object (no prose) -- don't write it out as a file: { \"spec\": \"unified-llm\", \"model\": \"gpt-5.4\", \"sections\": { \"8.1\": { \"title\": \"Core Infrastructure\", \"items\": [ {\"text\": \"...\", \"pass\": true/false, \"reason\": \"...\"} ] }, ... }, \"total\": N, \"passed\": M, \"failed\": K, \"failed_items\": [ {\"section\": \"8.2\", \"text\": \"...\", \"reason\": \"...\"} ] } Be thorough. Check every single checkbox." ] /* ---- Agent spec: both models ---- */ audit_agent_opus [ label="Opus: Audit Agent DoD", shape=box, class="opus", fidelity="truncate", prompt="Read docs/specs/coding-agent-loop-spec.md Section 9 (Definition of Done) in full. Then read every source file under crates/agent/. For EACH checkbox in sections 9.1 through 9.13, evaluate whether the current Rust implementation satisfies it. Be strict. Respond with ONLY a JSON object (no prose) -- don't write it out as a file: { \"spec\": \"coding-agent-loop\", \"model\": \"opus\", \"sections\": { ... }, \"total\": N, \"passed\": M, \"failed\": K, \"failed_items\": [ {\"section\": \"9.1\", \"text\": \"...\", \"reason\": \"...\"} ] } Be thorough. Check every single checkbox." ] audit_agent_gpt [ label="GPT-5.4: Audit Agent DoD", shape=box, class="gpt", fidelity="truncate", prompt="Read docs/specs/coding-agent-loop-spec.md Section 9 (Definition of Done) in full. Then read every source file under crates/agent/. For EACH checkbox in sections 9.1 through 9.13, evaluate whether the current Rust implementation satisfies it. Be strict. Respond with ONLY a JSON object (no prose) -- don't write it out as a file: { \"spec\": \"coding-agent-loop\", \"model\": \"gpt-5.4\", \"sections\": { ... }, \"total\": N, \"passed\": M, \"failed\": K, \"failed_items\": [ {\"section\": \"9.1\", \"text\": \"...\", \"reason\": \"...\"} ] } Be thorough. Check every single checkbox." ] /*======================================================================== * PHASE 2 — Cross-Critique (fidelity=full to see all response.* keys) * * Each model reviews the other's audit. Like megaplan's Compete phase: * independent work first, then adversarial review. *======================================================================*/ critique_by_gpt [ label="GPT-5.4: Critique Opus Audits", shape=box, class="gpt", fidelity="full", prompt="You have all four audit reports available in context. The full outputs are in these context keys: OPUS AUDITS: - response.audit_llm_opus — Opus's audit of unified-llm-spec.md Section 8 - response.audit_agent_opus — Opus's audit of coding-agent-loop-spec.md Section 9 GPT AUDITS (your own): - response.audit_llm_gpt — your audit of unified-llm-spec.md Section 8 - response.audit_agent_gpt — your audit of coding-agent-loop-spec.md Section 9 Compare them item by item. For every DoD checkbox where the two models DISAGREE (one says pass, the other says fail), re-read the relevant spec section and source file to determine who is correct. Also identify items that one model flagged but the other missed entirely. Respond with JSON (not by writing out a file): { \"agreements\": { \"both_pass\": N, \"both_fail\": N }, \"disagreements\": [ { \"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"opus_says\": \"pass|fail\", \"gpt_says\": \"pass|fail\", \"verdict\": \"pass|fail\", \"reasoning\": \"...\" } ], \"missed_by_opus\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"reason\": \"...\"} ], \"missed_by_gpt\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"reason\": \"...\"} ] } Be rigorous. When in doubt, fail the checkbox — strictness prevents false confidence." ] critique_by_opus [ label="Opus: Critique GPT-5.4 Audits", shape=box, class="opus", fidelity="full", prompt="You have all four audit reports available in context. The full outputs are in these context keys: GPT AUDITS: - response.audit_llm_gpt — GPT-5.4's audit of unified-llm-spec.md Section 8 - response.audit_agent_gpt — GPT-5.4's audit of coding-agent-loop-spec.md Section 9 OPUS AUDITS (your own): - response.audit_llm_opus — your audit of unified-llm-spec.md Section 8 - response.audit_agent_opus — your audit of coding-agent-loop-spec.md Section 9 Compare them item by item. For every DoD checkbox where the two models DISAGREE (one says pass, the other says fail), re-read the relevant spec section and source file to determine who is correct. Also identify items that one model flagged but the other missed entirely. Respond with JSON (not by writing out a file): { \"agreements\": { \"both_pass\": N, \"both_fail\": N }, \"disagreements\": [ { \"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"opus_says\": \"pass|fail\", \"gpt_says\": \"pass|fail\", \"verdict\": \"pass|fail\", \"reasoning\": \"...\" } ], \"missed_by_opus\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"reason\": \"...\"} ], \"missed_by_gpt\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"reason\": \"...\"} ] } Be rigorous. When in doubt, fail the checkbox — strictness prevents false confidence." ] /*======================================================================== * PHASE 3 — Audit Consensus * * Merge all findings into a single agreed-upon truth. * Like megaplan's Merge phase: best ideas from both, disagreements resolved. *======================================================================*/ audit_consensus [ label="Merge: Audit Consensus", shape=box, class="merge", fidelity="full", prompt="You have all prior audit and critique outputs in context. The key inputs are: FOUR AUDIT REPORTS (context keys response.audit_llm_opus, response.audit_agent_opus, response.audit_llm_gpt, response.audit_agent_gpt) TWO CROSS-CRITIQUES (context keys response.critique_by_gpt, response.critique_by_opus) Produce a single definitive audit result. Resolution rules: 1. If BOTH models agree a checkbox passes → pass 2. If BOTH models agree a checkbox fails → fail 3. If they DISAGREE, use the cross-critique verdicts. If the critiques also disagree, re-read the spec and code yourself and make the call. When in doubt, fail it. 4. Include any items that were missed by one model but caught by the other. Respond with JSON (not by writing out a file): { \"spec_results\": { \"unified-llm\": { \"total\": N, \"passed\": M, \"failed\": K, \"failed_items\": [...] }, \"coding-agent-loop\": { ... } }, \"consensus_total\": N, \"consensus_passed\": M, \"consensus_failed\": K, \"disagreements_resolved\": N, \"all_failed_items\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"reason\": \"...\", \"agreed_by\": \"both|opus_only|gpt_only|resolved\"} ] }" ] /*======================================================================== * PHASE 4 — Dual Triage * * Both models independently prioritize the failures, then merge. * Different models weight different risks differently — consensus is stronger. *======================================================================*/ triage_opus [ label="Opus: Triage & Prioritize", shape=box, class="opus", fidelity="full", prompt="The consensus audit results are in context key response.audit_consensus. Parse the all_failed_items list from that JSON and triage every failing DoD checkbox. Group failures into: 1. IMPLEMENTABLE — can be fixed by writing/modifying Rust code (functions, handlers, logic) 2. STRUCTURAL — requires new files, new modules, or significant architecture changes 3. DEFERRED — requires external resources (real API keys, network access, interactive testing) that cannot be done in a code-only pass For each IMPLEMENTABLE item, identify the exact file(s) to modify and briefly describe the fix. Rank them by impact (most important first). Respond with JSON (not by writing out a file): { \"model\": \"opus\", \"total_failing\": N, \"implementable\": [ {\"rank\": 1, \"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"files\": [\"...\"], \"fix\": \"...\", \"impact\": \"high|medium|low\"} ], \"structural\": [ ... ], \"deferred\": [ ... ] }" ] triage_gpt [ label="GPT-5.4: Triage & Prioritize", shape=box, class="gpt", fidelity="full", prompt="The consensus audit results are in context key response.audit_consensus. Parse the all_failed_items list from that JSON and triage every failing DoD checkbox. Group failures into: 1. IMPLEMENTABLE — can be fixed by writing/modifying Rust code (functions, handlers, logic) 2. STRUCTURAL — requires new files, new modules, or significant architecture changes 3. DEFERRED — requires external resources (real API keys, network access, interactive testing) that cannot be done in a code-only pass For each IMPLEMENTABLE item, identify the exact file(s) to modify and briefly describe the fix. Rank them by impact (most important first). Respond with JSON (not by writing out a file): { \"model\": \"gpt-5.4\", \"total_failing\": N, \"implementable\": [ {\"rank\": 1, \"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"files\": [\"...\"], \"fix\": \"...\", \"impact\": \"high|medium|low\"} ], \"structural\": [ ... ], \"deferred\": [ ... ] }" ] triage_merge [ label="Merge: Triage Consensus", shape=box, class="merge", fidelity="full", prompt="You have two triage reports in context: response.triage_opus and response.triage_gpt. Merge them into a single prioritized work plan. Resolution rules: 1. If both models classify an item the same way (IMPLEMENTABLE/STRUCTURAL/DEFERRED) → keep that classification 2. If they disagree on classification → take the MORE ACTIONABLE classification (prefer IMPLEMENTABLE over STRUCTURAL over DEFERRED) 3. For ranking, average the ranks and re-sort. If one model identified files/fixes the other didn't, include all suggestions. 4. Deduplicate items that both models identified. Respond with JSON (not by writing out a file): { \"total_failing\": N, \"implementable\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"files\": [\"...\"], \"fix\": \"...\", \"opus_rank\": N, \"gpt_rank\": N} ], \"structural\": [ ... ], \"deferred\": [ ... ], \"classification_disagreements\": N, \"verdict\": \"all_clear\" | \"has_fixes\" | \"only_deferred\" } If total_failing == 0 or verdict == \"only_deferred\", set preferred_next_label to \"Done\". Otherwise set preferred_next_label to \"Fix\"." ] /*======================================================================== * PHASE 5 — Multi-Model Implementation * * Codex implements, Opus reviews and corrects, Codex validates. * Like megaplan's draft→critique→merge but for code. *======================================================================*/ fix_codex [ label="Codex: Implement Fixes", shape=box, class="codex", goal_gate=true, fidelity="full", prompt="The merged triage report is in context key response.triage_merge. It contains a prioritized list of IMPLEMENTABLE DoD failures. Pick the top 5 most impactful items (or all if fewer than 5) and implement the fixes in Rust. Begin by making sure the build is green with `cargo test` For each fix: 1. Read the relevant source file(s) 2. Make the minimal change needed to satisfy the DoD checkbox 3. Write the modified file(s) -- update tests as needed 4. Verify the fix and the tests pass (`cargo test`) Constraints: - Do NOT modify any files under docs/specs/ (those are the specs) - Do NOT add external dependencies beyond what's already used - Keep changes minimal and focused — one checkbox per fix - Maintain the existing code style Respond with JSON (not by writing out a file): { \"model\": \"codex\", \"fixes_applied\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"files_changed\": [\"...\"], \"description\": \"...\"} ], \"count\": N, \"remaining_implementable\": M }" ] review_fix_opus [ label="Opus: Review & Fix", shape=box, class="opus", goal_gate=true, fidelity="full", prompt="Codex just implemented a batch of fixes. Its report is in context key response.fix_codex. PART A — Review Codex's work: 1. Read every file that Codex modified (check the files_changed lists in response.fix_codex) 2. For each fix, verify it actually satisfies the DoD checkbox it claims to address 3. Check for: correctness, edge cases, style consistency, missing error handling 4. If a fix is wrong or incomplete, rewrite it correctly PART B — Implement additional fixes: 5. From the remaining IMPLEMENTABLE items (see response.triage_merge for the full list), pick up to 5 more and implement them 6. Follow the same constraints as Codex (Rust, no new deps, minimal changes) Respond with JSON (not by writing out a file): { \"model\": \"opus\", \"codex_fixes_reviewed\": N, \"codex_fixes_correct\": N, \"codex_fixes_corrected\": [ {\"spec\": \"...\", \"section\": \"...\", \"issue\": \"...\", \"correction\": \"...\"} ], \"additional_fixes\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"files_changed\": [\"...\"], \"description\": \"...\"} ], \"total_fixes_this_round\": N, \"remaining_implementable\": M }" ] review_codex [ label="Codex: Validate All Changes", shape=box, class="codex", fidelity="full", prompt="Opus reviewed your fixes and implemented additional ones. Its report is in context key response.review_fix_opus. Your original report is in response.fix_codex. Validate the full set of changes from this round: 1. Read every file modified in this round (check files_changed in both response.fix_codex and response.review_fix_opus) 2. Check each change for correctness: does it satisfy its DoD checkbox? 3. Check for regressions: did any fix break something else? 4. Check for consistency: do all the changes work together? Respond with JSON (not by writing out a file): { \"model\": \"codex\", \"total_changes_reviewed\": N, \"all_correct\": true/false, \"issues_found\": [ {\"file\": \"...\", \"issue\": \"...\", \"severity\": \"critical|minor\"} ], \"remaining_implementable\": M } If issues_found contains any critical items, set preferred_next_label to \"more_fixes_needed\". If remaining_implementable > 0 and no critical issues, set preferred_next_label to \"more_fixes_needed\". Otherwise set preferred_next_label to \"ready_for_build\"." ] /*======================================================================== * PHASE 6 — Build Verification *======================================================================*/ build_check [ label="Build & Smoke Test", shape=parallelogram, script="cargo build 2>&1 && echo '---BUILD OK---' && cargo test 2>&1 && echo '---ALL TESTS PASSED---'", timeout="120s" ] build_fix [ label="Opus: Fix Build Errors", shape=box, class="opus", fidelity="full", prompt="The build or smoke tests failed. The build output is in context key command.output. Diagnose the compilation errors or test failures and fix them. Read the relevant source files, identify the issue, and write corrected versions. Common issues: - Missing includes - Type mismatches - Undeclared functions Output the fixes applied and ensure the code will compile cleanly with: cargo build" ] /*======================================================================== * PHASE 7 — Dual Final Audit (interleaved, fidelity-isolated) * * Both models independently verify the fixes worked. * If either model finds a remaining failure, it counts. *======================================================================*/ final_audit_opus [ label="Opus: Final Verification", shape=box, class="opus", fidelity="full", prompt="This is a verification pass. The items that were previously failing are listed in context key response.triage_merge (the implementable list). The fixes applied are in response.fix_codex and response.review_fix_opus. Re-read both spec DoD sections: - docs/specs/unified-llm-spec.md Section 8 - docs/specs/coding-agent-loop-spec.md Section 9 And re-read the implementation files that were changed in this iteration. Check ONLY the items that were previously failing. Have they been fixed? Respond with JSON (not by writing out a file): { \"model\": \"opus\", \"verified_fixed\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\"} ], \"still_failing\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"reason\": \"...\"} ], \"newly_broken\": [ ... ], \"remaining_total\": N }" ] final_audit_gpt [ label="GPT-5.4: Final Verification", shape=box, class="gpt", fidelity="full", prompt="This is a verification pass. The items that were previously failing are listed in context key response.triage_merge (the implementable list). The fixes applied are in response.fix_codex and response.review_fix_opus. Re-read both spec DoD sections: - docs/specs/unified-llm-spec.md Section 8 - docs/specs/coding-agent-loop-spec.md Section 9 And re-read the implementation files that were changed in this iteration. Check ONLY the items that were previously failing. Have they been fixed? Respond with JSON (not by writing out a file): { \"model\": \"gpt-5.4\", \"verified_fixed\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\"} ], \"still_failing\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"reason\": \"...\"} ], \"newly_broken\": [ ... ], \"remaining_total\": N }" ] final_consensus [ label="Merge: Final Consensus", shape=box, class="merge", fidelity="full", prompt="You have final audit results from both models in context: response.final_audit_opus and response.final_audit_gpt. Merge them into a definitive status. Rules: 1. An item is only \"verified_fixed\" if BOTH models agree it's fixed 2. If EITHER model says an item is still failing, it counts as still failing 3. Union all newly_broken items from both models Respond with JSON (not by writing out a file): { \"verified_fixed\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"agreed_by\": \"both|opus_only|gpt_only\"} ], \"still_failing\": [ {\"spec\": \"...\", \"section\": \"...\", \"text\": \"...\", \"flagged_by\": \"both|opus_only|gpt_only\", \"reason\": \"...\"} ], \"newly_broken\": [ ... ], \"remaining_total\": N } If remaining_total == 0 (ignoring DEFERRED items), set preferred_next_label to \"Complete\". Otherwise set preferred_next_label to \"more_work_needed\"." ] /*======================================================================== * PHASE 8 — Human Gate *======================================================================*/ review_gate [ label="A) Accept & finish\nB) Push for another round", shape=hexagon ] /*======================================================================== * EDGES — Serial interleaved chain * * The engine is single-path, so we interleave model audits per-spec. * fidelity="truncate" on audit nodes prevents cross-model anchoring. *======================================================================*/ /* Phase 1: Interleaved audits (Opus then GPT for each spec) */ start -> audit_llm_opus audit_llm_opus -> audit_llm_gpt audit_llm_gpt -> audit_agent_opus audit_agent_opus -> audit_agent_gpt /* Phase 2: Cross-critique (now sequential — GPT critiques Opus, then Opus critiques GPT) */ audit_agent_gpt -> critique_by_gpt critique_by_gpt -> critique_by_opus /* Phase 3: Consensus */ critique_by_opus -> audit_consensus /* Phase 4: Dual triage (sequential — Opus then GPT then merge) */ audit_consensus -> triage_opus triage_opus -> triage_gpt triage_gpt -> triage_merge /* Triage decision */ triage_merge -> exit [label="Done", condition="preferred_label=Done"] triage_merge -> fix_codex [label="Fix", condition="preferred_label=Fix", weight=10] triage_merge -> exit [label="Only deferred remain"] /* Phase 5: Multi-model implementation (sequential alternation) */ fix_codex -> review_fix_opus review_fix_opus -> review_codex /* Implementation loop */ review_codex -> fix_codex [label="More fixes needed", condition="preferred_label=more_fixes_needed", loop_restart=true] review_codex -> build_check [label="Ready for build", condition="preferred_label=ready_for_build"] review_codex -> fix_codex [label="Fallback"] /* Phase 6: Build */ build_check -> final_audit_opus [label="Build OK", condition="outcome=succeeded"] build_check -> build_fix [label="Build failed"] build_fix -> build_check /* Phase 7: Dual final audit (sequential — Opus then GPT then consensus) */ final_audit_opus -> final_audit_gpt final_audit_gpt -> final_consensus /* Final decision */ final_consensus -> review_gate [label="Complete", condition="preferred_label=Complete"] final_consensus -> triage_merge [label="More work needed"] /* Phase 8: Human gate */ review_gate -> exit [label="A) Accept"] review_gate -> triage_merge [label="B) Another round"] } ``` ### What the multi-model variant adds **Independent audits with `fidelity="truncate"`** — each model audits the same spec without seeing the other's answers. This prevents anchoring bias: if Opus marks a checkbox as passing, GPT doesn't blindly agree. The `fidelity="truncate"` attribute strips prior responses from the context while still storing them for later phases. **Cross-critique** — after all audits complete, each model reviews the other's work. Disagreements are resolved by re-reading the spec and code. This adversarial step catches both false positives (a model said "pass" when the feature is incomplete) and false negatives (a model said "fail" when the code is actually correct). **Consensus merging** — a dedicated merge node combines the four audit reports and two critiques into a single truth. The merge rules are conservative: when in doubt, fail the checkbox. **Dual triage** — both models independently classify and prioritize failures. The merge takes the more actionable classification (IMPLEMENTABLE > STRUCTURAL > DEFERRED) and averages ranks. Different models weight risks differently, so consensus produces a stronger work plan. **Alternating implementation** — Codex implements, Opus reviews and corrects, Codex validates. This draft-critique-validate cycle catches implementation errors before they reach the build gate. **Conservative final verification** — both models independently verify fixes. An item is only "verified fixed" if both agree. If either model says it's still failing, it counts as failing. This prevents premature declaration of victory. ## Key patterns across both variants ### Structured JSON responses Every audit and triage node requests JSON output with a defined schema. This makes responses machine-parseable: downstream nodes can reference specific fields (`response.triage_merge`) rather than parsing free-form text. ### The audit-triage-fix loop Both variants share the same core loop: ``` audit → triage → fix → build → verify → (loop or exit) ``` The single-model variant runs this loop with one model. The multi-model variant runs it with consensus at each decision point. The loop structure is identical — only the number of participants changes. ### `goal_gate` on fix nodes Fix nodes have `goal_gate=true`, meaning the workflow only succeeds if the agent actually applied fixes. If triage finds nothing implementable and routes directly to exit, the workflow succeeds. But if it routes to fix and the fix node produces no changes, the workflow fails. ### Human gate as a safety valve Both variants end with a human gate. Even after automated verification, a human can review the results and push for another round. This is especially useful when the automated audit might miss subtleties that a human reviewer would catch. ## Further reading Assigning different models to different workflow nodes. Independent assessments merged into consensus decisions. CSS-like rules for assigning models to workflow nodes. Human approval gates and review checkpoints. # NLSpec Conformance Source: https://docs.fabro.sh/examples/nlspec-conformance Implement a system from a natural language specification and iterate until conformance tests pass The NLSpec Conformance pattern gives an agent a detailed specification document, has it build an implementation, and then loops on automated conformance tests until the implementation achieves full conformance. This is the same pattern used by benchmarks like [AttractorBench](https://github.com/strongdm/attractorbench) to measure how well agents follow complex specs. ## When to use this * You have a detailed specification (API contract, RFC, design doc) and want an agent to implement it * You have automated tests that can verify conformance * The implementation is too large to get right in one pass and benefits from iterative repair ## The workflow NLSpec Conformance workflow: Start → Plan → Implement → Quick Tests → Quick passing? → Full Tests → All passing? → Exit, with Fix Failures loop ```dot title="n-l-spec-conformance.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph NLSpecConformance { graph [ goal="Implement a conformant system from a natural language specification", model_stylesheet=" * { model: claude-haiku-4-5;} .impl { model: claude-sonnet-4-5; reasoning_effort: high; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] // Phase 1: Read spec and plan plan [label="Plan", class="impl", prompt="@prompts/plan.md"] // Phase 2: Build the initial implementation subgraph cluster_impl { label = "Implement & Conform" node [thread_id="impl", fidelity="full"] implement [label="Implement", class="impl", prompt="@prompts/implement.md"] fix [label="Fix Failures", class="impl", prompt="@prompts/fix.md", max_visits=5] } // Phase 3: Quick conformance loop test_quick [label="Quick Conformance", shape=parallelogram, script="make conformance-quick 2>&1 || true"] gate_quick [shape=diamond, label="Quick suite passing?"] // Phase 4: Full conformance test_full [label="Full Conformance", shape=parallelogram, script="make conformance-full 2>&1 || true", goal_gate=true, retry_target="fix"] gate_full [shape=diamond, label="All tests passing?"] // Wiring start -> plan -> implement -> test_quick -> gate_quick gate_quick -> test_full [label="Pass", condition="outcome=succeeded"] gate_quick -> fix [label="Fix"] fix -> test_quick test_full -> gate_full gate_full -> exit [label="Pass", condition="outcome=succeeded"] gate_full -> fix [label="Fix"] } ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflows/nlspec-conformance.fabro ``` ## How it works ### Spec reading and planning The `plan` node reads the specification and produces an implementation plan. Because the spec may be long (thousands of lines), the prompt tells the agent to read it from a file rather than trying to include it inline: ```markdown theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} Read the specification at `docs/spec.md` in full. Produce a step-by-step implementation plan in `plan.md` that covers: 1. The key abstractions and data types to define 2. The public API surface (functions, CLI commands, endpoints) 3. The conformance contract (what `make conformance-quick` and `make conformance-full` will test) 4. Implementation order — start with the smallest slice that passes at least one conformance test ``` ### Implementation with a shared thread The `implement` and `fix` nodes share a `thread_id="impl"`, so they accumulate context across loop iterations. When the agent returns to `fix` after a failed conformance run, it sees the full history of what it built and what broke. Combined with `fidelity="full"`, the agent retains the detail it needs to make targeted repairs. ### The conformance loop The core of this pattern is the test-fix loop: ``` implement → test_quick → gate_quick → [Fix] → fix → test_quick → gate_quick → [Pass] → test_full → ... ``` The **quick conformance** suite runs a subset of tests that execute fast (seconds, not minutes). The agent iterates against this subset first, fixing one failure class at a time. Only after the quick suite passes does it run the **full conformance** suite. This two-tier approach mirrors how developers work: run the fast tests while iterating, then run the complete suite before calling it done. ### Fix node prompt The `fix` prompt reads conformance output and targets specific failures: ```markdown theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} The conformance tests found failures. Read the test output from the previous command node and fix the issues. Strategy: 1. Read the failing test names and error messages 2. Identify the root cause — is it a missing feature, wrong format, or integration bug? 3. Fix one failure class at a time (e.g. all JSON schema errors, then all routing errors) 4. After fixing, the workflow will re-run conformance automatically Do not rewrite working code. Make targeted fixes to the specific failures. ``` ### Max visits as a safety valve `max_visits=5` on the `fix` node prevents infinite loops. The node can execute up to 5 times; a sixth visit fails the run rather than looping forever. Tune this based on spec complexity: a 30-line spec might need 2 iterations, a 2,000-line spec might need 10. ### Goal gate on full conformance The `test_full` node has `goal_gate=true`. If the full conformance suite never passes, the workflow is marked as failed even if execution reaches the exit node. This makes the workflow's success criteria explicit: partial conformance is not a passing result. ## Model assignment The `model_stylesheet` assigns a cheaper model as the default and routes implementation work to a more capable model: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} graph [model_stylesheet=" * { model: claude-haiku-4-5;} .impl { model: claude-sonnet-4-5; reasoning_effort: high; } "] ``` The `.impl` class targets both the `implement` and `fix` nodes (both have `class="impl"`). Planning and implementation get the stronger model with high reasoning effort; any lightweight nodes you add later (summaries, notifications) default to the faster model. ## Adding a human approval gate For high-stakes specs, add a human gate after planning: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve Plan"] plan -> approve approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] ``` The agent writes its plan to `plan.md`, the human reviews it, and either approves (proceeding to implementation) or sends it back for revision. ## Adapting for your project To use this pattern: 1. **Write your spec** as a Markdown file in the repo (e.g. `docs/spec.md`) 2. **Write conformance tests** that exercise the spec's requirements via a CLI or test runner. Split them into quick (core paths) and full (everything) suites. 3. **Wire the Makefile** so `make conformance-quick` and `make conformance-full` run the suites and output results 4. **Customize the prompts** to reference your spec file, your project's language and conventions, and your conformance contract The pattern works for any spec that has automated verification: API contracts with integration tests, protocol implementations with compliance suites, or library specs with unit tests. ## What you've learned * The **conformance loop** (implement, test, fix, repeat) is the core pattern for spec-driven development * **Two-tier conformance** (quick then full) keeps iteration fast * **Shared threads** (`thread_id`) give the fix node context from prior iterations * **`max_visits`** prevents infinite loops when the agent can't pass * **`goal_gate`** makes conformance a hard requirement for workflow success # REPL Handoff Source: https://docs.fabro.sh/examples/repl-handoff Plan interactively in Claude Code, then delegate implementation to Fabro for autonomous execution with multi-model code review The REPL Handoff pattern bridges interactive planning in a coding assistant (like Claude Code) with autonomous execution in Fabro. You collaborate with an AI in your terminal to produce a plan, then hand it off to a workflow that implements the plan, runs it through multi-model simplification, and verifies the result — all on cloud resources. This pattern is useful when a task is well-understood enough to plan interactively but large enough that you don't want to babysit the implementation. The human stays in the loop for the creative part (planning) and delegates the mechanical part (coding, testing, reviewing) to an automated pipeline. ## When to use this * You've scoped a feature or fix and can describe the steps in a plan * The implementation involves writing code, running tests, and iterating on lint/build failures * You want multi-model review (not just the model that wrote the code) before calling it done * You'd rather not wait at your terminal while the agent codes — let it run in the background on cloud resources ## The workflow Start → Toolchain → Preflight Compile → Preflight Lint → Implement → Simplify (Opus) → Simplify (Gemini) → Simplify (GPT-54) → Verify → Format → Exit, with Preflight Lint/Fix Lints retry loop, Verify/Fixup retry loop, and early exit from Toolchain and Preflight Compile on failure ```dot title="implement.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph ImplementAndSimplify { graph [ goal="Implement and simplify", model_stylesheet=" * { backend: api; model: claude-opus-4-6;} " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gemini [label="Simplify (Gemini)", prompt="@prompts/simplify.md", model="gemini-3.1-pro-preview-customtools"] simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"] verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3] fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", goal_gate=true, max_retries=0] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gemini -> simplify_gpt -> verify verify -> fmt [condition="outcome=succeeded"] verify -> fixup fixup -> verify fmt -> exit } ``` ## How the handoff works The key insight is that **planning happens interactively** and **implementation happens autonomously**. The workflow's `implement` node reads a plan file that you created in your REPL session — it doesn't generate its own plan. ### Step 1: Plan in Claude Code Use Claude Code's plan mode to collaborate on an implementation plan. Go back and forth until you're happy with the steps, file changes, and test strategy: ``` > /plan Add retry logic to the webhook delivery system Planning... 1. Add RetryPolicy struct to lib/components/fabro-webhooks/src/policy.rs 2. Implement exponential backoff with jitter 3. Add max_retries field to WebhookConfig 4. Write tests for retry timing, max attempts, and jitter bounds 5. Update webhook delivery loop to use RetryPolicy Shall I proceed with this plan? ``` ### Step 2: Hand off to Fabro Once the plan is ready, run the `/fabro-implement` slash command. This reads your Claude Code plan file and launches a Fabro workflow run: ``` > /fabro-implement ``` The command runs: ```sh theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run --detach --goal-file implement ``` The `--detach` flag returns immediately so you get your terminal back. Fabro executes the workflow on cloud resources in the background. ### The Claude Code command To set up the `/fabro-implement` slash command, create this file: ```markdown title="~/.claude/commands/fabro-implement.md" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} Get the path to the current Claude Code plan file. Run this command from the root of this repository: \`\`\`sh fabro run --detach --goal-file CLAUDE_CODE_PLAN_FILE_PATH implement \`\`\` Respond with the plan file path and the output from the `fabro run` command, for example: \`\`\` Plan file: ~/.claude/plans/FILENAME.md Fabro Run: OUTPUT_FROM_FABRO_RUN \`\`\` ``` Claude Code resolves `CLAUDE_CODE_PLAN_FILE_PATH` to the actual plan file, passes it as the `--goal-file` to Fabro, and the workflow's `implement` node reads that file to know what to build. ## How it works ### Preflight checks Before touching any code, the workflow validates the environment: 1. **Toolchain** — ensures Rust is installed (installs via `rustup` if not). Exits immediately if this fails. 2. **Preflight Compile** — `cargo check --workspace` confirms the codebase compiles. Exits if it doesn't — no point implementing against a broken baseline. 3. **Preflight Lint** — `cargo clippy --workspace -D warnings` checks for lint warnings. If lints fail, the `fix_lints` agent fixes them (up to 3 attempts) before proceeding. This guarantees the `implement` node starts from a clean, compiling, lint-free codebase. ### Implementation The `implement` node is the core agent stage. Its prompt is simple: ``` Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. ``` The agent reads your plan file (passed via `--goal-file`), then writes code, creates tests, and iterates until all plan steps are complete. The "red/green TDD" instruction means: write a failing test first, then make it pass. ### Multi-model simplification After implementation, the code passes through three independent simplification stages: ``` implement → Simplify (Opus) → Simplify (Gemini) → Simplify (GPT-54) → verify ``` Each model runs the same `@prompts/simplify.md` prompt but brings different strengths. Running the same review prompt across Claude, Gemini, and GPT catches different classes of issues — verbose code one model wrote that another would simplify, edge cases one model spots that others miss, naming improvements that vary by model preference. The models override the graph-level default via per-node `model` attributes: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} simplify_opus [prompt="@prompts/simplify.md"] simplify_gemini [prompt="@prompts/simplify.md", model="gemini-3.1-pro-preview-customtools"] simplify_gpt [prompt="@prompts/simplify.md", model="gpt-54"] ``` ### Verification and fixup The `verify` node runs clippy and the full test suite: ```sh theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} cargo +nightly-2026-04-14 clippy -q --workspace -- -D warnings 2>&1 && \ cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 ``` This node has two important attributes: * **`goal_gate=true`** — the workflow cannot succeed unless verification passes * **`retry_target="fixup"`** — if verification fails at the exit node, route back to the `fixup` agent instead of failing immediately The `fixup` agent reads the build output and fixes lint warnings and test failures. It loops back to `verify`, with `max_visits=3` as a circuit breaker. ### Format A final `cargo fmt --all` ensures consistent formatting. This is also a goal gate — unformatted code means the workflow fails. ## Adapting for other languages This workflow is Rust-specific, but the pattern generalizes. Replace the shell commands for your language: | Stage | Rust | TypeScript | Python | | - | - | - | - | | Toolchain | `rustup` | `bun --version` | `python3 --version` | | Compile | `cargo check` | `bun run typecheck` | `mypy .` | | Lint | `cargo clippy` | `bun run lint` | `ruff check .` | | Test | `cargo nextest run` | `bun test` | `pytest` | | Format | `cargo fmt` | `bun run format` | `ruff format .` | The agent stages (implement, simplify, fixup) work with any language — they use the Fabro agent's standard tools (Read, Write, Edit, Bash) and follow the plan file regardless of language. ## Further reading Assigning different models to different workflow nodes. Goal gates, retry targets, max visits, and circuit breakers. TOML configs, goal files, and detached runs. Running workflows on cloud resources with sandboxed execution. # Semantic Port Source: https://docs.fabro.sh/examples/semantic-port Continuously port upstream changes from one language to another using a ledger-driven loop A semantic port workflow tracks commits in an upstream repository, analyzes each one for relevance, and either ports the change to a different codebase or acknowledges it as not applicable — then loops back for the next commit. It's an autonomous maintenance loop, not a one-shot build task. This pattern is useful when you maintain a downstream implementation (e.g., a Go SDK) that tracks an upstream reference (e.g., a Python SDK). Instead of manually reviewing every upstream commit, the workflow processes the backlog commit-by-commit, making intelligent port-or-skip decisions. ## The workflow Semantic Port workflow: Start → Fetch → Analyze → Plan → Implement → Validate → Tests pass? → Finalize → loops back to Fetch, with Skip shortcut from Analyze back to Fetch, Fix loop from gate back to Validate, and Done exit from Fetch ```dot title="semantic-port.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph SemanticPort { graph [ goal="Port semantic changes from upstream Python repository to our Go implementation", rankdir=LR, default_max_retries=3, model_stylesheet=" * { model: claude-sonnet-4-5;} .hard { model: claude-opus-4-6; } .analyze { model: gemini-3.1-pro-preview;} " ] start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] // Phase 1: Find the next unprocessed commit fetch [ label="Fetch & Identify", prompt="Find the next unprocessed upstream commit.\n\n\ 1. Run `python3 ledger/manage.py earliest` to get the oldest commit with status=new\n\ 2. If found, write the commit details to .fabro/current_commit.md and respond with:\n\ {\"preferred_next_label\": \"process\"}\n\ 3. If no new commits exist:\n\ a. Fetch latest from upstream: cd upstream/ && git fetch && git pull\n\ b. Find commits newer than the latest in ledger.tsv\n\ c. Add them with `python3 ledger/manage.py add `\n\ d. Try `earliest` again\n\ e. If still none, respond with: {\"preferred_next_label\": \"done\"}\n\n\ Respond with exactly one of: process or done." ] // Phase 2: Analyze the commit and decide port vs. skip analyze [ label="Analyze & Decide", class="analyze", prompt="Read .fabro/current_commit.md for the commit to process.\n\ Examine it with `git show ` in the upstream/ directory.\n\n\ Analyze the semantic changes — what functionality changed, not just syntax.\n\ Decide if this change is relevant to our Go implementation or if it is\n\ Python-specific, docs-only, or not applicable.\n\n\ Write .fabro/analysis.md with sections:\n\ - Commit summary\n\ - Semantic analysis\n\ - Decision: PORT or ACKNOWLEDGE (with reasoning)\n\ - Port plan (if porting): concrete tasks with file:line references\n\n\ If decision is ACKNOWLEDGE:\n\ 1. Update ledger: `python3 ledger/manage.py update acknowledged`\n\ 2. Commit: `git add ledger/ && git commit -m \"semport: acknowledge - \"`\n\ 3. Respond with: {\"preferred_next_label\": \"skip\"}\n\n\ If decision is PORT:\n\ Respond with: {\"preferred_next_label\": \"port\"}" ] // Phase 3: Refine the plan plan [ label="Finalize Plan", prompt="Read .fabro/analysis.md. Perform a final editorial pass.\n\ Write .fabro/plan.md ensuring each task has:\n\ - Concrete file:line references in our Go code\n\ - Clear acceptance criteria\n\ - Directly executable instructions\n\n\ Remove vague language. The plan must be actionable." ] // Phase 4: Implement the port implement [ label="Implement Port", class="hard", prompt="Follow the plan in .fabro/plan.md.\n\ Port the semantic changes to the Go codebase.\n\ Focus on semantic equivalence, not literal translation.\n\ Use Go idioms and respect existing architecture.\n\ Log all changes to .fabro/implementation_log.md." ] // Phase 5: Validate validate [ label="Validate", shape=parallelogram, script="cd go-sdk && go build ./... && go test ./... -v 2>&1 || true" ] gate [shape=diamond, label="Tests pass?"] // Phase 6: Fix failures fix [ label="Analyze & Fix", class="hard", max_visits=3, prompt="Tests or build failed. Read the test output from the prior stage.\n\ Read .fabro/plan.md and .fabro/implementation_log.md.\n\ Diagnose the root cause, fix the issue, and log the fix." ] // Phase 7: Update ledger and commit finalize [ label="Finalize", prompt="All tests pass. Finalize this port:\n\ 1. Update ledger: `python3 ledger/manage.py update implemented`\n\ 2. Commit all changes:\n\ `git add -A && git commit -m \"semport: implement - \"`\n\ 3. Write a brief summary to .fabro/implementation_summary.md" ] // Wiring start -> fetch fetch -> analyze [label="Process", condition="preferred_label=process"] fetch -> exit [label="Done"] analyze -> plan [label="Port", condition="preferred_label=port"] analyze -> fetch [label="Skip"] plan -> implement -> validate -> gate gate -> finalize [label="Pass", condition="outcome=succeeded"] gate -> fix [label="Fail"] fix -> validate finalize -> fetch } ``` ## Key patterns ### The commit-processing loop The core of this workflow is a loop: `fetch → analyze → (port or skip) → fetch`. Each iteration processes exactly one upstream commit, then loops back for the next. The loop terminates when `fetch` finds no more unprocessed commits and routes to `exit`. ``` fetch → analyze → [Skip] → fetch → analyze → [Port] → plan → implement → validate → gate → [Pass] → finalize → fetch → ... → [Done] → exit ``` This is fundamentally different from a build workflow that runs once and exits. The semantic port workflow is designed to process an entire backlog autonomously, handling dozens of commits in a single run. ### Ledger-driven state The workflow tracks disposition in an external ledger file (`ledger.tsv`) with three states: | Status | Meaning | | - | - | | `new` | Unprocessed — the workflow hasn't looked at this commit yet | | `acknowledged` | Reviewed and determined to be irrelevant (docs-only, Python-specific, etc.) | | `implemented` | Semantic changes ported to the Go codebase | The ledger is the source of truth for what's been processed. Because it's a plain file committed to Git, it survives across runs — you can stop and resume the workflow and it picks up where it left off. ### Semantic analysis, not literal translation The `analyze` node is the decision point. It examines each upstream commit for *what changed functionally*, not just what code was modified. A commit that refactors Python type hints has no semantic impact on a Go port. A commit that changes retry behavior in the HTTP client does. This distinction is critical — routing a different model (Gemini) to the analysis node via the `.analyze` class brings a fresh perspective to the port/skip decision: ``` .analyze { model: gemini-3.1-pro-preview;} ``` ### The fix loop When ported code fails tests, the workflow enters a bounded fix loop: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate -> fix [label="Fail"] fix -> validate ``` The `fix` node has `max_visits=3`, preventing infinite retry cycles. If the fix can't be resolved in 3 attempts, the run terminates rather than looping forever. ### Skip vs. port branching The `analyze` node uses [routing directives](/workflows/transitions#agent-transitions) to choose between two paths: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} analyze -> plan [label="Port", condition="preferred_label=port"] analyze -> fetch [label="Skip", condition="preferred_label=skip"] ``` When the agent decides a commit is irrelevant, it updates the ledger, commits the acknowledgment, and loops back to `fetch` immediately — no planning or implementation needed. This keeps the workflow efficient: trivial commits (typo fixes, CI config changes, docs updates) are processed in seconds. ## Multi-model routing The stylesheet assigns three tiers of models: ``` * { model: claude-sonnet-4-5; } // Default: plan, finalize .hard { model: claude-opus-4-6; } // Implementation, fixing .analyze { model: gemini-3.1-pro-preview; } // Analysis: fresh eyes ``` * **Sonnet** handles routine tasks: fetching commits, finalizing plans, updating the ledger * **Opus** handles the hard work: implementing ports and diagnosing test failures * **Gemini Pro** handles analysis: a different provider brings independent judgment to the port/skip decision, reducing the risk of a single model's blind spots ## Run configuration Pair the workflow with a run config TOML for repeatable execution: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [workflow] graph = "semport.fabro" [run] goal = "Port semantic changes from upstream openai-agents-python to our Go SDK" [run.model] name = "claude-sonnet-4-5" provider = "anthropic" [run.model.fallbacks] "claude-sonnet-4-5" = ["openai"] "gemini-3.1-pro-preview" = ["anthropic"] [[run.prepare.steps]] script = "git clone https://github.com/openai/openai-agents-python upstream || (cd upstream && git pull)" [[run.prepare.steps]] script = "pip install -r ledger/requirements.txt" [run.inputs] upstream_repo = "openai/openai-agents-python" downstream_lang = "go" ``` Launch with: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run semport.toml ``` ## Adapting this pattern The semantic port pattern generalizes beyond language porting: * **Spec tracking** — monitor an upstream specification (OpenAPI, protobuf) and propagate changes to client libraries * **Dependency updates** — process a queue of dependency version bumps, testing and committing each one * **Issue triage** — pull issues from a tracker, classify them, and route to the appropriate workflow * **Log analysis** — process a backlog of alerts or log entries, investigating each one The core structure is always the same: fetch the next item, analyze it, decide on an action, execute, record the disposition, loop. ## Further reading Edge conditions, routing directives, and how agents control flow. CSS-like rules for assigning models to workflow nodes. Retry policies, loop detection, and max\_visits. TOML configs for repeatable, parameterized runs. # Build Solitaire Source: https://docs.fabro.sh/examples/solitaire Build a complete application from a spec using phased implementation with verification gates A build-from-scratch workflow takes a high-level goal, expands it into a spec, then implements the application in phases — each phase followed by an independent verification node and a conditional gate that either advances or retries. A final review with `goal_gate=true` ensures the result meets the spec before the workflow can succeed. This pattern is useful when you want an agent to build something non-trivial from zero — a CLI tool, a game, a library — where the implementation naturally decomposes into layers that build on each other. ## The workflow Build Solitaire workflow: Start → Spec → Setup → OK? → Data → OK? → Logic → OK? → UI → OK? → Integrate → OK? → Review → OK? → Exit, with Retry arcs from each gate back to its phase, and a Fix arc from the review gate back to UI ```dot title="build-solitaire.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph BuildSolitaire { graph [ goal="Build a terminal-based solitaire (Klondike) game in Python", rankdir=LR, default_max_retries=3, retry_target="impl_setup", fallback_retry_target="impl_logic", model_stylesheet=" * { model: claude-sonnet;} .hard { model: claude-opus; } .verify { model: claude-haiku; } " ] start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] // Phase 0: Expand the goal into a detailed spec expand_spec [ label="Expand Spec", prompt="Expand the goal into a detailed spec covering:\n\ - Game rules and data structures (Card, Deck, Pile types)\n\ - Terminal rendering approach (curses library)\n\ - Input handling and move validation\n\ - Win/loss detection\n\ - UI layout\n\ - Test strategy\n\n\ Write the spec to spec.md." ] // Phase 1: Project setup impl_setup [ label="Setup Project", prompt="Read spec.md. Create the Python project structure:\n\ pyproject.toml, src/ directory, tests/ directory, main.py stub.\n\ Run: python3 -m py_compile src/*.py" ] verify_setup [label="Verify Setup", class="verify", prompt="Verify project setup: check pyproject.toml exists,\n\ source directories exist, and files compile without errors.\n\ Run: python3 -m py_compile src/*.py" ] check_setup [shape=diamond, label="Setup OK?"] // Phase 2: Core data structures impl_data [ label="Data Structures", prompt="Read spec.md. Implement Card, Deck, and Pile types\n\ with unit tests. Run: python3 -m pytest tests/ -v" ] verify_data [label="Verify Data", class="verify", prompt="Verify data structures: build, run tests, check that\n\ Card, Deck, and Pile types are defined and basic operations work.\n\ Run: python3 -m pytest tests/ -v" ] check_data [shape=diamond, label="Data OK?"] // Phase 3: Game logic (hardest phase) impl_logic [ label="Game Logic", class="hard", max_retries=2, prompt="Read spec.md and the data structure files.\n\ Implement Klondike rules: initial deal, move validation,\n\ auto-complete detection, win condition, undo.\n\ Write tests for legal/illegal moves, win detection, edge cases.\n\ Run: python3 -m pytest tests/ -v" ] verify_logic [label="Verify Logic", class="verify", prompt="Verify game logic: run all tests, check move validation,\n\ win detection, and undo.\n\ Run: python3 -m pytest tests/ -v" ] check_logic [shape=diamond, label="Logic OK?"] // Phase 4: Terminal UI impl_ui [ label="Terminal UI", class="hard", max_retries=2, prompt="Read spec.md and game logic files.\n\ Implement terminal UI with curses: card rendering (ASCII art),\n\ board layout, keyboard input, move selection, help text.\n\ Run: python3 -m pytest tests/ && python3 -m py_compile src/*.py" ] verify_ui [label="Verify UI", class="verify", prompt="Verify terminal UI: build, run tests, check that\n\ renderer and input handler exist, game can be instantiated.\n\ Run: python3 -m pytest tests/" ] check_ui [shape=diamond, label="UI OK?"] // Phase 5: Integration impl_integration [ label="Integrate", prompt="Wire up main.py to start the game loop.\n\ Connect UI input to game logic. Add game over screen,\n\ help menu, and README with build/run instructions.\n\ Run: python3 -m pytest tests/" ] verify_integration [label="Verify Integration", class="verify", prompt="Verify integration: build, run all tests, check README\n\ exists, verify the game starts without errors.\n\ Run: python3 -m pytest tests/" ] check_integration [shape=diamond, label="Integration OK?"] // Phase 6: Final review (goal gate) review [ label="Final Review", class="hard", goal_gate=true, prompt="Read spec.md in full. Review the complete implementation:\n\ - All Klondike rules correctly implemented\n\ - Terminal UI works and is intuitive\n\ - Tests comprehensive and passing\n\ - README clear and accurate\n\n\ Run the full test suite. Write a review to review.md.\n\ Run: python3 -m pytest tests/ -v" ] check_review [shape=diamond, label="Review OK?"] // Wiring: linear phases with verify-gate loops start -> expand_spec -> impl_setup -> verify_setup -> check_setup check_setup -> impl_data [condition="outcome=succeeded"] check_setup -> impl_setup [condition="outcome=failed", label="Retry"] check_setup -> impl_setup impl_data -> verify_data -> check_data check_data -> impl_logic [condition="outcome=succeeded"] check_data -> impl_data [condition="outcome=failed", label="Retry"] check_data -> impl_data impl_logic -> verify_logic -> check_logic check_logic -> impl_ui [condition="outcome=succeeded"] check_logic -> impl_logic [condition="outcome=failed", label="Retry"] check_logic -> impl_logic impl_ui -> verify_ui -> check_ui check_ui -> impl_integration [condition="outcome=succeeded"] check_ui -> impl_ui [condition="outcome=failed", label="Retry"] check_ui -> impl_ui impl_integration -> verify_integration -> check_integration check_integration -> review [condition="outcome=succeeded"] check_integration -> impl_integration [condition="outcome=failed", label="Retry"] check_integration -> impl_integration review -> check_review check_review -> exit [condition="outcome=succeeded"] check_review -> impl_ui [condition="outcome=failed", label="Fix"] check_review -> impl_ui } ``` ## Key patterns ### Phased implementation with verification gates The workflow decomposes the build into six phases, each building on the previous one: ``` Spec → Setup → Data Structures → Game Logic → Terminal UI → Integration → Review ``` Each phase follows the same three-node pattern: **implement**, **verify**, **gate**. The implement node builds the code, a separate verify node checks it independently, and a conditional gate routes to the next phase on success or back to retry on failure. This separation matters. The verify node runs with a different prompt and (via the `.verify` class) a cheaper model. It acts as an independent check — not just "did the implementation node think it succeeded?" but "does an independent evaluation confirm the phase is complete?" ### Graph-level retry targets The graph sets two levels of retry targets: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} graph [ retry_target="impl_setup", fallback_retry_target="impl_logic" ] ``` If a node fails and has no local retry target, Fabro jumps back to `impl_setup` to re-attempt from project setup. If that target itself can't recover, Fabro falls back further to `impl_logic`. This creates a cascading recovery strategy without cluttering every node with retry configuration. ### Three-tier model routing The stylesheet assigns models by role: ``` * { model: claude-sonnet-4-5; } // Default: spec, setup, integration .hard { model: claude-opus-4-6; } // Hard work: game logic, UI, review .verify { model: claude-haiku-4-5; } // Verification: fast, cheap checks ``` * **Sonnet** handles routine phases: expanding the spec, setting up the project, wiring integration * **Opus** handles the hard phases: implementing game logic and terminal UI where correctness and complexity demand the strongest model * **Haiku** handles all verification gates: these are straightforward "run the tests, check the output" tasks that don't need a frontier model This keeps costs down. Verification runs after every phase — using Haiku instead of Opus for those checks saves significant tokens across a full run. ### Goal gate on final review The `review` node has `goal_gate=true`: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review [label="Final Review", class="hard", goal_gate=true, ...] ``` This means the workflow **cannot succeed** unless the review passes. Even if execution reaches the exit node through some edge routing path, Fabro checks all goal gates and fails the run if any are unsatisfied. The review is the quality bar — it reads the original spec and verifies the implementation against it. If the review fails, execution routes back to `impl_ui` rather than to the beginning. The assumption is that by the time you reach review, the foundation (data structures, game logic) is solid and only the UI or integration needs fixing. ### Progressive layering Each phase reads the spec and builds on the artifacts from prior phases. The data structures phase defines Card, Deck, and Pile types. The game logic phase reads those types and implements rules on top of them. The UI phase reads the game logic and renders it. Each layer has its own tests, so failures are caught at the right level of abstraction. This is more reliable than a single "implement everything" node because: * Earlier phases are validated before later phases begin * Failures are localized — a broken data structure is caught before game logic tries to use it * Retries target the right phase, not the entire build ## Run configuration Pair the workflow with a run config for repeatable execution: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [workflow] graph = "build-solitaire.fabro" [run] goal = "Build a terminal-based solitaire (Klondike) game in Python" [run.model] name = "claude-sonnet-4-5" provider = "anthropic" [run.model.fallbacks] "claude-sonnet-4-5" = ["openai", "gemini"] [[run.prepare.steps]] script = "python3 -m venv .venv && . .venv/bin/activate && pip install pytest curses" [run.environment] id = "python-dev" [environments.python-dev] provider = "daytona" [environments.python-dev.image] dockerfile = "FROM python:3.12-slim\nRUN apt-get update && apt-get install -y git libncurses-dev" [environments.python-dev.resources] cpu = 4 memory = "8GB" disk = "20GB" ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run build-solitaire.toml ``` ## Adapting this pattern The phased build pattern generalizes to any project that decomposes into layers: * **CLI tool** — argument parsing → core logic → output formatting → integration tests * **REST API** — data models → route handlers → middleware → end-to-end tests * **Library** — type definitions → core algorithms → public API → documentation * **Compiler** — lexer → parser → type checker → code generator → test suite The structure is always the same: decompose into phases that build on each other, verify each one independently, gate advancement on verification, and enforce overall quality with a goal gate on the final review. ## Further reading Retry policies, goal gates, retry targets, and circuit breakers. CSS-like rules for assigning models to workflow nodes. All node types, shapes, and their attributes. TOML configs for repeatable, parameterized runs. # Automations Source: https://docs.fabro.sh/execution/automations Named, repeatable run configurations with API and schedule triggers An **automation** is a saved run configuration — a Git run target, a workflow, a server-managed environment, and the triggers that may start it. By default, Fabro loads the workflow from the run-target checkout. An automation can instead name an independent GitHub repository and branch, tag, or exact commit for its workflow files. When a trigger fires, Fabro packages the selected workflow as an immutable workflow version and admits it through the same `RunIntent` pipeline as `POST /api/v1/runs`. Automation runs therefore get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it. ## Defining automations The server stores automations in its SQLite database. Manage them in the web UI at `/automations` or through the `/api/v1/automations` REST API. New definitions use Fabro's canonical Git run target. The working branch is always required. An optional tag selects that tag when no exact commit is present, and an optional 40-character commit SHA pins the run exactly. The exact commit wins when both a tag and SHA are present; the branch is retained as the run's working branch in every case. ```json title="Create automation request" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "id": "nightly-release", "name": "Nightly release", "description": "Cut a nightly build from main", "environment_id": "ci", "target": { "kind": "git", "repo": "fabro-sh/fabro", "branch": "main", "tag": "v1.2.3", "sha": "0123456789abcdef0123456789abcdef01234567" }, "workflow": "release", "triggers": [ { "type": "api", "id": "manual", "enabled": true } ] } ``` Automations currently support Git targets only. Folder and empty run targets are rejected during validation. ### Environment selection Every new or edited automation must select a server-managed Docker or Daytona environment. Local environments are intentionally unavailable because automation Git targets require a clone-based provider. Fabro validates that the selected environment exists, uses a compatible provider, and is enabled and ready on the server. The automation stores the environment ID rather than a copy of its settings. Each trigger fire resolves the current environment definition and snapshots those settings into the new run. Deleting an environment that an automation still references returns a conflict; edit or delete the automation first. ### Workflow resolution An extensionless workflow such as `"release"` resolves directly to `.fabro/workflows/release/workflow.toml` in the selected repository checkout. You may also provide an explicit repository-relative workflow path. Automation admission does not read `.fabro/project.toml`. Put settings needed by the run in the workflow configuration or the selected server environment. Fabro packages the workflow and its runnable dependencies into immutable workflow versions before creating the run. ### Using a remote workflow Omit `workflow_source` to resolve the `workflow` selector in the run-target checkout, as in the request above. This is the compatibility default for existing definitions. To keep reusable workflow files in another repository, enable a remote workflow and provide the same branch-plus-overrides coordinate used by Git run targets: ```json title="Create automation with a remote workflow" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "id": "nightly-release", "name": "Nightly release", "environment_id": "default", "target": { "kind": "git", "repo": "acme/orders-api", "branch": "main" }, "workflow": "release", "workflow_source": { "repo": "acme/automation-workflows", "branch": "main" }, "triggers": [ { "type": "api", "id": "manual", "enabled": true } ] } ``` `branch` is required and is used when neither override is present. An optional bare `tag` takes precedence over the branch, and an optional 40-character `sha` takes precedence over both. The branch is retained as fallback and audit context; Fabro fetches an exact SHA directly and does not require it to be reachable from the named branch. Branches and tags are resolved again on every firing. The server uses its configured GitHub credentials independently for the target and workflow-source repositories, with read-only repository access for workflow materialization; automation requests never carry credentials. Fabro resolves the run target to an exact commit and checks out the selected workflow source before it creates a run. It packages the workflow and its dependencies into immutable, content-addressed workflow versions, then admits the run with the root workflow-version ID and the independently exact run target. The run's automation metadata records both the requested workflow-source coordinate and its resolved commit, so the mutable source can be audited after its branch or tag moves. If an explicit source names the same repository and effective selector as the target, Fabro reuses the checkout without removing the explicit saved source. If target or source authentication, checkout, workflow discovery, packaging, or workflow-version storage fails, Fabro creates no run and sends no start request. ### Upgrading legacy targets When upgrading from file-backed automation storage, startup imports every valid `automations/*.toml` file next to the active `settings.toml`. Existing SQLite definitions win on ID conflicts. After a successful import, Fabro renames the directory to a timestamped backup such as `automations.imported-20260711T180000000000Z.bak`. Invalid TOML or an invalid target leaves the original directory untouched for operator repair. The legacy files use this shape: ```toml title="automations/nightly-release.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} name = "Nightly release" description = "Cut a nightly build from main" [target] repository = "fabro-sh/fabro" ref = "main" workflow = "release" [[triggers]] type = "api" id = "manual" enabled = true [[triggers]] type = "schedule" id = "nightly" enabled = true expression = "0 0 * * *" ``` Fabro converts legacy refs deterministically: * A 40-character hexadecimal SHA becomes an exact commit on working branch `main`. * `refs/tags/` and `tags/` become a tag on working branch `main`. * `refs/heads/` and `heads/` become a working branch. * `HEAD` becomes working branch `main`. * Any other bare value becomes a working branch. The `main` default is only a migration assumption. If the repository uses another working branch, edit the imported automation before running it. The same conversion runs transactionally for automations already in SQLite. An unsupported `refs/*` selector or an invalid branch or tag name aborts startup with an actionable error instead of guessing. The database remains on its previous schema and data, and the migration snapshot remains available. Edit the unsupported legacy `target_ref` to a branch, head selector, tag selector, `HEAD`, or exact SHA, then restart Fabro. Automations created before environment selection was introduced are backfilled conservatively. Fabro selects a compatible environment named `default` when one exists, or the sole Docker or Daytona environment when there is exactly one. With no compatible environment or multiple ambiguous choices, the automation remains incomplete until an operator selects one in the web UI. An incomplete automation cannot run. When a trigger fires, Fabro resolves the run target and selected workflow checkout, packages the workflow, and creates and starts the run. The created run records the exact checked-out target commit in its canonical target, so later inspection and automation creation preserve the target revision that actually ran. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed. ## Triggers Each trigger has an `id` and its own `enabled` flag. Trigger-level `enabled` is the sole activation control — there is no automation-level master switch. ### API triggers `POST /api/v1/automations/{id}/runs` creates and starts a run through the automation's API trigger. The request is rejected with a conflict when the automation has no enabled API trigger. The Run button on automation cards and detail pages in the web UI uses the same endpoint. ### Schedule triggers `expression` is a five-field cron expression evaluated in UTC: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[triggers]] type = "schedule" id = "weekday-mornings" enabled = true expression = "0 9 * * 1-5" ``` The server fires each enabled schedule trigger at its next occurrence and creates and starts a run. Creating, editing, or deleting an automation takes effect immediately — no restart needed. If a fire fails (for example, because its environment is unavailable or workflow packaging fails), Fabro stores the failure on the automation as `last_error`, logs it, and waits for the next occurrence rather than retrying. The error is cleared after a later scheduled run is queued successfully. ## Web UI The `/automations` area lists automations with create, edit, delete, and Run actions. The create and edit forms require a Docker or Daytona environment. Migrated automations without an environment are shown as incomplete and cannot run until edited. Saves are revision-checked, so concurrent edits fail loudly instead of silently overwriting each other. The detail page shows the automation's configuration, its most recent schedule error, and its run history with status, time, and repo filters. To bootstrap an automation from work you have already run, open a run's actions menu and choose **Create automation from run** — the new-automation form is pre-filled from that run's target repository and workflow. Its workflow source defaults to the target checkout because normal run summaries do not retain the automation's mutable source coordinate. You can enable a remote workflow before saving. Runs that were created by an automation show **View automation** instead. ## API `/api/v1/automations` provides full CRUD: list, create, fetch, replace, and delete. `environment_id` is required in create and replace requests. Automation responses may return a null environment only for an incomplete migrated definition, and expose the latest scheduled-run failure through `last_error`. Responses carry an `ETag` revision; `PUT` and `DELETE` require a matching `If-Match` header. `GET /api/v1/automations/{id}/runs` lists the automation's runs newest-first with standard pagination. # Checkpoints Source: https://docs.fabro.sh/execution/checkpoints How Fabro uses Git to checkpoint and resume workflow runs Fabro records every workflow run in the durable run store. With run branches enabled, it also commits file changes inside the run workspace after each node completes. GitHub targets push those commits to the run branch; local runs keep them in their workspace. A local-folder run works in a clone of the folder's committed `HEAD`, so uncommitted changes are not included, and a folder that is not a Git repository starts as an empty workspace. ## Code and execution history Fabro stores code and execution state separately: | Storage | Contains | | - | - | | **Run branch** (`fabro/run/{run_id}`) | File changes made by agents and commands | | **Durable run store** | Events and event-derived projections for checkpoints, stages, configuration, and conclusions | | **Content-addressed store (CAS)** | Artifact and offloaded context payloads referenced by events and checkpoints | The run branch is a regular Git branch. Checkpoint events record its commit SHAs, which link execution state to the corresponding code. ### Run branch commits After each node finishes, Fabro stages file changes and creates a commit on the run branch. Files matching `[run.checkpoint] exclude_globs` patterns (configured in [run.toml](/execution/run-configuration#runcheckpoint) or [settings.toml](/administration/server-configuration#runcheckpoint-section)) are excluded from staging: ``` fabro(01JKXYZ...): plan (succeeded) Fabro-Run: 01JKXYZ... Fabro-Completed: 2 ``` The commit message follows a structured format: | Part | Description | | - | - | | Subject line | `fabro({run_id}): {node_id} ({status})` | | `Fabro-Run` trailer | The run ID | | `Fabro-Completed` trailer | Number of completed nodes so far | The `git_commit_sha` in a `checkpoint.completed` event identifies the run branch commit. New commits do not include a `Fabro-Checkpoint` trailer. Fabro disables Git commit and tag signing for checkpoint commits created inside a sandbox. Your personal or repository-level signing settings can stay enabled, but sandbox bookkeeping does not need access to your signing key. Checkpoint commits and any commit a workflow command or agent creates all carry the run's one resolved Git identity. Fabro derives it from the run's GitHub App bot account or token user, or from the generic `Fabro ` identity when the run has no GitHub credential, and `[run.git.author]` overrides it. See [`[run.git.author]`](/administration/server-configuration#rungitauthor-section). ### Durable execution state Fabro derives run state from persisted events. The projection includes the run spec, start and status records, checkpoints, stage results, conclusion, and sandbox information. Artifact payloads live in CAS. Use `fabro dump` to export a run as files, including `run.json`, `graph.fabro`, and per-stage artifacts. ### Metadata branch Fabro no longer creates or pushes `fabro/meta/{run_id}` branches. Existing metadata branches remain untouched. Historical metadata snapshot events remain readable. ## What's in a checkpoint The checkpoint projection captures the execution state needed to resume a run: | Field | Description | | - | - | | `timestamp` | When the checkpoint was created | | `current_node` | The node that just completed | | `next_node_id` | The next node the engine would execute | | `completed_nodes` | Ordered list of all completed node IDs | | `node_retries` | How many retry attempts each node has used | | `node_outcomes` | Full outcome (status, context updates, usage) for each completed node | | `context_values` | Snapshot of the entire [run context](/execution/context) | | `git_commit_sha` | SHA of the run branch commit at this checkpoint | | `loop_failure_signatures` | Failure signature counts for loop detection | | `restart_failure_signatures` | Failure signature counts across loop-restart edges | The durable run store also keeps the current checkpoint so `resume`, `inspect`, and API reads do not need to rely on scratch files. ## Run workspaces A GitHub target is checked out inside the run workspace. For Docker and Daytona, checkout, checkpoint commits, diffs, and pushes execute inside the sandbox. The host provider performs those operations in its local run workspace. Fabro keeps no second checkout, bare snapshot repository, or Git checkpoint bundle on the server for a remote sandbox. When pushing is configured, Fabro pushes `fabro/run/{run_id}` after each checkpoint and again before successful completion. Execution records and artifact payloads remain in the run store and CAS. Ordinary resume requires the original workspace to survive. Git-backed workspaces reset to their recorded checkpoint; non-Git workspaces continue with their surviving files. Losing the workspace does not trigger automatic reconstruction or sandbox replacement. A GitHub-backed fork or rewind fetches the source run's published branch inside a fresh workspace and checks out the selected checkpoint. This requires run-branch pushes to be enabled and the commit to be available on origin. Select a checkpoint with remaining work: the final terminal checkpoint is refused because no stage remains to acquire a workspace. A retry starts the workflow from the beginning using its saved specification, without requiring the previous workspace or any Git checkpoint. ## Resuming a run Resume an interrupted run from its durable checkpoint: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro resume 01JKXYZ ``` Fabro resolves the run by ID prefix, validates that durable state contains a checkpoint, and asks the server to continue execution. No workflow file or override flags are needed — all configuration is restored from persisted state. 1. Fabro looks up the run directory by ID prefix 2. Validates that a checkpoint exists in durable state and no engine process is already running 3. Cleans stale local artifacts from the previous execution 4. Resets status to `Submitted` and spawns a new engine subprocess with `--resume` 5. The engine restores the full context, completed node list, retry counts, and failure signatures from durable state 6. If the checkpointed node used `full` fidelity, downgrades the first resumed node to `summary:high` (since the original conversation thread no longer exists in memory) 7. Continues execution from `next_node_id` ### Resuming an in-flight stage If the latest checkpoint captured a stage that was still running, resume allocates a new stage execution ID instead of rewriting the original history. For example, a resumed `work@1` stage continues as `work@2`, and the new record sets `resumed_from_stage_id = "work@1"`. The earlier stage remains immutable. The number after `@` is the stage execution ordinal. It is separate from the graph visit count and from a handler's retry attempt, so loops, retries, and resume ancestry can be inspected independently. ## The checkpoint cycle After a node completes, Fabro: 1. Stores offloaded context payloads in CAS. 2. Creates a code checkpoint commit when Git checkpointing is enabled. 3. Collects the code diff. 4. Emits a checkpoint event with execution state and the code commit SHA. The run store persists this event and updates the projection. A checkpoint commit failure stops execution. A stage diff failure emits a warning notice. An intermediate push failure warns and is retried at later checkpoints and final publication. Failure to prepare required final publication, push the final commit, or open the pull request marks an otherwise successful run as failed with `publish_failed`. ## Inspecting run history Because checkpoints are plain Git commits, you can inspect them with standard Git tools: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} # View the commit log for a run git log fabro/run/01JKXYZ... --oneline # See what an agent changed at a specific node git show fabro/run/01JKXYZ... # Diff the full run against the starting point git diff main..fabro/run/01JKXYZ... # Inspect execution state and events fabro inspect 01JKXYZ fabro events 01JKXYZ # Export the run projection and artifacts fabro dump 01JKXYZ --output ./run-dump ``` ## When checkpointing is active Git checkpointing activates automatically when run branches are enabled and either: * The run targets a GitHub repository with cloning enabled, or * The run's workspace is on the host: a Local environment, or any `--dry-run` It is skipped when: * Run branches are disabled * A Docker or Daytona run has no GitHub target, or its cloning is disabled # Child Runs Source: https://docs.fabro.sh/execution/child-runs Spawn and supervise additional Fabro runs from a parent run A Fabro run can orchestrate other Fabro runs. An agent in the parent run can create child runs, inspect them, wait for them, and control them while the parent continues coordinating the larger job. Child runs are full workflow runs. Each child has its own run ID, workflow, lifecycle, sandbox, events, checkpoints, artifacts, and outputs. The parent-child relationship gives Fabro a durable way to show, query, and manage the orchestration tree. ## When to use child runs Use child runs when the unit of work is large enough to deserve its own workflow run: * **Parallel workstreams** - launch implementation, review, migration, or validation runs at the same time. * **Specialized workflows** - delegate to purpose-built workflows for different repos, services, or review types. * **Long-running work** - let the parent keep coordinating while children run independently. * **Manager patterns** - build a parent workflow that creates workers, watches progress, gathers results, and decides what happens next. * **Variants and attempts** - run multiple approaches as separate durable runs with separate outputs. For small subtasks inside one agent stage, use [sub-agents](/agents/subagents). For reusable workflow structure inside one run, use [sub-workflows](/tutorials/sub-workflow) or [imports](/workflows/imports). ## Enable run tools Workflow agents can create and manage runs when the parent run opts in to Fabro's run-management tool catalog: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent] fabro_tools = true ``` This exposes the same Fabro run tools available through [MCP](/agents/mcp): | Tool | Purpose | | - | - | | `fabro_workflow_version_create` | Register supplied workflow files and return an immutable version ID | | `fabro_run_create` | Create one or more child runs, starting them by default | | `fabro_run_search` | Search runs, including direct children by `parent_id` | | `fabro_run_get` | Inspect a run without mutating it | | `fabro_run_interact` | Start, message, interrupt, cancel, archive, unarchive, link, unlink, inspect, or answer questions | | `fabro_run_gather` | Wait for runs to reach terminal states | | `fabro_run_events` | Read stored events for a run | | `fabro_run_pair` | Pair with an active API-mode agent stage | When a workflow agent calls `fabro_run_create`, Fabro always parents the created runs to the current run. If the agent supplies `parent_id`, it must match the current run ID. ## Create child runs Acquire workflow files with the agent's shell/read tools, then call `fabro_workflow_version_create` with their contents: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "entrypoint": "workflow.fabro", "files": { "workflow.fabro": "digraph Child { start [shape=Mdiamond] work [prompt=\"@prompt.md\"] exit [shape=Msquare] start -> work -> exit }", "prompt.md": "Implement the requested change and run the relevant tests." } } ``` Use the returned `workflow_version_id` in `fabro_run_create`: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "runs": [{ "workflow_version_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "goal": "Implement the checkout page refactor and run its tests.", "args": { "labels": { "lane": "checkout", "source": "parent-run" } }, "start": true }] } ``` A version can be reused for several children, each with its own goal, target, and `args`. When the version already exists, skip registration. Read goal files with the agent's read tool and pass literal `goal` text. Workflow names, paths, inline source objects, and `goal_file` are no longer run-create inputs. This flow works in Local, Docker, and Daytona environments. The agent can clone a remote workflow in its sandbox and submit the resulting contents. Fabro's native tool handler executes outside the sandbox, so registration treats file-map keys as virtual relative paths and never reads sandbox paths from the worker host. Include the workflow's referenced configuration, prompts, and child workflows in the supplied tree. See [MCP](/agents/mcp) for package limits. Workflow content and workspace target are independent. If `target` is omitted, a native child inherits the parent's canonical target: `none` or folder as-is, and a Git target's repository and current execution branch (normally `fabro/run/`). Push parent changes before creating the child; a child clone sees that branch's remote HEAD. The parent's original pinned SHA/tag is not inherited. If run branches are disabled, the original input branch is used; if an enabled execution branch is unavailable, send an explicit target. An explicit Git, `none`, or folder target overrides inheritance while the current run remains the forced parent. Set `sha` on an explicit Git target to pin a child. Server admission enforces folder access: Docker/Daytona parents cannot select a server-host folder, including by requesting a Local child environment. Standalone MCP always requires an explicit target, even with `parent_id`. Use `environment_id` to choose a server environment; omission uses the server default, not the parent's environment. Run overrides belong in canonical `args`: `inputs`, `labels`, `model`, `provider`, `auto_approve`, `dry_run`, and `preserve_sandbox`. Omission preserves workflow/server defaults; explicit `false` is not omitted. The tool does not apply caller, project, or machine run settings. Keep workflow-owned configuration in the registered `workflow.toml`. Creation and start remain separate operations. Set `start: false` to leave a child submitted. A batch stops on its first failure; if any runs have already been created, their IDs appear in the error so the parent can inspect them instead of recreating them blindly. ## Start and approval Child run creation and child run execution are separate steps: 1. **Create** - `fabro_run_create` creates a durable run record with the current run as parent. 2. **Start request** - if `start` is true, Fabro requests execution for the child. 3. **Approval if required** - parent-generated child runs may enter `pending` with `approval_required`. 4. **Schedule** - after approval, the child becomes `runnable` and the scheduler starts it when capacity is available. 5. **Execute** - the child runs its own workflow and writes its own events, checkpoints, artifacts, and outputs. The approval step prevents a workflow agent from recursively launching executing runs without an operator checkpoint. Approve or deny pending child runs from the web UI or through the run lifecycle API. ## Supervise children A parent run can keep track of the children it creates. List direct children: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "parent_id": "01KRTKP5DJJ4EV6T7QSB081Z1N" } ``` Wait for children to finish: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "run_ids": [ "01KRTM8V3WQ6E9M2A2R6B3G8HK", "01KRTM91N7W9S6BP2RZ6SXX4FR" ], "timeout_seconds": 1800 } ``` Inspect a child: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "run_id": "01KRTM8V3WQ6E9M2A2R6B3G8HK" } ``` Cancel a child that is no longer useful: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "action": "cancel", "run_id": "01KRTM8V3WQ6E9M2A2R6B3G8HK" } ``` Read a child's events: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "run_id": "01KRTM8V3WQ6E9M2A2R6B3G8HK", "limit": 100 } ``` Child listings are direct. To walk a larger tree, list each child as a parent and repeat. ## Web UI Run detail pages include a **Children** tab. It lists runs whose `parent_id` is the current run, shows a count badge on the tab, supports refresh, filtering, sorting, and archived-run visibility, and uses the same run list layout as the main runs page. Use this tab when you want to see the work a manager run delegated, jump into a child run, inspect child output, or verify that a child has finished. ## CLI and API You can also create and organize child runs outside a workflow agent. Create a run under an existing parent: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run child.fabro --parent parent-run fabro create child.fabro --parent parent-run ``` List direct children: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro ps --parent parent-run ``` Repair or reorganize relationships: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro parent link child-run parent-run fabro parent unlink child-run ``` The HTTP API exposes the same model: | Operation | Purpose | | - | - | | `POST /api/v1/runs` with `parent_id` | Create a run under a parent | | `GET /api/v1/runs?parent_id=...` | List direct children | | `PUT /api/v1/runs/{id}/parent` | Link or replace a run's parent | | `DELETE /api/v1/runs/{id}/parent` | Remove a run's parent link | | `POST /api/v1/runs/{id}/approve` | Approve a pending child run | | `POST /api/v1/runs/{id}/deny` | Deny a pending child run | ## Relationship rules Parent-child links are orchestration metadata: * A run can have one parent and any number of direct children. * Creating or linking a child requires the parent run to exist. * Self-parenting and cycles are rejected. * Parent links can be changed for active, terminal, and archived runs. * A child can keep its historical parent reference even if the parent run is later removed. * Parent-child links are not fork or rewind lineage. Fork and rewind use separate source fields. ## Related concepts | Concept | Runtime boundary | Use it for | | - | - | - | | Child runs | Separate durable runs connected by `parent_id` | Orchestrating independent workflows | | Sub-agents | Separate LLM sessions inside one agent stage | Delegating small agent subtasks without creating runs | | Sub-workflows | A child workflow engine inside the same run | Reusing a workflow with runtime isolation but one parent run | | Imports | Parse-time graph expansion | Reusing graph structure without a runtime boundary | | Forks and rewinds | New runs from an existing checkpoint | Exploring or replacing execution from prior run state | | Parallel branches | Concurrent branches inside one workflow run | Splitting work within a single graph | # Context Source: https://docs.fabro.sh/execution/context How data flows between workflow stages Every workflow run has a **context** — a shared key-value store that carries data between stages. When an agent writes code, a command captures test output, or a human makes a selection, the results are recorded as context updates. Downstream nodes can read these values to make decisions, build prompts, and route execution. ## How context flows The context starts empty at the beginning of a run. As each stage completes, its outcome includes a set of **context updates** — key-value pairs that are merged into the shared context. Later stages see all updates from earlier stages. ``` Start → Plan → Implement → Test → Exit │ │ │ │ │ └─ sets command.output │ └─ sets response.implement, last_response └─ sets response.plan, last_response ``` Context is thread-safe and shared across the entire run. Parallel branches receive an isolated **deep copy** of the context at the point of fan-out, so branches can't interfere with each other. The parallel handler gathers their results under `parallel.results`. ## How agents access context Agents do not have a tool to query the context store directly. Instead, context is made available through two mechanisms: * **Preamble injection** — When a node starts, Fabro assembles a preamble from the current context and prepends it to the node's prompt. The [fidelity setting](#fidelity-controlling-agent-context) controls how detailed this preamble is. * **Context updates** — Agents can write to the context by including a `context_updates` object in a JSON response. See [Transitions](/workflows/transitions#agent-transitions) for the response format. ## Keys set by handlers Each handler type writes specific keys into the context after execution: ### Agent and prompt nodes | Key | Value | | - | - | | `last_stage` | The node ID of the stage that just completed | | `last_response` | Truncated LLM response (first 200 characters) | | `response.{node_id}` | Full LLM response text | Agents can also emit arbitrary context updates by including a JSON object with a `context_updates` field in their response. See [Transitions](/workflows/transitions#agent-transitions). The `review_target` key has an optional typed convention for human review workflows. A human gate with `review_target=true` reads this exact flat key and presents its document URL as the primary question link. See [Review targets](/workflows/human-in-the-loop#review-targets). ### Command nodes | Key | Value | | - | - | | `command.output` | The command's ordered output stream. Durable context stores this as a `blob://sha256/...` ref after the command completes; downstream prompts resolve it back to text. | ### Human gates | Key | Value | | - | - | | `human.gate.selected` | The accelerator key (e.g. `"A"`) or `"freeform"` | | `human.gate.label` | The full label of the selected edge | | `human.gate.text` | The user's freeform text (if applicable) | | `human.gate..question` | The question text for a specific human gate node | | `human.gate..answer` | The answer text for a specific human gate node | | `human.gate..label` | The selected label for a specific human gate node, when applicable | ### Parallel fan-out and fan-in | Key | Value | | - | - | | `parallel.results` | Ordered branch results. Each entry contains `id`, `index`, optional `item_label`, `status`, and the branch's isolated `context_updates`. Legacy results may omit `index`. | | `parallel.branch_count` | Number of branches dispatched. For `for_each`, this is the runtime array length. | Branch updates remain nested inside `parallel.results`; they are not merged into top-level context. Prompted fan-in nodes can synthesize the complete result array, while promptless fan-in nodes act as barriers. ### Runtime arrays with `for_each` A parallel node can read a flat context key and run one agent or prompt target per array item: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} batch [shape=component, for_each="context.candidates"] batch -> reviewer ``` `context.candidates` first checks the exact key and then falls back to `candidates`. The source must be a JSON array, either inline or stored behind a Fabro-managed `blob://` or `file://` reference. General nested lookup such as `output.scan.candidates` is not supported; have the producing node write the array to a flat context key such as `candidates`. Each item receives a separate context fork, while `(id, index)` identifies its result. The item itself is not copied into `parallel.results`. ### Engine-managed keys The engine sets several keys automatically. These are prefixed with `internal.` and are excluded from preambles: | Key | Value | | - | - | | `internal.run_id` | Unique identifier for this run | | `internal.work_dir` | Working directory path | | `internal.fidelity` | The resolved fidelity mode for the current node | | `internal.thread_id` | Thread ID for shared-conversation nodes (or null) | | `internal.node_visit_count` | How many times the current node has been visited | | `internal.retry_count.{node_id}` | Number of retry attempts used by a node | | `outcome` | Status of the last completed stage (`succeeded`, `failed`, etc.) | | `failure_class` | Classification of the last failure (if any) | | `failure_signature` | Deduplication signature for the last failure | | `preferred_label` | Label selected by a human gate or agent routing directive | | `current_node` | ID of the node currently executing | | `graph.goal` | The workflow's goal attribute | | `graph.{attr}` | All graph-level attributes, mirrored into context | ## Using context in conditions Edge [conditions](/workflows/transitions#conditions) can read context values to route execution: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate -> deploy [condition="outcome=succeeded && context.tests_passed=true"] gate -> fix [condition="outcome=failed"] ``` The `context.` prefix is optional — `tests_passed=true` and `context.tests_passed=true` are equivalent. Engine-managed keys like `internal.node_visit_count` work in conditions too. This is useful for [fixed-count loops](/tutorials/branch-loop#fixed-count-loops): ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate -> exit [condition="context.internal.node_visit_count >= 5"] gate -> improve ``` ## Fidelity: Controlling agent context When a new agent or prompt node starts, Fabro assembles a **preamble** — a summary of what happened in prior stages. The **fidelity** setting controls how detailed this preamble is. | Fidelity | Behavior | | - | - | | `full` | No preamble. The agent continues in the same conversation thread, seeing complete prior context. | | `compact` | Nested-bullet summary with handler-specific details (default). | | `summary:high` | Detailed per-stage Markdown report. | | `summary:medium` | Moderate detail with outcomes and notable findings (\~1500 token target). | | `summary:low` | Brief summary with just outcomes per stage (\~600 token target). | | `truncate` | Minimal — only the goal and run ID. | ### Setting fidelity Fidelity can be set at three levels. The first match wins: 1. **Edge attribute** — Set `fidelity` on an edge to control the transition into a specific node: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} plan -> implement [fidelity="full"] ``` 2. **Node attribute** — Set `fidelity` on a node to control all transitions into it: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [fidelity="full"] ``` 3. **Graph default** — Set `default_fidelity` on the graph for a run-wide default: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [default_fidelity="summary:medium"] } ``` If none of these are set, fidelity defaults to `compact`. ### Parallel branch fidelity The first node in each parallel branch uses this precedence: 1. `fidelity` on the fork-to-branch edge 2. `fidelity` on the branch node 3. Otherwise, inherit the fork's preamble unchanged Fabro renders any branch-specific preambles before fan-out from the fork's context snapshot, then places them into the isolated branch contexts. An explicit branch-level `full` degrades to `summary:high` because concurrent branches cannot share conversation sessions. `thread_id` on a branch node or fork-to-branch edge is inert. ### Full fidelity and threads `full` fidelity is typically used with `thread_id` to create a shared conversation across multiple nodes. Nodes with the same `thread_id` share a single LLM session, preserving full context continuity: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} subgraph cluster_impl { node [fidelity="full", thread_id="impl"] plan [label="Plan"] implement [label="Implement"] review [label="Review"] } ``` In this example, the implement node sees the plan node's entire conversation, and the review node sees both. ### Fidelity on resume When a run is resumed from a checkpoint, the first node after resume degrades `full` fidelity to `summary:high`. This prevents the resumed node from expecting a conversation thread that no longer exists in memory. ## Preamble construction For non-`full` fidelity modes, Fabro builds a preamble from runtime data and prepends it to the node's prompt. Given a plan-test-implement workflow where the plan and test stages have completed, the `implement` node would receive a preamble like this prepended to its prompt: ```markdown theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} Goal: Add a /health endpoint to the API server ## Completed stages - **plan**: success - Model: claude-sonnet-4-5, 12.4k tokens in / 3.2k out - Files: docs/plan.md - **test**: success - Script: `cargo test 2>&1 || true` - Output: ``` running 42 tests ... 41 passed, 1 failed ``` ## Context - tests_passed: false ``` The preamble includes: * The workflow goal * A summary of completed stages (format depends on fidelity level) * Handler-specific details: * **Command nodes** — the script that ran and the ordered output * **Agent/prompt nodes** — the model used, token counts, and files touched * Non-internal context values that weren't already rendered inline Internal keys (prefixed with `internal.`, `current`, `graph.`, `thread.`, `response.`) are excluded from preambles to avoid noise. ### Large preamble values In fidelity modes that render context or completed-stage output, one value can contribute at most 8 KiB of serialized JSON inline. Fabro stores larger values as content-addressed blobs, materializes them as readable files, and puts the size, file path, and a 300-character preview in the preamble. The agent can read the file when it needs the full value. This prompt limit is separate from durable artifact offloading. If Fabro cannot demote a value, it logs a warning and keeps that value inline so the stage can continue. ## Artifact offloading When a stage produces a large output (over 100KB of serialized JSON), Fabro stores the serialized bytes in a global content-addressed blob store and replaces the context value with a durable blob ref. Command output is always finalized into a blob ref after command completion, even when it is small or empty: ``` response.plan → blob://sha256/2cf24dba5fb0... command.output → blob://sha256/a4f3c1d9c2e1... ``` Checkpoints and checkpoint-completed events persist these `blob://` refs, not host-specific file paths. Before Fabro builds a preamble or starts the next stage, it resolves any blob refs into execution-local files so handlers and agents still see normal `file://` references: * Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_hash}.json` * Remote sandboxes materialize blobs under the sandbox runtime directory, `{runtime_directory}/blobs/{blob_hash}.json`. This directory lives outside the repository checkout, so materialized blobs never appear in `git status` and are never committed by a checkpoint. These materialized `file://` paths are runtime-only. They are not written back into durable context snapshots. This keeps the durable context lean and portable while still giving downstream stages a filesystem path they can read. ## Context compaction During long-running agent sessions, the conversation history can grow large enough to exceed the LLM's context window. **Context compaction** automatically summarizes older turns and replaces them with a structured summary, keeping the session running without manual intervention. Compaction is always enabled and runs with hardcoded defaults — there are no user-facing configuration options. ### When it triggers After every assistant turn, Fabro estimates the total token usage of the system prompt and conversation history (using a rough heuristic of 1 token per 4 characters). If the estimate exceeds **80%** of the model's context window, compaction runs. ### How it works 1. **Split history** — The conversation is divided into older turns (to be summarized) and the most recent **6 turns** (preserved verbatim). 2. **Render old turns** — Older turns are serialized to a human-readable text format. Tool call arguments and tool results are truncated to 500 characters each. 3. **Summarize via LLM** — Fabro makes a non-streaming LLM call (using the same model and provider as the agent session) with a structured summarization prompt. The summary uses these sections: * **Goal** — what the user asked for * **Progress** — what was accomplished, with file paths and key decisions * **Key Decisions** — important choices and their rationale * **Failed Approaches** — what was tried and didn't work * **Open Issues** — remaining bugs, edge cases, or TODOs * **Next Steps** — what should happen next * **File Operations** — if the agent has tracked file modifications, this section is copied verbatim into the summary 4. **Replace history** — All old turns are removed and replaced with a single `[Context Summary]` system message containing the structured summary. The preserved recent turns remain unchanged. ### Failure behavior Compaction failures are **non-fatal**. If the summarization LLM call fails, Fabro emits an `Agent.Error` event and the session continues with the original uncompacted history. The next assistant turn will trigger another compaction attempt. ### Events Compaction emits three events to the [event stream](/execution/observability#event-stream): | Event | When | Key fields | | - | - | - | | `Agent.Warning` (kind: `context_window`) | Token estimate exceeds 80% of context window | `kind`, `message`, `details` (contains `estimated_tokens`, `context_window_size`, `usage_percent`) | | `Agent.CompactionStarted` | Compaction begins | `estimated_tokens`, `context_window_size` | | `Agent.CompactionCompleted` | Summary generated and history replaced | `original_turn_count`, `preserved_turn_count`, `summary_token_estimate`, `tracked_file_count` | # Environments Source: https://docs.fabro.sh/execution/environments Reusable named execution environments for workflow runs Fabro separates **environments** from **sandboxes**: * An **environment** is reusable desired configuration: provider, image, resources, network, lifecycle, labels, and environment variables. * A **sandbox** is the concrete runtime instance Fabro creates for a run from the selected environment. Older pre-v1.0 config files that still use `[run.sandbox]` are temporarily auto-migrated when Fabro loads them from disk. Fabro writes a sibling `*.legacy-sandbox-migration.bak` file, rewrites the config to `[run.environment]` plus an environment definition, and then continues startup. Similarly, `[environments.*]` tables in the server's active `settings.toml` are auto-migrated on startup. Existing sibling `environments/*.toml` files are then treated as a legacy import source: Fabro imports missing environment IDs into SQLite once and renames the directory to `environments.imported-.bak`. These compatibility rewrites only handle direct field mappings. Unsupported legacy fields fail with a migration message that lists the keys to edit manually. The rewrite paths will be removed before v1.0. Runs select environments by slug: ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment] id = "fabro-dev" ``` Server-managed environments are stored in the server SQLite database. Manage them through the web UI or the `/api/v1/environments` REST API. Install seeds `default` as an ordinary persisted environment; users can replace or delete it. `local` is reserved, synthetic, and unpersisted: it appears only when the local sandbox provider is enabled, and it cannot be created, replaced, or deleted through the environments API. Workflow and project TOML can additionally define `[environments.]` catalog entries that merge with the server catalog through the normal settings precedence. ## Defining Server Environments Create server-managed environments through the REST API. Stored environments use inline Dockerfile content; local Dockerfile paths are rejected by the API because the server cannot safely resolve client-side paths. ```json title="POST /api/v1/environments" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "id": "fabro-dev", "provider": "daytona", "cwd": null, "image": { "docker": null, "dockerfile": { "type": "inline", "value": "FROM buildpack-deps:noble\n" } }, "resources": { "cpu": 8, "memory": "16GB", "disk": "20GB" }, "network": { "mode": "cidr_allow_list", "allow": ["10.0.0.0/8"] }, "lifecycle": { "preserve": false, "stop_on_terminal": true, "auto_stop": "30m" }, "labels": { "repo": "fabro-sh/fabro" }, "env": { "NODE_ENV": "development" } } ``` Server-managed local-provider environments can also set `cwd`, an optional runtime command working directory: ```json title="POST /api/v1/environments" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "id": "host", "provider": "local", "cwd": "/srv/fabro/workspaces/team-a", "image": { "docker": null, "dockerfile": null }, "resources": { "cpu": null, "memory": null, "disk": null }, "network": { "mode": "allow_all", "allow": [] }, "lifecycle": { "preserve": false, "stop_on_terminal": true, "auto_stop": null }, "labels": {}, "env": {} } ``` `cwd` is owned by the server environment and is only honored by the `local` provider. It is not a replacement for `run.working_dir`. Docker and Daytona ignore `cwd` and report a preflight warning because those clone-based providers own their workspace layout. Workflow, project, user, and direct-run `[environments.]` catalogs cannot set `cwd`; configure it in the server-managed environment through the environments API. The same fields nest under `[environments.]` when defined in workflow or project TOML instead: ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment] id = "fabro-dev" [environments.fabro-dev] provider = "daytona" [environments.fabro-dev.image] dockerfile = { path = "Dockerfile" } [environments.fabro-dev.resources] cpu = 8 memory = "16GB" disk = "20GB" [environments.fabro-dev.network] mode = "cidr_allow_list" # allow_all | block | cidr_allow_list allow = ["10.0.0.0/8"] [environments.fabro-dev.lifecycle] preserve = false stop_on_terminal = true auto_stop = "30m" [environments.fabro-dev.labels] repo = "fabro-sh/fabro" [environments.fabro-dev.env] NODE_ENV = "development" ``` Run-level overrides are sparse and apply only to the selected environment: ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment] id = "fabro-dev" [run.environment.resources] memory = "32GB" [run.environment.lifecycle] preserve = true ``` `env` and `labels` merge by key. ## Environment value interpolation Environment `env` values can mix literal text with `{{ vars.NAME }}` and `{{ secrets.NAME }}` tokens: ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.fabro-dev.env] DEPLOY_ENV = "{{ vars.DEPLOY_ENV }}" SERVICE_URL = "https://api.{{ vars.REGION }}.example.com" SERVICE_TOKEN = "{{ secrets.SERVICE_TOKEN }}" ``` Server-managed variables resolve when the run is created. Token secrets resolve immediately before the sandbox starts, so resolved secret values are not persisted in the run definition. A missing or non-token secret fails closed, as does any `{{ env.* }}` reference: the process environment is not a configuration source. ## Selecting an environment from the CLI Use `--environment` with an environment slug: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro --environment fabro-dev fabro preflight workflow.fabro --environment ci fabro server start --environment default ``` `--preserve-sandbox` still controls the concrete runtime instance lifecycle for a run. Runtime commands such as `fabro sandbox ssh` keep the word "sandbox" because they operate on an already-created runtime instance. ## Seeded Environments Install seeds a `default` environment into SQLite. It is a normal persisted environment, so deleting it removes the default run target until you recreate it. The standard Docker default is: ```json title="GET /api/v1/environments/default" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "id": "default", "provider": "docker", "image": { "docker": "buildpack-deps:noble", "dockerfile": null }, "resources": { "cpu": 2, "memory": "4GB", "disk": null }, "network": { "mode": "allow_all", "allow": [] }, "lifecycle": { "preserve": false, "stop_on_terminal": true, "auto_stop": null }, "labels": {}, "env": {} } ``` `local` is not stored in SQLite. It is synthesized at runtime when the local sandbox provider is enabled. ## Provider mappings | Environment field | Local | Docker | Daytona | | - | - | - | - | | `image.docker` | Ignored | Docker image | Snapshot base image; Fabro computes the snapshot name | | `image.dockerfile` | Ignored | Warning; ignored | Snapshot Dockerfile; Fabro computes the snapshot name | | `resources.cpu` | Warning; ignored | `cpu_quota = cpu * 100000` | Snapshot CPU | | `resources.memory` | Warning; ignored | Container memory limit | Snapshot memory | | `resources.disk` | Warning; ignored | Warning; ignored | Snapshot disk | | `network.mode = "allow_all"` | Host network | Docker default bridge | Daytona allow-all | | `network.mode = "block"` | Error | Docker `none` network | Daytona block | | `network.mode = "cidr_allow_list"` | Error | Error | Daytona CIDR allow-list | | `labels` | Warning; ignored | Warning; ignored | Daytona labels | | `lifecycle.auto_stop` | Warning; ignored | Warning; ignored | Daytona auto-stop | | `env` | Process environment overlay | Container environment | Sandbox environment | | `cwd` | Does not override the managed run workspace | Warning; ignored | Warning; ignored | ## Local `local` runs tools on the Fabro host, in a workspace under the run's directory. It offers no filesystem or network isolation, so use it only for trusted workflows. Use a folder target to select an existing source directory on the Fabro host. A version-backed run intent can submit `{ "kind": "folder", "path": "/absolute/server/path" }` to start from an existing server directory. Fabro accepts this target only with a Local environment, resolves symlinks and `..`, requires an existing directory, and persists the canonical UTF-8 path. The target path takes precedence over the environment's `cwd`. Because the Local provider has unrestricted host access, use folder targets only in trusted single-tenant deployments. Docker and Daytona always reject folder targets. Local environments do not support the `none` target. Local folder runs work in a clone of the folder's committed `HEAD` (see [Checkpoints](/execution/checkpoints)): retries retain the canonical folder target, but fork and rewind are unavailable for these runs. The folder target identifies the source repository, while commands run in the managed workspace under the run's directory. Setting an environment's `cwd` does not make a folder-target run execute in place. Fabro hard-errors if a local environment asks for blocked or CIDR-restricted networking because the provider cannot enforce it. ## Docker Docker runs tools inside a container created from `image.docker`. Docker is the built-in default provider. ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.ci] provider = "docker" [environments.ci.image] docker = "buildpack-deps:noble" [environments.ci.resources] cpu = 2 memory = "4GB" [environments.ci.network] mode = "block" ``` Every provider except `local` is clone-based, including Docker, Daytona, and sandbox-driver plugins. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands. The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready. ## Daytona Daytona runs tools in a cloud sandbox. Set either `image.docker` to use an existing Docker image or `image.dockerfile` to build a custom image. Fabro computes a deterministic internal snapshot name from the selected image source, resource hints, a single-tenant scope, and the Daytona API key. If neither field is set, Fabro uses Daytona's built-in `daytona-medium` snapshot. ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.cloud] provider = "daytona" [environments.cloud.image] docker = "python:3.11-slim" [environments.cloud.resources] cpu = 4 memory = "8GB" disk = "20GB" [environments.cloud.lifecycle] auto_stop = "30m" [environments.cloud.network] mode = "cidr_allow_list" allow = ["208.80.154.232/32", "10.0.0.0/8"] ``` # Failures Source: https://docs.fabro.sh/execution/failures How Fabro classifies, retries, and recovers from failures during workflow execution Failures are inevitable when orchestrating LLM-powered workflows — models hit rate limits, agents produce bad output, commands fail, and providers go down. Fabro handles this with multiple layers of defense: automatic retries with backoff, provider failover, failure classification for intelligent routing, circuit breakers to prevent infinite loops, and goal gates to catch quality problems before a run completes. ## Failure classification When a node fails, Fabro classifies the failure into one of six categories. These classes drive retry decisions, circuit breaker logic, and edge routing. | Class | Description | Examples | | - | - | - | | `transient_infra` | Temporary infrastructure problem — likely to resolve on retry | Rate limits, timeouts, network errors, 5xx responses | | `deterministic` | Permanent failure — retrying won't help | Authentication errors, bad configuration, invalid requests | | `budget_exhausted` | Resource limit reached | Context length exceeded, token/turn limits, quota exhausted | | `compilation_loop` | Reserved for loop detection | — | | `canceled` | User or system cancellation | Cancel signal, abort | | `structural` | Reserved for scope enforcement | Write scope violations | Classification happens automatically. Fabro inspects SDK error types, HTTP status codes, and error message patterns to assign the right class. The `failure_class` is written to [context](/execution/context) after each stage, so you can route on it in edge conditions: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement -> fix [condition="failure_class=transient_infra"] implement -> escalate [condition="failure_class=deterministic"] ``` ## Human gates fail closed when unanswered Human approval gates are special: lack of an answer is treated as a failure, not as an implicit approval. If a prompt ends because stdin is closed, the user cancels it, a web session disconnects, or a test/replay interviewer has no answer available, the human stage returns a failure outcome instead of selecting an unconditional branch. That means an unanswered gate will only continue if you model that path explicitly, for example: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve release?"] approve -> ship [label="[A] Approve"] approve -> manual_review [condition="outcome=failed"] ``` If no `outcome=failed` edge or `retry_target` exists, the run stops rather than advancing past the approval gate. ## Stop linear workflows on failure By default, Fabro uses `on_failure="route"`. A failed node can take an unconditional edge when no explicit route matches. This compatibility default lets existing workflows decide how later nodes handle the failure. Set graph-level `on_failure="exit"` to stop a linear workflow at a failed node: ```dot title="stop-on-failure.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Build { graph [on_failure="exit"] start [shape=Mdiamond] exit [shape=Msquare] plan [prompt="Plan the work"] implement [prompt="Implement the plan"] verify [prompt="Verify the implementation"] start -> plan -> implement -> verify -> exit } ``` Fabro still uses an explicit recovery edge, such as `condition="outcome=failed"`, before it applies this policy. Matching preferred labels and suggested next node IDs also remain explicit routes. If no explicit edge matches, `exit` skips the unconditional edge and checks retry targets. The run ends as failed only when no retry target exists. Set `on_failure` on a node to control that node alone. The node-level attribute overrides the graph level, in both directions: a node can opt out of a graph-level `exit` with `on_failure="route"`, or stop the run on its own failure with `on_failure="exit"` while the rest of the graph keeps the default. A node without the attribute inherits the graph policy. See [Failed-node routing policy](/workflows/transitions#failed-node-routing-policy). The policy applies only to `failed`. Other outcomes keep their normal routing behavior. For parallel nodes, the policy uses the completed parallel node's final outcome. It does not stop or cancel individual branches early. ## Treat a failed node as succeeded Set `on_failure="succeed"` on a best-effort node so its failure never blocks the workflow. This pairs well with a strict graph default: ```dot title="best-effort-node.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Review { graph [on_failure="exit"] start [shape=Mdiamond] exit [shape=Msquare] required_check [script="./required-check"] optional_scan [script="./optional-scan" on_failure="succeed"] start -> required_check -> optional_scan -> exit } ``` When `optional_scan` fails, Fabro first checks explicit recovery routes with the `failed` outcome. If none match, it rewrites the outcome to `succeeded` and routes the node as a success. Retries still run first; only the final outcome changes. The original failure stays on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion. A promoted outcome satisfies a goal gate. Setting `on_failure="succeed"` on the graph applies it to every node. `succeed` applies only to `failed`. It does not change a `partially_succeeded` outcome. `auto_status=true` is the deprecated spelling of this policy; validation warns and suggests `on_failure="succeed"`. ## Retry layers Fabro retries failures at three levels: **LLM retries** handle transient API errors inside a single model call, **turn-level retries** recover from dropped streams mid-response, and **node retries** re-execute the entire node handler when the first two levels aren't enough. These layers are independent — a node retry re-runs the full handler, which gets its own fresh set of LLM and turn-level retries. ### LLM retries Every LLM call (within an agent session or a one-shot prompt node) has a built-in retry loop for transient API errors. This is invisible to the workflow — it happens inside the model call itself. | Setting | Default | | - | - | | Max retries | 3 | | Initial delay | 1 second | | Backoff multiplier | 2x | | Max delay | 60 seconds | | Jitter | 0.5x–1.5x random factor | Only transient errors are retried: rate limits, server errors (5xx), timeouts, network failures, and stream interruptions. Permanent errors like authentication failures or invalid requests fail immediately. If the provider returns a `Retry-After` header, Fabro respects it — unless the delay exceeds 60 seconds, in which case the call fails rather than blocking the run. ### Turn-level retries When an LLM stream drops mid-response (common under high concurrency), Fabro retries the same agent turn up to 3 times instead of failing the entire session. Conversation history is preserved across retries, and any partial assistant output from the interrupted stream is replayed so the model can continue where it left off. This avoids restarting the full stage from scratch for transient stream failures. ### Node retries When a node handler fails (after LLM and turn-level retries are exhausted), the engine can retry the entire node. This is controlled by **retry policies**. #### Retry policies Set a retry policy on a node with the `retry_policy` attribute: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [retry_policy="standard"] ``` | Policy | Max attempts | Initial delay | Backoff | Typical delays | | - | - | - | - | - | | `none` | 1 | — | — | No retries | | `standard` | 5 | 200ms | 2x exponential | 200ms, 400ms, 800ms, 1.6s | | `aggressive` | 5 | 500ms | 2x exponential | 500ms, 1s, 2s, 4s | | `linear` | 3 | 500ms | 1x (constant) | 500ms, 500ms | | `patient` | 3 | 2s | 3x exponential | 2s, 6s | All policies apply random jitter (0.5x–1.5x) and cap individual delays at 60 seconds. #### Setting retries without a policy You can also set just the retry count using `max_retries`: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [max_retries="5"] ``` This uses the default backoff (5s initial, 2x exponential) with the specified number of retries. #### Resolution order The engine resolves retry configuration in this order: 1. Node attribute `retry_policy` — named preset 2. Node attribute `max_retries` — count only, default backoff 3. Graph attribute `default_max_retries` — applies to all nodes without explicit config (default: **3**) #### What gets retried Not all errors trigger a node retry. The handler's `should_retry` check must return true — generally, only errors classified as transient are retried. Deterministic errors (auth failures, bad config) fail immediately without consuming retry attempts. When a handler reports a retryable failure, retries always proceed if attempts remain. If retries are exhausted and the node has `allow_partial=true`, the outcome is promoted to `partially_succeeded` instead of `failed`. See [Node Outcomes — Retry loop](/execution/outcomes#retry-loop) for a detailed flow diagram. ## Model fallbacks When a model provider fails with a provider-local error, Fabro can automatically switch to another target. Configure one fixed chain for each requested model in your [run configuration](/execution/run-configuration): ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.model] name = "claude-opus-4-6" provider = "anthropic" [run.model.fallbacks] "claude-opus-4-6" = ["gemini", "openai"] ``` When Anthropic fails, Fabro tries Gemini first, then OpenAI. Fallback resolution is provider-aware: * A bare provider token such as `"gemini"` selects that provider's closest compatible model. * A qualified selector such as `"openrouter:gpt-56-sol"` resolves only within that provider. The selector may be a canonical model ID, alias, or provider API ID such as `"openrouter:moonshotai/kimi-k3"`. * A bare model slug or alias considers ready providers and uses provider priority. Qualified fallback references always remain provider pins. For example, `"openai:gpt-5.6-sol"` pins the direct OpenAI offering. Legacy `provider/model` fallback references remain accepted for compatibility. Fabro selects the chain by the original requested model. A target in that chain never activates the target model's own chain. The same chain position is retained across structured-output repairs and cached agent sessions. The primary provider and model were already resolved and persisted when the run was created; resuming does not re-run primary selection. Fallbacks are only considered after an eligible runtime failure. If the fallback model does not support the requested reasoning level, Fabro uses the nearest supported level and rounds equal-distance choices up. ### What triggers failover Failover is a superset of LLM retry eligibility: | Error type | LLM retry | Provider failover | | - | - | - | | Rate limit | Yes | Yes | | Server error (5xx) | Yes | Yes | | Timeout / network | Yes | Yes | | Quota exceeded | No | Yes | | Authentication (401) | No | Yes | | Access denied (403) | No | Yes | | Model not found (404) | No | Yes | | Model refusal | No | Yes | | Invalid request (400) | No | No | | Context length (413) | No | No | | Content filter | No | No | Quota errors are the key distinction — they aren't retried against the same provider (the quota won't reset) but *are* eligible for failover to a provider with its own quota. ## Loop detection Fabro has two independent mechanisms for detecting stuck loops: **node visit limits** that catch workflow-level cycles, and **tool call pattern detection** that catches agent-level repetition. ### Node visit limits The `max_node_visits` graph attribute sets the maximum number of times any single node can execute before the run is terminated: ```dot title="example.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [max_node_visits="20"] // ... } ``` | Context | Default | | - | - | | Normal runs | Disabled (unlimited) | | Dry runs (`--dry-run`) | 10 | | Explicit `max_node_visits` | The configured value | When a node hits the limit, the run fails immediately: ``` node "verify" visited 20 times (graph limit 20); run is stuck in a cycle ``` #### Per-node overrides You can set `max_visits` on individual nodes to override the graph-level limit for that node: ```dot title="example.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [max_node_visits="20"] fix [max_visits=3] } ``` The per-node `max_visits` takes precedence over `max_node_visits` (and the dry-run default of 10). This is useful when specific nodes — like a fix-and-verify loop — should have a tighter limit than the rest of the workflow: ``` node "fix" visited 3 times (node limit 3); run is stuck in a cycle ``` ### Tool call loop detection Inside an agent session, Fabro monitors the last 10 assistant turns for repeating tool call patterns. It detects patterns of length 1 (same call repeated), 2 (A-B-A-B), or 3 (A-B-C-A-B-C). Every complete group in the window must match for detection to trigger. When a loop is detected, Fabro injects a steering message into the conversation: > WARNING: Loop detected. You appear to be repeating the same tool calls. Please try a different approach or ask for clarification. This gives the agent a chance to break out of the loop without failing the node. ## Failure signatures and circuit breakers Failure signatures are a deduplication mechanism that prevents the same failure from recurring indefinitely across loop iterations. They are particularly important for workflows with retry loops (implement → verify → fix → verify → ...). ### How signatures work After each failed node, Fabro constructs a **failure signature** — a normalized fingerprint combining the node ID, failure class, and error message: ``` implement|deterministic|handler panicked: index out of bounds ``` The error message is normalized by lowercasing, replacing hex strings with ``, replacing digits with ``, and truncating to 240 characters. This groups failures with the same root cause even when details like line numbers or timestamps vary. ### Circuit breaker Fabro tracks signature counts across the run. When the same signature repeats **3 times** (configurable via `loop_restart_signature_limit`), the run is terminated: ``` deterministic failure cycle detected: signature ... repeated 3 times (limit 3) ``` Only `deterministic` and `structural` failures are tracked — transient failures are excluded because they may genuinely resolve on retry. Failure signature counts are never reset on success. This is intentional — it prevents cycles like "implement succeeds → verify fails → fix → implement succeeds → verify fails" from running indefinitely. ### Loop restart edges Taking an edge marked with `loop_restart=true` restarts the workflow from the edge's target node. A restart is more than a jump: the completed-stage history, per-node outcomes, and retry counts are cleared, and the run context is replaced with a **fresh, empty context** — the target node starts over as if the run had just begun there, with no preamble of prior stages. Node visit counts are the one thing preserved, so `max_visits` and `max_node_visits` still bound how many times a restart loop can run. A **successful** outcome may take a `loop_restart` edge freely. This is the "start another round from a clean slate" pattern — for example, a self-loop that begins a fresh batch of work and re-derives its remaining work from the repository state rather than from accumulated context. A **failed** outcome faces an additional guard: only `transient_infra` failures may cross a `loop_restart` edge. If the failure class is anything else, the run is terminated: ``` loop_restart blocked: failure_class=deterministic (requires transient_infra) ``` Loop restart edges also have their own separate circuit breaker (`restart_failure_signatures`) that enforces the same signature limit. ## Goal gates Goal gates are quality checkpoints that are enforced when the workflow reaches an exit node. A node marked with `goal_gate=true` must have completed with `succeeded` or `partially_succeeded` — otherwise the run cannot finish. See [Node Outcomes — Goal gate interaction](/execution/outcomes#goal-gate-interaction) for how `partially_succeeded` and `allow_partial` interact with goal gates. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} verify [shape=box, goal_gate="true"] ``` When a goal gate is unsatisfied at the exit node, Fabro looks for a **retry target** — a node to jump back to for another attempt: 1. Failed node's `retry_target` attribute 2. Failed node's `fallback_retry_target` attribute 3. Graph-level `retry_target` attribute 4. Graph-level `fallback_retry_target` attribute ```dot title="example.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [retry_target="plan"] verify [shape=box, goal_gate="true", retry_target="implement"] // If verify fails, jump to implement // If no node-level target existed, would jump to plan } ``` If no retry target is found at any level, the run fails: ``` goal gate unsatisfied for node verify and no retry target ``` ## Stall watchdog Fabro runs a background watchdog that monitors event activity. If no events are emitted for longer than the **stall timeout**, the run is canceled. This catches cases where a handler hangs indefinitely without producing errors. | Setting | Default | | - | - | | `stall_timeout` | 1800 seconds (30 minutes) | | Set to `0` | Disables the watchdog | ```dot title="example.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [stall_timeout="300"] // 5 minutes } ``` ## When failures become fatal A node failure does **not** automatically terminate the run. Fabro follows this escalation path: 1. **LLM retries** — transient API errors are retried inside the model call (up to 3 retries) 2. **Turn-level retries** — dropped streams retry the same agent turn (up to 3 retries), preserving conversation history 3. **Provider failover** — if configured, switch to a fallback provider 4. **Node retries** — re-execute the entire handler (per the retry policy) 5. **Direct jump** — use `jump_to_node` when the outcome supplies one 6. **Explicit edge routing** — look for a matching condition, preferred label, or suggested next node 7. **Failure policy** — with no explicit route, apply the effective `on_failure` (node-level `on_failure` first, then graph-level): `exit` skips the unconditional edge, `succeed` promotes the outcome to `succeeded` and routes it as a success, and `route` (or no attribute) keeps normal fallback routing 8. **Unconditional edge** — in `route` mode, or after a `succeed` promotion, use an edge without a condition as the fallback 9. **Retry target** — if no edge was selected, check `retry_target` and `fallback_retry_target` on the node, then on the graph 10. **Run failure** — if none of the above produces a path forward, the run terminates When a retry target sends the run back to a failing path, use graph-level `max_node_visits` or node-level `max_visits` to stop an unbounded cycle. The run also terminates immediately for: * **Node visit limit exceeded** — a node has been visited too many times * **Circuit breaker tripped** — the same failure signature has repeated too many times * **Loop restart blocked** — a non-transient failure tried to cross a `loop_restart` edge * **Goal gate failure with no retry target** — a required gate was unsatisfied at the exit node * **Stall timeout** — no events for too long * **Cancellation** — user or system cancel signal # Observability Source: https://docs.fabro.sh/execution/observability How to monitor, inspect, and analyze workflow runs Fabro captures a structured event for every significant action during a workflow run. These events cover stage execution, agent tool calls, retries, routing, sandbox lifecycle, git checkpoints, and more. ## Event stream Every workflow run emits a sequence of canonical **run event envelopes** that are: * Stored durably in the run store * Broadcast over SSE to connected API clients * Stored for later analysis * Rendered by CLI progress and log tooling * Optionally materialized into JSONL by export/debug paths ### Event names Event names use lowercase dot notation, for example: * `run.started` * `stage.started` * `stage.completed` * `agent.tool.started` * `agent.tool.completed` * `sandbox.ready` * `todo.updated` * `parallel.branch.completed` ### Envelope format Each serialized event envelope has a stable JSON shape: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "id": "01960d0c-5d16-7d6e-8f61-9fd6f4a532b5", "ts": "2026-03-30T12:00:01.000Z", "run_id": "01JQ...", "event": "agent.tool.started", "session_id": "ses_child", "parent_session_id": "ses_parent", "node_id": "implement", "node_label": "Implement", "properties": { "tool_name": "shell", "tool_call_id": "call_1", "arguments": {"command": "cargo test"} } } ``` Envelope fields: | Field | Description | | - | - | | `id` | Unique event id | | `ts` | UTC timestamp | | `run_id` | Workflow run id | | `event` | Event name | | `session_id` | Session that emitted the event, when applicable | | `parent_session_id` | Immediate parent session for forwarded child events | | `node_id` | Node or branch id, when applicable | | `node_label` | Human-facing label for `node_id`, when applicable | | `properties` | Event-specific payload | Only `id`, `ts`, `run_id`, and `event` are always present. Optional fields are omitted when they do not apply. For runtime `for_each` branches, `parallel.branch.started` and `parallel.branch.completed` include the zero-based `index` and an optional `item_label`. They do not include the raw item. The final prompt is recorded by the existing `stage.prompt` event, including the fenced item data, so event streams, run dumps, and retained logs are source-bearing data. Apply the same access controls and retention policy you use for workflow inputs. ## Reading the event stream Because event payload lives in `properties`, most shell queries should look there. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} # Count tool calls in a run fabro events 01JKXYZ... | jq -r 'select(.event == "agent.tool.started") | .properties.tool_name' | wc -l # Find stage failures fabro events 01JKXYZ... | jq 'select(.event == "stage.failed")' # See which edges were taken jq '{from: .properties.from_node, to: .properties.to_node, label: .properties.label}' \ <(fabro events 01JKXYZ...) | head ``` If you need files on disk for offline analysis, `fabro dump` exports `events.jsonl` plus run-state projections. ## Event categories Common categories include: | Category | Example events | | - | - | | Run lifecycle | `run.started`, `run.completed`, `run.failed`, `run.notice` | | Stage lifecycle | `stage.started`, `stage.completed`, `stage.failed`, `stage.retrying` | | Agent activity | `agent.message`, `agent.tool.started`, `agent.warning`, `agent.sub.spawned` | | Agent todo state | `todo.created`, `todo.updated`, `todo.deleted` | | Routing | `edge.selected`, `loop.restart`, `parallel.started` | | Git and checkpoints | `checkpoint.completed`, `git.commit`, `git.push` | | Setup and sandbox | `sandbox.initializing`, `sandbox.ready`, `setup.started` | `agent.message` is the canonical post-response event for agent turns. When context-window data is available, it appears on the message payload as `context_window`; there is no separate context-window snapshot event to consume. ## Sub-agent visibility Sub-agent activity now appears as normal agent events with session linkage: * `session_id` identifies the child session * `parent_session_id` identifies its immediate parent Lifecycle events such as `agent.sub.spawned` and `agent.sub.completed` are emitted by the parent session. Tool calls and other child activity are forwarded with their original `session_id`. ## Real-time monitoring ### API: Server-Sent Events When running workflows through the API server, subscribe to the [run events endpoint](/api-reference/runs/stream-run-events). Each SSE payload is a serialized run event envelope in the same shape used by `fabro events` and `events.jsonl` exports. ### Web UI The web frontend consumes the SSE stream automatically and shows stage progress, tool calls, command output, and human interaction as they happen. Use the stage `Chat` view for a readable conversation, `Thread` for the detailed agent transcript and disclosed provider reasoning, and `Debug` for raw stage events. Use the run-level `Run Events` page when you need the full event stream with search and category filters. The `Run Events` page also includes a Waterfall view for comparing stage durations and inspecting timing details from hover popovers. Fabro web UI run stages showing agent conversation with tool calls ### CLI progress The CLI renders live progress from the same envelope format. This is written to stderr so stdout remains pipe-friendly. ## Post-run analysis Post-run analysis surfaces include: | Surface | Description | | - | - | | `fabro events ` | Full event envelope stream as NDJSON | | `fabro logs ` | Raw per-run worker tracing log, when available | | `fabro inspect ` | Current durable run state, including run/start/checkpoint/conclusion records | | `fabro dump --output ` | Exported `events.jsonl` plus reconstructed JSON and node files | See [stages](/api-reference/run-internals/list-run-stages) and [turns](/api-reference/run-internals/list-stage-turns) for higher-level analysis views built on top of this event stream. # Node Outcomes Source: https://docs.fabro.sh/execution/outcomes The four stage outcomes and the attributes that control them Every node execution produces an **outcome** that drives edge routing, retry logic, and goal gate checks. This page defines the four externally visible outcomes and the attributes that influence them. ## The four outcomes | Outcome | Meaning | | - | - | | `succeeded` | The handler completed normally | | `failed` | The handler encountered an unrecoverable error | | `partially_succeeded` | The handler did not fully succeed but produced usable results — typically from retries exhausted with `allow_partial=true` | | `skipped` | The node was not executed (e.g. a branch not taken in a parallel fan-out) | Retry intent is internal to the engine. It triggers re-execution inside the retry loop and is never visible in edge `condition` expressions. Retryable failures emit `stage.retrying` events while the node is still active, then finish as one of the four outcomes above. ## How handlers produce outcomes Each node type has its own rules for which outcomes it can return: | Handler | Produces | Conditions | | - | - | - | | **Command** | `succeeded`, `failed` | `succeeded` when exit code is 0; `failed` otherwise | | **Agent / Prompt** | `succeeded`, `failed`, `partially_succeeded`, `skipped` | Defaults to `succeeded`. The LLM can set any outcome via a [routing directive](/agents/outputs#routing-directives) JSON object in its response. Backend errors request retry when retryable or finish as `failed`. | | **Parallel** | `succeeded`, `partially_succeeded`, `failed` | Waits for every branch. `succeeded` when all branches succeed, `failed` when all branches fail, and `partially_succeeded` for mixed or partial results. A static fan-out with no branches remains partial; a valid `for_each` source with zero items succeeds. | | **Human** | `succeeded` | Always succeeds — the user's selection becomes a routing signal via `preferred_label` | | **Conditional** | `succeeded` | Always succeeds — routing is handled by the engine's edge selection | | **Start / Exit / Wait** | `succeeded` | Always succeed | ## Retry loop When a handler returns a retryable failure, the engine enters the retry loop. If retry attempts remain (per the node's [retry policy](/execution/failures#retry-policies)), the handler re-executes after a backoff delay. If attempts are exhausted, the final outcome depends on `allow_partial`: ``` ┌─────────────┐ │ Run handler │ └──────┬──────┘ │ ▼ ┌──────────┐ succeeded / failed / │Outcome? │─── partially_succeeded / skipped ──▶ Done (use as-is) └────┬─────┘ │ retryable failure ▼ ┌──────────────┐ yes ┌──────────────┐ │ Attempts │─────────▶│ Backoff + │──┐ │ remain? │ │ re-execute │ │ └──────┬───────┘ └──────────────┘ │ │ no │ ▼ ┌──────────┘ ┌──────────────┐ │ │allow_partial?│ │ (loops back to └──────┬───┬───┘ │ "Run handler") yes │ │ no │ ▼ ▼ partially_succeeded failed ``` Handler errors follow the same loop: retryable errors (transient infrastructure) re-execute if attempts remain; non-retryable errors (authentication, bad config) fail immediately without consuming retry attempts. ## `allow_partial` When `allow_partial=true` and the retry loop exhausts all attempts on a retryable failure, the outcome is promoted to `partially_succeeded` instead of `failed`. This lets the workflow continue past nodes that could not fully succeed. | Attribute | Type | Default | | - | - | - | | `allow_partial` | Boolean | `false` | ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [ label="Implement", retry_policy="standard", allow_partial=true, prompt="Implement the feature." ] ``` In this example, if the agent returns a retryable failure and all 5 standard-policy attempts are used, the node finishes with `partially_succeeded` rather than failing the run. See [Retry policies](/execution/failures#retry-policies) for the available presets and backoff settings. ## Succeed on failure When a node's effective `on_failure` policy is `succeed`, a `failed` outcome with no explicit recovery route is promoted to `succeeded`. This is applied after the retry loop, so retries still happen normally — only the final outcome changes. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record the promotion. | Attribute | Type | Default | | - | - | - | | `on_failure` | String | inherits the graph-level `on_failure` (default `route`) | ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} scan [ label="Scan", shape=parallelogram, on_failure="succeed", script="find . -name '*.log' | head -20" ] ``` Use `on_failure="succeed"` for nodes whose failure should never block the workflow — optional scans, best-effort cleanup steps, or informational commands where the output matters more than the exit code. An explicit `condition="outcome=failed"` edge still takes priority; the promotion applies only when no explicit route matches. The policy applies only to `failed` and leaves `partially_succeeded` unchanged. See [Failed-node routing policy](/workflows/transitions#failed-node-routing-policy) for the full set of policies. `auto_status=true` is the deprecated spelling of `on_failure="succeed"`. Fabro still accepts it as an alias, and validation reports an `auto_status_deprecated` warning with the replacement. Unlike the old attribute, the alias no longer promotes `partially_succeeded` outcomes. ## Goal gate interaction Node outcomes and workflow status are separate. A node can finish with `failed` and the workflow can still reach `exit` and complete successfully if routing handles that outcome. Mark a node with `goal_gate=true` when its failure must make the workflow fail. Nodes marked with `goal_gate=true` are checked when the workflow reaches the exit node. A goal gate is satisfied if its last outcome was `succeeded` **or** `partially_succeeded`. Any other outcome (`failed`, `skipped`) causes the workflow to fail, even though execution reached the exit. This means `allow_partial=true` on a goal gate node lets the gate pass even if the node exhausted its retries — the promoted `partially_succeeded` outcome counts as passing. Likewise, a `succeeded` outcome promoted by `on_failure="succeed"` satisfies the gate. See [Goal gates](/execution/failures#goal-gates) for retry target resolution and failure behavior. ## Outcome in edge conditions The four externally-visible statuses can be used in edge `condition` expressions via the `outcome` key: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate -> deploy [label="Pass", condition="outcome=succeeded"] gate -> fix [label="Fix", condition="outcome=failed"] gate -> review [label="Partial", condition="outcome=partially_succeeded"] gate -> skip [label="Skipped", condition="outcome=skipped"] // Common pattern: treat partial as passing gate -> deploy [condition="outcome=succeeded || outcome=partially_succeeded"] gate -> fix [condition="outcome=failed"] ``` See [Transitions](/workflows/transitions) for the full edge selection logic and operator reference. # Run Configuration Source: https://docs.fabro.sh/execution/run-configuration Configure workflow runs with TOML files A workflow config is a TOML file that bundles a workflow graph with its execution behavior — the goal, model, prepare steps, inputs, hooks, and other workflow-owned settings. Instead of passing a dozen CLI flags, check `workflow.toml` into version control and launch it with a single command: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run run.toml ``` `fabro run` and `fabro create` resolve and package the workflow locally, register its immutable workflow version and dependencies, and then ask the server to admit the intent and create a run from that version. Source parsing or packaging failures stop locally before registration; full effective-intent validation is authoritative at server admission. Use `fabro preflight` for explicit local validation without creating a run. `fabro create` stops with the run in the submitted state; `fabro run` performs the same create operation and then starts the run separately. The workflow can be selected by name from the current project or user workflow storage, by a path in another local checkout, or as a loose local file. Its source location does not choose the execution workspace: the directory where you invoke Fabro remains the target source. Clone-based environments derive a GitHub target from that caller directory, while a local environment receives the canonical caller directory directly. Fetching workflow definitions from a remote Git URL is not part of these commands. ## Minimal example A run config needs at minimum a schema version and a goal: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [workflow] graph = "workflow.fabro" [run] goal = "Implement the login feature" ``` | Field | Required | Description | | - | - | - | | `_version` | No (defaults to `1`) | Schema version. Must be `1` in the first pass. | | `[workflow].graph` | No | Path to the Graphviz workflow file, relative to the TOML file's directory. Defaults to `workflow.fabro`. | | `[run].goal` | No | What the workflow should accomplish. Passed to agents and available via `--goal` CLI flag or Graphviz graph `goal` attribute. | Goal precedence: CLI `--goal` or `--goal-file` > `[run].goal` > Graphviz graph attribute. A CLI `--goal-file` is read on the invoking machine and sent as a per-run value; a goal file referenced by `workflow.toml` remains immutable workflow content. ## Full example ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [workflow] graph = ".fabro/workflows/ci.fabro" [run] goal = "Run the CI pipeline" working_dir = "/tmp/workdir" [run.model] name = "claude-sonnet-4-5" [run.model.controls] reasoning_effort = "high" [run.model.fallbacks] "claude-sonnet-4-5" = ["openai", "gemini"] [[run.prepare.steps]] script = "git clone https://github.com/fabro-sh/fabro repo" [[run.prepare.steps]] script = "cd repo && npm install" [run.integrations.github.permissions] contents = "write" pull_requests = "write" [run.notifications.deploys] enabled = true provider = "slack" events = ["run.started", "run.completed", "run.failed"] [run.notifications.deploys.slack] channel = "#deploys" [run.checkpoint] exclude_globs = ["**/node_modules/**", "**/.cache/**"] [run.inputs] repo_name = "fabro" repo_url = "https://github.com/fabro-sh/fabro" [run.artifacts] include = ["test-results/**", "playwright-report/**", "**/*.trace.zip"] [run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"] port = 3100 [run.pull_request] enabled = true draft = false [[run.hooks]] id = "pre-check" event = "stage_start" script = "./scripts/pre-check.sh" blocking = true sandbox = false [[run.hooks]] event = "run_complete" script = "echo done" ``` ## Sections ### `[run.model]` Override the default model and provider for all nodes that don't have an explicit model assigned via a [stylesheet](/workflows/stylesheets). ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.model] name = "claude-sonnet-4-5" ``` | Field | Description | | - | - | | `name` | Canonical model slug or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). | | `provider` | Optional provider pin. When omitted, Fabro selects among ready offerings by provider priority. When present, an unavailable provider is an error rather than permission to switch. | | `fallbacks` | Table of ordered fallback lists keyed by the originally requested model. | Provider values are catalog provider ID strings. Built-in IDs like `anthropic` and `openai` work, and settings-defined IDs like `proxy` work after they are added under `[llm.providers.]`. For a qualified fallback, the selector may be that provider's canonical model ID, alias, or API ID. Fabro splits on the first `:` when the part before it names a known provider, so provider API IDs may contain `/` or additional colons: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.model.fallbacks] "kimi-k3" = [ "moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus", ] ``` This chain applies only when the original request resolves to `kimi-k3`. It tries direct Moonshot AI, then OpenRouter, then Claude Opus. The OpenRouter entry could equivalently be written as `"openrouter:moonshotai/kimi-k3"` using its API ID. Legacy `provider/model` fallback references remain accepted but are normalized to `provider:model`. A colon alone does not make a reference qualified. Many model IDs contain one — ollama `name:tag` values, Bedrock inference-profile IDs and ARNs — so Fabro treats the reference as qualified only when the text before the first `:` names a known provider. `"llama3:8b"` stays a single model ID, while `"ollama:llama3:8b"` pins the `ollama` provider and passes `llama3:8b` as the selector. At run creation, Fabro resolves the primary selector, every node selector, and the fallback table against the server's ready-provider snapshot. It persists the selected canonical model slug and provider, so resuming the run does not choose a different provider just because credentials or priorities changed. `fabro validate` only checks the table's TOML shape because it is offline and has no server model catalog. Use `fabro preflight` for catalog and provider checks. Each original requested model selects one fixed chain. Fabro does not jump to the chain configured for a fallback target. When a target lacks the requested reasoning level, Fabro uses the nearest supported level. It rounds equal-distance choices up. For example, a server that selects Modal as the primary `kimi-k3` offering can define these independent chains: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.model.fallbacks] "kimi-k3" = ["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"] "glm-5.2" = ["gpt-sol"] "gpt-sol" = ["claude-opus"] "claude-opus" = ["gpt-sol"] "gpt-terra" = ["claude-opus"] "gpt-luna" = ["claude-sonnet"] "claude-fable" = ["gpt-sol", "claude-opus"] ``` If `claude-fable` falls back to `gpt-sol`, Fabro continues with `claude-opus` from the Fable list. It does not restart from the separate `gpt-sol` list. Historical built-in provider API IDs are accepted for compatibility and normalize before this selection. For example, `name = "openai/gpt-5.6-sol"` is treated as the canonical `gpt-5.6-sol` selector; omit `provider` to use readiness and priority, or set `provider` separately to pin an offering. #### `[run.model.controls]` Set default model controls for all nodes that do not override them in the workflow stylesheet: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.model] provider = "proxy" name = "team-code" [run.model.controls] reasoning_effort = "high" speed = "fast" ``` | Field | Description | | - | - | | `reasoning_effort` | Native reasoning-effort value to request when the selected model allows it, such as `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, or `"max"`. | | `speed` | Native speed value to request when the selected model declares it, such as `"fast"`. The standard speed is implicit and does not need to be set. | #### Fallback lists with splice Use the reserved `"..."` marker in one model's list to splice in that model's inherited list from lower-precedence layers: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.model.fallbacks] # Prepend Anthropic to the inherited chain for gpt-5.6-sol. "gpt-5.6-sol" = ["anthropic", "..."] ``` ### `[run.prepare]` Ordered list of steps to run before the workflow starts. Use this to clone repositories, install dependencies, or prepare the environment. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[run.prepare.steps]] script = "pip install -r requirements.txt" [[run.prepare.steps]] command = ["npm", "install"] env = { NPM_TOKEN = "{{ secrets.NPM_TOKEN }}" } ``` | Field | Description | | - | - | | `script` | Bash source, evaluated by the sandbox's non-login Bash (`bash -c`). Supports `{{ vars.* }}` and `{{ secrets.* }}` interpolation. | | `command` | Argv-style command, mutually exclusive with `script`. Each resolved element is shell-quoted as one argument. | | `env` | Additional environment variables for this step. Values support the same interpolation as `script` and `command`. | Each step must exit with status 0. If any step fails, the run aborts before the workflow starts. Prepare steps replace across layers — the higher-precedence layer wins wholesale. Fabro substitutes `{{ vars.* }}` when the server creates the run, then resolves `{{ secrets.* }}` from token entries in the server vault immediately before the worker executes the steps. Secret expressions remain in the persisted run definition; resolved secret values are not persisted. A missing or non-token secret aborts startup with the affected step and token named in the error. ### `[run.clone]` Configure whether the run's repository is cloned into its workspace before execution: the GitHub origin for Docker and Daytona, or the target folder's repository for Local. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.clone] enabled = true depth = 100 ``` Set `enabled = false` to start runs with an empty workspace. Use [prepare steps](#runprepare) to clone or create any files the workflow needs. | Field | Description | | - | - | | `enabled` | When `false`, Fabro skips the repository clone. Defaults to `true`. | | `depth` | Git history depth. Defaults to `100`. Set it to `0` to clone full history. | ### `[run.run_branch]` Configure Fabro's managed `fabro/run/` checkpoint branch. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.run_branch] enabled = true push = true ``` | Field | Description | | - | - | | `enabled` | When `false`, Fabro does not create the managed run branch or checkpoint commits. | | `push` | When `false`, Fabro creates local checkpoint commits but does not push `fabro/run/` to the remote. | ### `[run.meta_branch]` Metadata branches have been retired. Existing `enabled` and `push` settings in this table are accepted but ignored. Resolved settings and API responses omit `meta_branch`. You can remove the table from your configuration. Existing Git metadata branches remain untouched. ### `[run.environment]` and server-managed environments Runs select a reusable server-managed environment by slug. For `fabro run` and `fabro create`, use `--environment ` to select it; omitting the flag selects `default`. Configure the catalog on the server rather than relying on the CLI machine's `settings.toml` or the source checkout's `.fabro/project.toml`, because those `environments` tables are not transmitted during intent creation. ```toml title="server settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.ci] provider = "docker" # local | docker | daytona [environments.ci.image] docker = "buildpack-deps:noble" [environments.ci.resources] cpu = 2 memory = "4GB" [environments.ci.lifecycle] preserve = true stop_on_terminal = true [environments.ci.env] NODE_ENV = "production" ``` Workflow-owned sparse overrides can live under `[run.environment.*]` and apply to the selected server environment: ```toml theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment.resources] memory = "8GB" ``` | Field | Description | | - | - | | `run.environment.id` | Environment slug to select. Defaults to `default`. | | `environments..provider` | Required provider: `local`, `docker`, or `daytona`. | | `image.docker` | Docker image. Docker runs it directly; Daytona uses it to create or reuse an internally named snapshot. | | `image.dockerfile` | Inline Dockerfile or `{ path = "Dockerfile" }`; Daytona uses it to create or reuse an internally named snapshot. Do not set it with `image.docker`. | | `resources.cpu` / `memory` / `disk` | Best-effort resource hints. Unsupported provider fields warn and continue. | | `network.mode` | `allow_all`, `block`, or `cidr_allow_list`. Local cannot enforce blocked/CIDR networking; Docker cannot enforce CIDR allow-lists. | | `network.allow` | CIDRs for `cidr_allow_list`; entries are validated as CIDRs. | | `lifecycle.preserve` | Keep the created sandbox after the run finishes. | | `lifecycle.stop_on_terminal` | Stop the sandbox when the run reaches a terminal state. | | `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. Defaults to `"120m"`; `"0s"` disables auto-stop. | | `labels` | Provider labels. Merge by key across layers. | | `env` | Environment variables passed to command and agent execution. Merge by key across layers. | When `provider = "local"`, Fabro works in a clone of the target folder's committed `HEAD`, not in the folder itself, so uncommitted changes are not included. Environment variable values can combine literal text with server variables and token secrets: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.ci.env] API_KEY = "{{ secrets.SERVICE_API_KEY }}" NODE_ENV = "production" SERVICE_URL = "https://api.{{ vars.REGION }}.example.com" RELEASE_CHANNEL = "{{ vars.RELEASE_CHANNEL }}" ``` | Syntax | Description | | - | - | | `"literal"` | Static value passed as-is | | `"{{ vars.NAME }}"` | Server-managed variable substituted when the run is created | | `"{{ secrets.NAME }}"` | Token secret resolved from the server vault when the run starts | | `"prefix-{{ vars.X }}-suffix"` | Substring interpolation; multiple supported tokens per string are allowed | Missing or non-token secret references fail closed before sandbox startup. `{{ env.* }}` is not supported: the process environment is not a configuration source. Use `{{ vars.NAME }}` for a non-sensitive value or `{{ secrets.NAME }}` for a credential. ### `[run.integrations.github.permissions]` Request a scoped GitHub App token for workflow stages that need `GITHUB_TOKEN` inside the sandbox. Values map directly to GitHub App permission names and access levels. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.integrations.github.permissions] contents = "write" pull_requests = "write" issues = "read" ``` Only requested permissions are included. The upper bound is the permission set granted to the installed GitHub App, and Fabro logs a warning and continues without `GITHUB_TOKEN` if the app is not configured or is not installed on the repository. This table follows the workflow settings merge rules. A higher-precedence workflow or CLI override can set `permissions = {}` to clear inherited permissions and run without a GitHub token. ### `[run.integrations.github].additional_repositories` Declare extra GitHub repositories, beyond the implicit run origin, that the minted `GITHUB_TOKEN` must cover. The one `permissions` map applies to the origin and every declared repository. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.integrations.github] additional_repositories = ["fabro-sh/keystone"] permissions = { contents = "read" } ``` Each entry is a full `owner/repository` slug. Every repository in the effective set must share one owner and be reachable by the origin repository's GitHub App installation. A non-empty list requires `contents = "read"` or `contents = "write"`. Malformed slugs, case-insensitive duplicates, cross-owner sets, and sets larger than 499 entries fail configuration validation with indexed error paths such as `run.integrations.github.additional_repositories[1]`. Unlike permissions-only configuration, declared additional repositories are a hard requirement: missing credentials, a missing origin, or an inaccessible declared repository fails preflight and run initialization with the repository named. The higher-precedence list replaces the lower one wholesale — no union and no `...` splice — and `additional_repositories = []` explicitly clears an inherited list. `additional_repositories` and `permissions` resolve independently; if layering leaves repositories declared while permissions were cleared, resolution reports the invalid combination instead of dropping either field. See [Additional repositories](/integrations/github#additional-repositories) for what works inside stages (`gh`, GitHub API, plain Git over HTTPS and the common SSH spellings) and for the security boundary. ### `[run.notifications]` Define named notification routes for run events. Slack lifecycle notifications are configured here, not in server config. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.notifications.deploys] enabled = true provider = "slack" events = ["run.started", "run.completed", "run.failed"] [run.notifications.deploys.slack] channel = "#deploys" ``` | Field | Description | | - | - | | `enabled` | Enables this route. Defaults to `false`. | | `provider` | Notification provider. Use `"slack"` for Slack lifecycle notifications. Other provider names may be parsed but are not delivered by the server yet. | | `events` | Raw Fabro event names that trigger this route, such as `run.started`, `run.completed`, and `run.failed`. | | `[run.notifications..slack].channel` | Required for Slack lifecycle notifications. Literal channel names and `{{ vars.NAME }}` interpolation are supported. | Each enabled Slack route posts once for each matching lifecycle event. Messages include the run ID, an Open in Fabro link when available, workflow label, terminal result, duration, and pull request details when those are already present in the run event stream. `run.failed` is emitted only when the run terminally fails. A failed stage that routes onward to a normal completion path produces `run.completed`, not `run.failed`. If a Slack route's channel is missing, empty, or contains an unsupported interpolation token, Fabro logs a warning and skips that route. Delivery failures are logged and never fail or alter the run. ### `[run.checkpoint]` Configure how git checkpoint commits behave. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.checkpoint] exclude_globs = ["**/node_modules/**", "**/.cache/**", "**/dist/**"] skip_git_hooks = false commit_timeout = "30s" ``` | Field | Description | | - | - | | `exclude_globs` | Glob patterns for files to exclude from checkpoint commits. Uses git pathspec `:(glob,exclude)` syntax. | | `skip_git_hooks` | Accepted for compatibility. Fabro-managed run-branch checkpoint commits never run local Git commit hooks (e.g. `pre-commit`, `commit-msg`); the sandbox driver disables repository hooks on every git command it runs. Does not affect Fabro workflow `[[run.hooks]]`. | | `commit_timeout` | Accepted for compatibility. The per-node run-branch checkpoint commit runs under the sandbox driver's git command budget; no repository hook can prolong it. | `exclude_globs` replaces across layers — the higher-precedence layer wins wholesale. `skip_git_hooks` and `commit_timeout` use normal override semantics: the highest layer that sets the field wins. ### `[run.inputs]` Define inputs that are rendered into final workflow string attributes. See [Variables](/workflows/variables) for the full reference. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.inputs] repo_name = "fabro" repo_url = "https://github.com/fabro-sh/fabro" language = "rust" ``` Inputs can be used in graph `goal`, root `model_stylesheet`, and node `prompt` attributes with `{{ inputs.name }}` syntax: ```dot title="c-i.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph CI { graph [ goal="Run tests for {{ inputs.repo_name }}", model_stylesheet="{% if inputs.language == 'rust' %}* { reasoning_effort: high; }{% endif %}" ] test [label="Test", prompt="Clone {{ inputs.repo_url }} and run the {{ inputs.language }} test suite."] } ``` Inputs cannot parameterize workflow structure, file references such as node IDs, edges, `import` paths, `@file` paths, or child workflow paths, or any full-template attribute besides `prompt`, `goal`, and the root `model_stylesheet`. Command `script` supports only simple value substitution. Other attributes such as `label` are literal text. If a workflow template references an undefined input like `{{ inputs.langauge }}`, `fabro validate` reports a warning. Run-style commands promote that diagnostic to an error before creating or starting a run. TOML `[run.inputs]` tables replace wholesale across layers. Unlike labels, TOML input tables do not merge by key — the highest-precedence config layer that sets `inputs` wins its entire map. CLI input flags are sparse overrides on top of the resolved config inputs: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run .fabro/workflows/ci/workflow.toml -I repo_name=fabro-2 --input language=rust ``` Repeat `-I` / `--input` to override multiple keys. CLI input flags have the highest precedence, merge per key, and preserve unrelated inherited inputs. Duplicate CLI keys are accepted; the last value wins. ### `[run.artifacts]` Configure automatic collection of test artifacts (Playwright reports, JUnit XML, screenshots, etc.) from the execution environment after each stage. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.artifacts] include = ["test-results/**", "playwright-report/**", "**/*.trace.zip"] ``` | Field | Description | | - | - | | `include` | Workspace-relative glob patterns for regular files to collect as assets after each stage. | Artifact collection is opt-in — when no `[run.artifacts]` section is present, no file scanning occurs. Artifact globs use `/` as the separator and have the same semantics in every sandbox: * `*` and `?` match within one path segment. * Bracket expressions such as `[abc]` and `[!abc]` match one character. * `**` matches across directories when used as a complete segment. * Leading dots are matched normally. * Patterns use `/`, are relative to the sandbox working directory, and are case-sensitive. Backslashes are invalid. * Absolute patterns and patterns containing a `..` segment are invalid. For example, `.ai/reports/*.md` matches direct Markdown children of `.ai/reports`, while `.ai/reports/**/*.md` also matches nested reports. `*.trace.zip` matches only the working-directory root; use `**/*.trace.zip` to match at any depth. To collect date-named implementation plans, use `.ai/plans/????-??-??-*.md`. Each collection reflects the post-stage workspace state rather than filesystem modification timestamps. A path with unchanged content is recorded only once per run; if its content changes, Fabro captures the new version. Fabro resolves the configured workspace root but does not recurse through symlinks below it while collecting artifacts. Dependency, cache, and build directories such as `.git`, `node_modules`, `target`, `.venv`, `.cache`, and `dist` are pruned. A collection is limited to 100 files, 10 MB per file, and 50 MB total. ### `[run.agent]` Configure workflow agent behavior that is not tied to a single stage. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent] fabro_tools = true ``` `fabro_tools` defaults to `false`. Set it to `true` only for runs whose agents should be able to use the same Fabro run-management MCP tool catalog exposed to human MCP clients: workflow version registration, create, search, get, interact, gather, events, and pair. One workflow-agent exception is intentional: `fabro_run_create` always creates [child runs](/execution/child-runs) parented to the current run. If an agent supplies `parent_id`, it must match the current run ID. This setting is independent of `[run.agent.mcps]`, which configures external MCP servers available to the agent. ### `[run.agent.mcps]` Configure [MCP servers](/agents/mcp) available to agent stages during the workflow run. Each server is a named TOML table under `[run.agent.mcps]`. All three transport types are supported: `stdio`, `http`, and `sandbox`. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"] port = 3100 startup_timeout = "60s" tool_timeout = "2m" ``` To reuse a definition from the server-managed MCP catalog, reference its ID instead of defining an inline transport: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent.mcps.sentry] id = "sentry" ``` | Field | Description | Default | | - | - | - | | `id` | Server-managed MCP definition to use. Cannot be combined with inline transport fields. | — | | `enabled` | Set `false` to leave this inline server or catalog reference disabled. | `true` | | `type` | Transport type: `"stdio"`, `"http"`, or `"sandbox"`. | — | | `script` | (stdio, sandbox) Shell-evaluated startup command, mutually exclusive with `command`. | — | | `command` | (stdio, sandbox) Argv array: executable + arguments. | — | | `port` | (sandbox) Port the server listens on inside the sandbox. | — | | `url` | (http) The MCP server endpoint URL. | — | | `env` | (stdio, sandbox) Additional environment variables. | `{}` | | `headers` | (http) Optional HTTP headers for authentication. | `{}` | | `startup_timeout` | Max duration for server startup + MCP handshake (e.g. `"10s"`, `"1m"`). | `"10s"` | | `tool_timeout` | Max duration for a single tool call. | `"60s"` | Inline transport commands, URLs, env values, and headers support `{{ vars.* }}` and `{{ secrets.* }}` interpolation. As with prepare steps, server variables resolve at run creation and token secrets resolve at launch; missing values fail closed. See [MCP runtime interpolation](/agents/mcp#runtime-interpolation) for the standalone `fabro exec` difference. The `sandbox` transport runs the MCP server inside the workflow's sandbox. This is useful for tools that need access to the sandbox environment, such as browser automation with Playwright. See [MCP](/agents/mcp#sandbox) for details. ### `[run.pull_request]` Automatically open a GitHub pull request when the workflow run completes successfully. Requires a [GitHub App](/integrations/github) to be configured and a clone-based Docker or Daytona environment; run creation rejects `enabled = true` on a Local environment. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.pull_request] enabled = true draft = true auto_merge = false merge_strategy = "squash" ``` | Field | Description | | - | - | | `enabled` | When `true`, Fabro creates a PR from the agent's working branch after a successful run. Default: `false`. | | `draft` | When `true`, the PR is created as a draft pull request. Default: `true`. | | `auto_merge` | When `true`, enables GitHub auto-merge on the created PR. Implies `draft = false` since GitHub doesn't allow auto-merge on draft PRs. The repository must have auto-merge enabled in GitHub settings. Default: `false`. | | `merge_strategy` | Merge method when `auto_merge` is enabled: `squash` (default), `merge`, or `rebase`. | ### `[[run.hooks]]` Define hooks that run in response to lifecycle events. Each hook is a TOML array entry: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [[run.hooks]] id = "pre-check" name = "Pre-check script" event = "stage_start" script = "./scripts/pre-check.sh" matcher = "agent" blocking = true timeout = "30s" sandbox = false ``` | Field | Description | | - | - | | `id` | Optional merge identity. Hooks with the same `id` replace each other across layers. | | `name` | Optional display name for the hook. | | `event` | Lifecycle event: `run_start`, `run_complete`, `stage_start`, `stage_complete`, etc. | | `script` | Shell-evaluated command (equivalent to the old `type = "command"` shorthand). | | `command` | Argv-style command (alternative to `script`). | | `matcher` | Regex matched against node ID or handler type. Limits which stages trigger this hook. | | `blocking` | Whether the hook must complete before execution continues. Defaults vary by event. | | `timeout` | Human-readable hook timeout (e.g. `"30s"`, `"1m"`). Default: `"60s"`. | | `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). | Hook merge semantics: hooks with matching `id` values replace in place. Hooks without an `id` from a higher-precedence layer append after the fully merged inherited hook list. See [Hooks](/agents/hooks) for hook types beyond scripts (HTTP, prompt, agent). ## Graph path resolution The `[workflow].graph` path is resolved relative to the TOML file's parent directory, not the current working directory. This means a run config and its workflow can live side by side: ``` project/ runs/ ci.toml # [workflow] graph = "ci.fabro" ci.fabro ``` Absolute paths are used as-is. ## Precedence For runs created by `fabro run` and `fabro create`, the CLI transmits sparse flags and immutable workflow content, not machine or project run defaults. Fabro resolves workflow behavior in this order (first match wins): | Source | Priority | | - | - | | Node-level [stylesheet](/workflows/stylesheets) | Highest | | CLI flags (`--model`, `--provider`, `--environment`) | | | Run config TOML (`workflow.toml` or equivalent) | | | Graphviz graph attributes (`default_model`, `default_provider`) | | | Built-in defaults | Lowest | Stylesheet rules on individual nodes always take priority over run config values. ### Project and machine settings `fabro run` and `fabro create` do not transmit `[run]` or `[environments]` from `.fabro/project.toml` or the CLI machine's `~/.fabro/settings.toml`. When either key is present, the CLI warns with the affected file and key names, but never includes the values in the warning or request. Move workflow-owned behavior into each `workflow.toml`, and configure placement in server-managed environments. In particular, automatic pull-request behavior for CLI-created runs belongs in the workflow: ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [workflow] graph = "workflow.fabro" [run.pull_request] enabled = true draft = false ``` There is no compatibility field in the create request and no global pull-request default supplied by the CLI. A server may still apply its own active configuration independently; the warning does not claim otherwise. ## Validation Fabro validates the run config when it loads: * **`_version` check** — Only `_version = 1` (or missing, which defaults to `1`) is accepted. The legacy top-level `version` key is rejected with a rename hint. * **Unknown keys** — Any top-level key not in `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, or `_version` is rejected with a targeted rename hint pointing at the v2 replacement path. * **Variable check** — Undefined workflow or prompt template variables produce diagnostics. `fabro validate` reports them as warnings; run-style commands treat them as errors before creating or starting a run. The CLI also continues to parse and validate its active machine settings and a discovered source-project config before creation. Malformed or unreadable files remain hard local failures even though their run values are not transmitted. Use `fabro preflight` to validate a run config without executing it: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro preflight run.toml ``` # Comparison Source: https://docs.fabro.sh/getting-started/comparison How Fabro compares to AI coding agents, software factories, and orchestration platforms. Fabro is a [dark software factory](/getting-started/dark-factory). It is not an IDE plugin. It is not a REPL, command line or otherwise. It is not a web application where you drag-and-drop workflows or update technical documents (we have Git for those). Almost every other tool in AI coding starts from the same place: a developer at a keyboard, typing prompts, reviewing responses and outputs. Fabro starts from a different premise — that the highest-leverage work for expert engineers is defining **what** gets built and **how quality is verified**, not supervising each line of code as it's written. This means Fabro intentionally does not include: * **An IDE integration** — no VS Code extension, no editor plugins * **A REPL CLI** — no interactive prompt-response loop * **Autocomplete** — no inline code suggestions Instead, Fabro provides workflow graphs, verification gates, multi-model orchestration, and observability — the infrastructure a small team needs to run coding agents with minimal human interaction. ## Notable Comparisons ### Automated Coding Workflows These are the closest alternatives to Fabro — platforms that structure and automate multi-step coding processes rather than offering a single interactive agent session. They are proprietary products which offer a more "light" software factory approach where humans are still watching and driving. * **Factory AI (Droids)** — Enterprise coding automation platform * **Ona (Gitpod)** — Agentic coding platform with cloud dev environments * **Devin** — Autonomous coding agent by Cognition ### AI Coding REPLs AI coding REPLs like Claude Code, Codex CLI, and Cursor are interactive, prompt-driven agents designed for single-session tasks. They are powerful pair-programming tools but operate at a different level than Fabro — they lack declarative workflow definition, multi-stage orchestration, and Git-native checkpointing. REPLs and Fabro pair nicely. REPLs are ideal for exploring ideas interactively — prototyping an approach, testing assumptions, and iterating in real time. When you're ready to move from exploration to implementation, you can hand the work off to a Fabro workflow for the build out. ## Other Comparisons **8090 Software Factory** 8090.ai is an AI SDLC orchestration platform that structures upstream context — requirements, architecture, and planning — then delegates implementation to external coding agents via MCP. Because 8090 stops before code generation and does not perform coding activities itself, it operates in a different stage than Fabro and is not compared in the sections below. **OpenAI Symphony** [Symphony](https://github.com/openai/symphony) is a new multi-agent orchestration framework from OpenAI that dispatches Codex sessions from a Linear issue board. It is currently an engineering preview with a narrow, fixed workflow (poll → dispatch → resolve → land). Because it is early-stage and with a limited implementation, it is not compared in the sections below. ## Comparison Dimensions We strive to keep this information accurate, but the landscape changes rapidly. If you spot an inaccuracy, please [let us know](mailto:hello@fabro.sh). ### Licensing Open source vs. proprietary — trust, auditability, extensibility. | Tool | License | | - | - | | **Fabro** | Open source, MIT license. Fork and customize. | | **Factory AI** | Proprietary, closed source | | **Devin** | Proprietary, closed source | | **Ona** | Proprietary, closed source | | **AI Coding REPLs** | Proprietary except Codex CLI (Apache 2.0) | ### Workflow Definition How coding tasks are structured, repeated, and version-controlled. | Tool | Approach | | - | - | | **Fabro** | Declarative, deterministic workflow graphs with loops, branching, and gates. Version controlled. | | **Factory AI** | Non-deterministic Markdown skills (custom Droids) and black box Missions stored in a proprietary database. | | **Devin** | One-off chat tasks or non-deterministic Markdown skills stored in a proprietary database. | | **Ona** | One-off chat tasks or proprietary, web-based workflow builder. | | **AI Coding REPLs** | Not applicable. Imperative prompts with Markdown skills. | ### Model Access Access to models of various intelligence and costs across providers with per-step control. | Tool | Models | | - | - | | **Fabro** | Multi-provider with ensembles. | | **Factory AI** | Multi-provider with ensembles for Missions. | | **Devin** | Proprietary, black box selection. | | **Ona** | Multi-provider. One user selection per task. | | **AI Coding REPLs** | Locked to REPL provider's ecosystem. | ### Human-in-the-Loop How and where humans intervene in the workflow. | Tool | Approach | | - | - | | **Fabro** | Deterministically defined human gates in the workflow, plus ad-hoc injected steering. | | **Factory AI** | Plan before implementation, then review pull requests. | | **Devin** | IDE-based mid-task chat intervention plus pull request review. | | **Ona** | Humans review at the pull request boundary. | | **AI Coding REPLs** | REPL prompts plus permission prompt modes. | ### Multi-Agent Orchestration How multiple agents coordinate on complex work. | Tool | Coordination | | - | - | | **Fabro** | Explicit graph of stages that fan out and converge. Deterministic composition of agents, prompts, commands, and gates. | | **Factory AI** | Hierarchical subagents delegated from a parent Droid. Non-deterministic Missions for parallel feature workers. | | **Devin** | Opaque internal compound system. Multiple instances run in parallel but users cannot define the orchestration. | | **Ona** | Fleet of independent agents in isolated VMs. Horizontal scale but no agent-to-agent communication. | | **AI Coding REPLs** | Limited. Session-scoped sub-agents or parallel background agents with no shared workflow definition. | ### Execution Environment Where code runs and who controls the sandbox. | Tool | Environment | | - | - | | **Fabro** | Local or BYO provider (Daytona, Sprites, exe.dev) and SSH support. | | **Factory AI** | Local plus cloud VM configured with cloud templates. Enterprise supports air-gapped environments. | | **Devin** | Proprietary cloud VM managed by Devin ("Devbox") or self hosted. | | **Ona** | Proprietary cloud VMs managed by Ona (Gitpods) or self-hosted. | | **AI Coding REPLs** | Local and proprietary cloud modes. | ### Cloud Sandbox Access Human access to the running sandbox via preview URLs, SSH, and VNC. | Tool | Access | | - | - | | **Fabro** | Live preview URLs, SSH, and VNC when using a supported sandbox provider (e.g. Daytona). | | **Factory AI** | SSH access to cloud environments. No preview URLs or VNC. | | **Devin** | Browser-based shell and VS Code in the Devbox. No direct SSH or VNC. | | **Ona** | SSH access to cloud environments. No preview URLs or VNC. | | **AI Coding REPLs** | None. | ### Git Checkpointing How state is managed during execution. | Tool | Git behavior | | - | - | | **Fabro** | Commits after every stage with provenance captured. Inspect, revert, or fork from any checkpoint. | | **Factory AI** | Standard git operations. No per-step checkpointing. | | **Devin** | Opens pull requests. No per-step git checkpointing. | | **Ona** | Disposable VMs with pull request output. No per-step checkpointing. | | **AI Coding REPLs** | None. | ### Quality Verification How the system verifies that generated code meets requirements. | Tool | Verification | | - | - | | **Fabro** | First class quality verification system (criteria, controls, evals) combining agent reviews, CI checks, and human approvals. | | **Factory AI** | Sub-agent code reviews. PR-based human review. | | **Devin** | Sub-agent code reviews. PR-based human review. | | **Ona** | Human review at PR boundary. | | **AI Coding REPLs** | Manual. User reviews output in the REPL or at the PR boundary. | ### Observability Can you trace what happened step-by-step? Audit and debug agent behavior. | Tool | Visibility | | - | - | | **Fabro** | Full trace of every model call, tool invocation, and decision. Cross-run comparison. | | **Factory AI** | OpenTelemetry-native with dual-export and structured metrics. | | **Devin** | Session replay with timeline and milestones. Enterprise audit logging. | | **Ona** | Enterprise audit logging with real-time event streaming. | | **AI Coding REPLs** | Varies. Limited to session-level transcripts or enterprise audit logs. | ### Deployment Model SaaS dependency vs. self-hosted control. | Tool | Deployment | | - | - | | **Fabro** | Self-hosted Rust single binary with no runtime dependencies. | | **Factory AI** | SaaS with enterprise hybrid and air-gapped options. | | **Devin** | SaaS only. Enterprise can host execution in customer VPC. | | **Ona** | SaaS with enterprise self-hosted VPC option. | | **AI Coding REPLs** | Local CLI tools with optional proprietary cloud modes. | # Dark Factory Source: https://docs.fabro.sh/getting-started/dark-factory How Fabro helps small teams incrementally adopt a dark factory approach to software development The term "dark factory" comes from manufacturing. Since 2001, FANUC has operated a factory near Mt. Fuji where robots build other robots — running unsupervised for up to 30 days at a time. The factory is "dark" because no humans are present and robots don't need light. In software, the dark factory concept is different. It doesn't mean zero human involvement — it means **minimal human interaction** with the code itself. Humans supervise the specs, guardrails, and outcomes, not each line of code. Engineers shift from writing and reviewing code to defining what should be built, how quality is measured, and when to intervene. This is an aspirational concept, and getting there is iterative. ## From coding to orchestrating Most teams today have AI writing code while humans review it line by line. The hardest transition is moving beyond that — replacing ad-hoc human review with structured, repeatable verification that you actually trust. Dan Shapiro's [five-level framework](https://www.danshapiro.com/blog/2026/01/the-five-levels-from-spicy-autocomplete-to-the-software-factory/) describes this progression well. ## What makes it work The dark factory isn't a single tool or practice. It's a set of capabilities that compound: **Declarative workflows over imperative prompts.** When the process is a version-controlled graph — not a chat transcript — you can review, iterate, and share it like any other source file. The workflow itself becomes the specification of how work gets done. **Deterministic verification over human review.** Test suites, linters, type checkers, and LLM-as-judge evaluations replace line-by-line code review. Failures route back to fix loops automatically. Humans define the criteria; the system enforces them. **Multi-model ensembles over single-model dependence.** Using different models for implementation and verification breaks the circularity problem — where the builder and inspector share the same blind spots. Cross-critique with fresh eyes catches what self-review misses. **Checkpointed execution over black-box runs.** Git commits after every stage create an audit trail. When something goes wrong, you can inspect, revert, or fork from any point — without having watched the run live. **Observability over black-box automation.** Durable event streams, checkpoints, conclusions, and stage outputs make each run inspectable after the fact. Workflows get better when teams can see what happened and adjust the graph. ## The human role in a dark factory The dark factory doesn't eliminate engineering judgment. It redirects it: | Before | After | | - | - | | Writing code | Defining workflows and prompts | | Reviewing diffs | Defining verification criteria | | Debugging test failures | Designing fix loops | | Watching agent sessions | Inspecting run traces | | Manual quality checks | Tuning goal gates and evals | The goal is to spend your time on the parts that require human judgment — what to build, how to verify it, and when something doesn't look right — while the factory handles the rest. ## Further reading Learn how workflow graphs orchestrate agents, commands, and human gates. Control where and how humans intervene in workflows. Build verification into your workflows. Inspect event streams, logs, and exported run state. # Introduction Source: https://docs.fabro.sh/getting-started/introduction Fabro is the open source dark software factory for small teams of expert engineers Fabro replaces the prompt-wait-review loop with version-controlled workflow graphs that orchestrate AI agents, shell commands, and human decisions into repeatable, long-horizon coding processes. Fabro Runs board showing workflows across Working, Pending, Verify, and Merge stages ## Use cases * **Extend disengagement time** — Stop babysitting an agent REPL. Define a workflow with verification gates and walk away — Fabro keeps the process on track without you. * **Leverage ensemble intelligence** — Seamlessly combine models from different vendors. Use one model to implement, another to cross-critique, and a third to summarize — all in a single workflow. * **Share best practices across your team** — Collaborate on version-controlled workflows that encode your software processes as code. Review, iterate, and reuse them like any other source file. * **Reduce token bills** — Route cheap tasks to fast, inexpensive models and reserve frontier models for the steps that need them. CSS-like stylesheets make this a one-line change. * **Improve agent security** — Run agents in cloud sandboxes with full network and filesystem isolation. Keep untrusted code off your laptop and out of your production environment. * **Run agents 24/7 at scale** — Fabro's API server queues and executes runs continuously in cloud sandboxes. Close your laptop — workflows keep running across as many concurrent runs as your infrastructure allows. * **Guarantee code quality** — Layer deterministic verifications — test suites, linters, type checkers, LLM-as-judge — into your workflow graph. Failures trigger fix loops automatically. * **Inspect every run** — Query durable event streams, checkpoints, conclusions, and stage outputs to understand what happened and improve the workflow. * **Specify in natural language** — Define requirements as natural-language specs and let Fabro generate — and regenerate — implementations that conform to them. The problems Fabro solves for AI-assisted software teams. Install Fabro and run your first workflow in minutes. Learn how workflow graphs orchestrate agents, commands, and human gates. Understand how Fabro configures and runs LLM agents within workflows. # Quick Start Source: https://docs.fabro.sh/getting-started/quick-start Get up and running with Fabro Fabro runs as a server. This quickstart runs everything locally on your laptop — no deployment required. To self-host the server for a team or 24/7 workflows, see [Deployment](/administration/deployment). ## Supported platforms | OS | Architecture | Supported | | - | - | - | | Linux | x86\_64 | Yes | | Linux | arm64 (aarch64) | Yes | | macOS | arm64 (Apple Silicon) | Yes | | macOS | x86\_64 (Intel) | No | | Windows | any | No | On Intel Macs, run Fabro in a Linux x86\_64 container or VM. Native Windows isn't supported; use WSL2 with a supported Linux architecture. ## Install ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} curl -fsSL https://fabro.sh/install.md | claude ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} codex "$(curl -fsSL https://fabro.sh/install.md)" ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} brew install fabro-sh/tap/fabro-nightly ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} curl -fsSL https://fabro.sh/install.sh | bash ``` Release binaries and the multi-arch Docker image ship with [SLSA Build Provenance](/reference/verifying-releases) attestations you can verify with `gh attestation verify`. Self-hosting the server? See [Self-host with Docker](/administration/self-host-docker) and [Server Operations](/reference/server-operations) for the install wizard, auth, and CLI-pointing. ## Initialize your project ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} cd my-repo/ fabro repo init ``` This creates `.fabro/project.toml` and a starter workflow under `.fabro/workflows/hello/`. ## Configure API keys For local CLI runs, export at least one LLM provider key in your shell: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} ANTHROPIC_API_KEY=sk-ant-... OPENAI_API_KEY=sk-... GEMINI_API_KEY=AI... ``` You only need one provider key to get started. Add more to enable multi-model workflows. For server-backed runs, store provider keys in the server vault instead: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set ANTHROPIC_API_KEY sk-ant-... ``` ## Run your first workflow ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run hello ``` ## Next steps Understand the problems Fabro solves. Learn how to define workflow graphs in Graphviz. # Why Fabro? Source: https://docs.fabro.sh/getting-started/why-fabro The problems Fabro solves for AI-assisted software teams Fabro is the open source dark software factory for small teams of expert engineers. It replaces the prompt-wait-review loop with version-controlled workflow graphs that orchestrate AI agents, shell commands, and human decisions into repeatable, long-horizon coding processes. ## The problem AI coding agents have transformed software engineering productivity, but the surrounding toolchain hasn't kept up: * **Developers work for the agents.** The prompt-wait-review loop idles engineers while agents run, then demands constant babysitting to course-correct. * **Unpredictable agents force oversight.** Non-deterministic guardrails create an explosion of failure modes. Engineers compensate by watching every step. * **Verification is overwhelmed.** Agent throughput exceeds human review capacity. CI pipelines designed for pass/fail signals can't keep pace with the volume or nuance of AI-generated code. * **Token costs are primed to explode.** ROI per token diverges wildly across tasks, models, and harnesses. Every unnecessary frontier token is one that can't be spent where it matters. * **The continuous improvement loop broke.** Data is lost at every sub-process boundary. Organizations can't train LLMs the way they train people, and memory files make no guarantees. Fabro run detail view showing verifications for a pipeline event types change, including Traceability, Readability, Reliability, Code Coverage, and more ## How Fabro solves this Fabro gives you a deterministic harness around non-deterministic AI. You define **workflow graphs** in Graphviz files that specify exactly what happens, in what order, with which models, and where humans weigh in. Fabro handles orchestration, parallelism, model routing, verification, and observability. Define workflows as code in Graphviz. Nodes are agents, shell commands, or human input gates. Fan out, loop, branch, and resume — all traceable and repeatable. Route tasks to the right model using CSS-like stylesheets. Cross-critique with fresh eyes, delegate simple tasks to fast models, and fail over automatically when providers go down. Steer while the agent runs, not after. Approval gates, interviews, and steering let you intervene at the right moments without waiting for a pull request. Combine LLM-as-judge, test suites, third-party tools, and human review. Verifications act as an eval suite tailored to your organization, building confidence over time. Every tool call, agent turn, and shell command is captured in a unified event stream. Query run data with SQL via DuckDB and inspect the full execution trail. Licensed under MIT. Written in Rust with minimal dependencies. Runs on a single node with no databases to set up. Fabro workflow diagram for Fix Build showing stages from Start through Analyze Build Errors, Diagnose Root Cause, Validate Build, and Review Changes to Exit ## What a workflow looks like Workflows are defined in Graphviz, a simple graph description language. Here's a plan-approve-implement workflow and its Graphviz source: Plan-Implement workflow graph ```dot title="plan-implement.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph PlanImplement { graph [goal="Plan, approve, implement, and simplify a change"] start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] plan [label="Plan", prompt="Analyze the goal and codebase. Write a step-by-step plan.", reasoning_effort="high"] approve [shape=hexagon, label="Approve Plan"] implement [label="Implement", prompt="Read plan.md and implement every step."] simplify [label="Simplify", prompt="Review the changes for clarity and correctness."] start -> plan -> approve approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] implement -> simplify -> exit } ``` This workflow plans a change, asks a human to approve it, implements the plan, and simplifies the result. If the human rejects the plan, the agent revises it. The entire process is version-controlled, repeatable, and resumable. ## Next steps Install Fabro and run your first workflow in minutes. # Interviews Source: https://docs.fabro.sh/human-tools/interviews How Fabro collects human input during workflow execution When a workflow reaches a [human gate](/workflows/human-in-the-loop), it needs to pause and wait for a person to respond. The **interviewer** is the abstraction that makes this work — it presents a question, collects an answer, and returns it to the engine so execution can continue. Fabro ships with several interviewer implementations for different environments: an interactive terminal prompt for the CLI, a web-based queue for the API server and web UI, an auto-approve mode for CI, and record/replay support for testing. ## Question types Every human interaction is modeled as a `Question` with a type that determines how it's presented: | Type | Description | CLI presentation | | - | - | - | | `YesNo` | Binary yes/no decision | `[Y/N]` prompt | | `Confirmation` | Confirm an action (like YesNo) | `[Y/N]` prompt | | `MultipleChoice` | Pick one option from a list | Arrow-key selector or numbered list | | `MultiSelect` | Pick one or more from a list | Checkbox selector | | `Freeform` | Open-ended text input | `>` prompt | ### Question structure Each question carries metadata beyond the prompt text: | Field | Description | | - | - | | `text` | The question displayed to the user | | `question_type` | One of the types above | | `options` | List of `{key, label}` pairs for choice questions | | `allow_freeform` | Whether free-text input is accepted in addition to fixed options | | `default` | Default answer used on timeout | | `timeout_seconds` | How long to wait before using the default or timing out | | `stage` | The node ID that generated this question | | `metadata` | Arbitrary key-value metadata for integrations | ## Answer values Answers are one of seven variants: | Value | Meaning | | - | - | | `Yes` | Affirmative response to a yes/no or confirmation question | | `No` | Negative response | | `Selected(key)` | A specific option was chosen (carries the option key) | | `Text(string)` | Free-text input | | `Interrupted` | No answer was obtained because the prompt ended early (EOF, cancel, disconnected session, exhausted replay/queue) | | `Skipped` | Legacy skip-style answer; not treated as approval by human gates | | `Timeout` | The question's timeout elapsed without a response | An answer can also carry a `selected_option` (the full `{key, label}` pair) and a `text` field for freeform input. ## How human gates build questions When the engine reaches a human gate node (`shape=hexagon`), the [human handler](/workflows/human-in-the-loop) builds a question from the node's outgoing edges: 1. Each edge becomes an option, with the accelerator key parsed from the label (e.g. `[A] Approve` → key `A`, label `[A] Approve`) 2. Edges with `freeform=true` are excluded from the option list and enable free-text fallback 3. The question text comes from the node's `label` attribute 4. The question type defaults to `multiple_choice` when fixed options exist and `freeform` when only a freeform edge exists. Set `question_type="yes_no"`, `question_type="confirmation"`, `question_type="multi_select"`, or another supported value to override it. The handler then passes the question to the interviewer, waits for an answer, and maps it back to an edge for [transition](/workflows/transitions#human-gate-transitions). ## Channels The `Interviewer` trait has a simple interface — `ask(question) → answer` — and Fabro provides implementations for each delivery channel: ### Console The default for CLI runs. On a TTY, the console interviewer uses interactive widgets (arrow-key selection, checkbox multi-select, confirm prompts) via `dialoguer`. When stdin is piped (non-TTY), it falls back to a line-based reader with numbered options. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro # At a human gate: # ? Approve Plan # [1] A - [A] Approve # [2] R - [R] Revise # Select: ``` If the console prompt is canceled or stdin is already closed, the interviewer returns `Interrupted`. That does not count as approval. ### Web The default for API server runs. The web interviewer holds questions in a queue until answers are submitted externally — typically by the web UI or a REST API call. Each question gets a unique ID (e.g. `q-1`), and the `ask()` call blocks on a oneshot channel until `submit_answer(id, answer)` is called. This decoupling means the workflow engine and the user interface can run in different processes. The web UI shows pending questions in the run page's interview dock, listens for interview events, and posts answers back to the API. The dock supports yes/no, confirmation, multiple choice, multi-select, and freeform questions. When multiple questions are pending, the dock lets you cycle through them without leaving the run detail page. If the pending session disappears before an answer is submitted, the waiting question resolves as `Interrupted`. ### Slack Fabro's [Slack integration](/integrations/slack) uses the web interviewer under the hood. When a human gate fires, the pending question is rendered as a Slack message with interactive buttons. When a user clicks a button, the Slack event handler calls `submit_answer()` on the web interviewer, unblocking the workflow. Slack interview prompts require Slack server credentials, an enabled `[server.integrations.slack]` table, and a destination channel. Most servers should configure `server.integrations.slack.default_channel`; lifecycle notifications use their own per-route channels instead. ### Auto-approve For fully automated runs or CI pipelines, the auto-approve interviewer answers every question without human input: * `YesNo` / `Confirmation` → `Yes` * `MultipleChoice` / `MultiSelect` → first option * `Freeform` → `"auto-approved"` Enable it with the `--auto-approve` flag: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro --auto-approve ``` `--auto-approve` is intentionally distinct from an unanswered prompt. Auto-approve is an explicit operator choice to advance human gates automatically. ## Timeouts Questions can have a `timeout_seconds` field. When set, Fabro wraps the interviewer call with a timeout: * If the user answers before the deadline, their answer is used normally * If the timeout elapses and a `default` answer is set on the question, the default is used * If the timeout elapses with no default, the answer is `Timeout` The human handler then checks the node's `human.default_choice` attribute. If set, execution continues to the default target. Otherwise, the stage retries. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve?", human.default_choice="deploy"] ``` Outside of timeout defaults, human gates fail closed: `Interrupted` and `Skipped` answers do not fall through to ordinary approval edges. To model an explicit unanswered path, add an edge such as `condition="outcome=failed"` or configure a `retry_target`. # Preview Source: https://docs.fabro.sh/human-tools/preview Preview running applications in sandbox environments from your browser When an agent starts a web server or other network service inside a sandbox, you can generate a time-limited preview URL to access it from your browser — no port forwarding or SSH tunnels required. This lets you visually inspect what the agent is building, test interactive features, and verify UI changes while the workflow is still running. Preview URLs require the **Daytona** sandbox provider. Local, Docker, and exe.dev sandboxes do not support this feature. ## How it works When you request a preview, Fabro generates a time-limited, token-authenticated URL that proxies traffic to the specified port on the sandbox. The URL is accessible from any browser without additional credentials — the signed token in the URL handles authentication. 1. An agent starts a service on a port inside the sandbox (e.g. a dev server on port 3000) 2. You request a preview URL for that port — via the web UI button or the API 3. Fabro returns a signed URL that you open in your browser 4. The URL expires after the configured TTL (up to 24 hours), after which a new one must be generated ## Using preview from the CLI Use `fabro sandbox preview` to generate a preview URL for any run with an active Daytona sandbox: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro sandbox preview 3000 # URL + token + curl example fabro sandbox preview 3000 --signed # self-contained signed URL fabro sandbox preview 3000 --open # open in browser (implies --signed) ``` See [`fabro sandbox preview`](/reference/cli#fabro-sandbox-preview) for the full flag reference. ## Using preview from the web UI When viewing a run with an active sandbox, the run detail page shows a **Preview** button in the toolbar. Clicking it generates a preview URL for port 3000 with a 1-hour TTL and opens it in a new browser tab. ## Using preview from the API Generate a preview URL by calling `POST /api/v1/runs/{id}/preview` with the target port and a TTL in seconds. See the [Preview URL API reference](/api-reference/human-in-the-loop/preview-url) for the full request/response schema. ## Common use cases ### Previewing a web application When a workflow builds and serves a web application, use preview to verify the result without waiting for the run to complete: Preview workflow: Start → Build Site → Start Dev Server → Review Preview → Exit, with a Revise loop back to Build Site ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph BuildAndPreview { graph [goal="Build and preview the marketing site"] start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] build [label="Build Site", prompt="Install dependencies and build the marketing site."] serve [label="Start Dev Server", shape=parallelogram, script="cd site && npx serve -l 3000 &"] review [shape=hexagon, label="Review Preview"] start -> build -> serve -> review review -> exit [label="[A] Approve"] review -> build [label="[R] Revise"] } ``` After the dev server starts, the human gate pauses the workflow. Open a preview URL for port 3000 to inspect the site, then approve or request revisions. ### Debugging a failing test If an agent is building a frontend and tests are failing, generate a preview URL to visually inspect the application in the same state the test runner sees. ### Reviewing API responses Start an API server in the sandbox and use the preview URL as the base for `curl` or Postman requests, inspecting the agent's implementation directly. ## Tips * **Use `--preserve-sandbox`** to keep the sandbox alive after the workflow finishes. This gives you time to generate and use preview URLs for post-run inspection. * **Pair with human gates** — place a human gate after the agent starts a service so you can preview before approving the next step. * **Multiple ports** — generate separate preview URLs for different ports (e.g. frontend on 3000, API on 8080) by making multiple API calls. * **TTL selection** — use shorter TTLs (300–900 seconds) for quick checks and longer TTLs (3600+ seconds) when you need sustained access during a review. * **Sandbox must be running** — preview URLs only work while the sandbox is active. If the sandbox has been stopped or destroyed, the URL will stop working even if the token hasn't expired. # SSH Access Source: https://docs.fabro.sh/human-tools/ssh-access Connect to sandbox environments via SSH for direct inspection and debugging When a workflow runs in a [Daytona sandbox](/execution/environments#daytona), you can SSH into the sandbox to inspect files, run commands, and debug issues — all while the workflow is still executing. SSH access is only available with the Daytona sandbox provider. Local, Docker, and exe.dev sandboxes do not support SSH access. ## Connecting to a run's sandbox Use `fabro sandbox ssh` to connect to the Daytona sandbox from any completed or in-progress run: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro sandbox ssh ``` Fabro creates temporary SSH credentials and connects directly. Use `--print` to print the SSH command instead of connecting, or `--ttl` to set the credential expiry: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro sandbox ssh --print fabro sandbox ssh --ttl 120 ``` ## Keeping the sandbox alive By default, Daytona sandboxes are destroyed when the workflow finishes. To keep the sandbox running after the workflow completes — so you can continue debugging — pass `--preserve-sandbox`: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro --environment cloud --preserve-sandbox ``` Without `--preserve-sandbox`, the SSH session is terminated when the run ends and the sandbox is cleaned up. You can also set `lifecycle.auto_stop` in your environment to control how long an idle sandbox stays alive: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment] id = "cloud" [environments.cloud] provider = "daytona" [environments.cloud.lifecycle] preserve = true auto_stop = "60m" ``` ## What you can do over SSH Once connected, you have a full shell inside the sandbox VM: * **Inspect the working directory** — Agent file changes are at `/home/daytona/workspace` * **Run commands** — Execute tests, check logs, inspect process state * **Edit files** — Make manual fixes while the workflow is paused at a human gate * **Debug failures** — Reproduce and diagnose issues in the exact environment where they occurred ## Credential lifetime SSH credentials are temporary and expire after **60 minutes** by default. With `fabro sandbox ssh`, you can set a custom TTL with `--ttl `. If your session expires, run `fabro sandbox ssh` again to get fresh credentials. ## Limitations * SSH access is **Daytona-only**. * SSH access is currently available only from the **CLI**. The API server and web UI do not yet expose an SSH endpoint. # Steering Source: https://docs.fabro.sh/human-tools/steering Guide running agents with real-time course corrections Steering lets you send guidance to an agent while it's working — without waiting for a human gate or stopping the run. If you see the agent heading down the wrong path, you can nudge it back on track mid-stage. ## How steering works A steering message is injected into the agent's conversation as a user-role message. The agent sees it on its next LLM turn and can adjust its approach immediately. The delivery flow: 1. You send a steering request with your guidance text 2. The message is queued on the agent session's steering queue 3. Before the next LLM call, Fabro drains the queue and injects each message as a `Steering` turn in the conversation history 4. The LLM sees the guidance alongside its existing context and adjusts accordingly Steering is **asynchronous** — the agent picks up the message at its next natural pause point. When you send steering with `interrupt=true`, Fabro first cancels the active API-mode agent round, then queues the steering text in the same worker-control operation. The agent resumes with that steering text as the next user turn. A standalone interrupt through the API cancels the active round without text and keeps the session waiting until a later steer arrives. ## When steering is delivered Steering messages are drained from the queue at two points during the agent loop: 1. **Before the first LLM call** — any messages queued before the agent starts its first turn 2. **After each interrupted or completed round** — before the next LLM call This means there is a natural latency between sending a plain steering message and the agent seeing it. If the agent is in the middle of a long-running shell command, the message waits until that command finishes and the next LLM turn begins. Use interrupting steering when the current round should stop before the message is delivered. Multiple steering messages sent in quick succession are all delivered together at the next drain point. ## Steering vs. human gates Steering and [human gates](/workflows/human-in-the-loop) serve different purposes: | | Steering | Human gates | | - | - | - | | **When** | Any time during an agent stage | At a defined point in the workflow graph | | **Blocks execution** | No — agent continues working | Yes — workflow pauses until a choice is made | | **Defined in the graph** | No — sent ad hoc via the API | Yes — `hexagon` nodes with edge options | | **Use case** | Course corrections, hints, focus changes | Approval decisions, strategy selection, go/no-go | Use human gates for structured decisions that are part of the workflow design. Use steering for reactive guidance when you're watching a run and want to intervene. Fabro web UI Files Changed tab showing a side-by-side diff # VNC Access Source: https://docs.fabro.sh/human-tools/vnc-access Access graphical desktop environments in sandboxes via VNC VNC access is **coming soon** and is not yet available. This page describes the planned feature. VNC (Virtual Network Computing) provides a graphical desktop environment inside sandboxes, accessible directly from your browser. This enables interaction with GUI applications, visual debugging, and observation of agent-driven desktop automation. ## Use cases * **GUI application testing** — interact with desktop apps built or modified by an agent * **Browser testing** — verify web applications in a full browser environment inside the sandbox * **Visual debugging** — inspect graphical output, rendered documents, or UI state * **Agent observation** — watch an AI agent perform automated desktop interactions ## How it works Sandboxes support VNC through a set of pre-installed packages (Xvfb, xfce4, x11vnc, noVNC) that provide a headless desktop environment with browser-based access. When VNC is enabled: 1. Start a run with VNC enabled on a cloud sandbox 2. Open a browser-based VNC viewer to see the sandbox desktop 3. Interact with the desktop using mouse and keyboard — or watch an agent automate it ## Example workflow VNC is useful when the agent needs to interact with a GUI application that can't be driven through a CLI alone. This workflow automates filling out a legacy internal web form that requires browser interaction: VNC access workflow: Start → Prepare Data → Fill Portal Form → Review Submission → Exit, with a Redo loop back to Fill Portal Form ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph FillLegacyForm { graph [goal="Submit the quarterly compliance data through the internal portal"] start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] prepare [label="Prepare Data", prompt="Read the compliance data from data/quarterly-compliance.csv and format it for entry into the internal portal."] fill [label="Fill Portal Form", prompt="Open Firefox, navigate to http://compliance.internal:8080, and fill out the quarterly submission form with the prepared data. Take a screenshot after each page."] review [shape=hexagon, label="Review Submission"] start -> prepare -> fill -> review review -> exit [label="[A] Submit"] review -> fill [label="[R] Redo"] } ``` # VPN Connections Source: https://docs.fabro.sh/human-tools/vpn-connections Connect sandbox environments to private networks via VPN VPN connections are **coming soon** and not yet available. This page describes the planned feature. VPN connections enable sandboxes to connect to private networks, giving agents access to internal services, databases, and APIs that aren't reachable over the public internet. ## Use cases * **Access private services** — connect to internal APIs, databases, or staging environments behind a corporate firewall * **Test against production-like infrastructure** — reach services on a private network without exposing them publicly * **Team collaboration** — share a VPN network so sandboxes and developer machines can communicate directly ## Supported providers | Provider | Authentication | Best for | | - | - | - | | **Tailscale** | Browser login or auth key | Mesh networking, zero-config connectivity | | **OpenVPN** | Client configuration file | Corporate VPNs, existing OpenVPN infrastructure | ## How it works When VPN is enabled, Fabro installs the VPN client inside the sandbox, connects to the specified network, and verifies connectivity before the workflow begins. Once connected, the sandbox receives a VPN IP address and can reach all resources on the private network. ### Tailscale Tailscale provides two authentication methods: * **Auth key** — non-interactive authentication suitable for automated runs and CI/CD pipelines. You provide a Tailscale auth key and the sandbox connects without manual intervention. * **Browser login** — interactive authentication where Fabro generates a login URL. You visit the URL in your browser to authorize the sandbox device on your Tailscale network. ### OpenVPN OpenVPN uses a client configuration file (`.ovpn`) containing connection parameters, certificates, and encryption settings. The sandbox installs the OpenVPN client, uploads the configuration, and connects in the background. ## Example workflow With a VPN connection configured, agents can reach internal services like a private database. This workflow investigates a performance issue by querying the staging database directly: VPN workflow: Start → Analyze Slow Queries → Fix Query → Review Changes → Exit, with a Revise loop back to Fix Query ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph InvestigateSlowQuery { graph [goal="Identify and fix the slow query causing timeouts on the orders page"] start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] analyze [label="Analyze Slow Queries", prompt="Connect to the staging database at staging-db.internal:5432 and run EXPLAIN ANALYZE on the queries used by the orders page. Identify the slowest query."] fix [label="Fix Query", prompt="Add or modify indexes and rewrite the slow query to resolve the performance issue. Verify the fix with EXPLAIN ANALYZE."] review [shape=hexagon, label="Review Changes"] start -> analyze -> fix -> review review -> exit [label="[A] Approve"] review -> fix [label="[R] Revise"] } ``` ## Verification After connecting, Fabro verifies the VPN connection by checking that the sandbox has a VPN interface and can reach the private network. If the connection fails, the run reports the error before workflow execution begins. # VS Code Source: https://docs.fabro.sh/human-tools/vs-code Open sandbox environments in VS Code for interactive editing and debugging When a Daytona sandbox has [SSH access](/human-tools/ssh-access) enabled, you can connect VS Code directly to the sandbox using the built-in [Remote - SSH](https://code.visualstudio.com/docs/remote/ssh) extension. This gives you a full IDE experience — editing, terminal, debugging, port forwarding — inside the same environment where your workflow agents are running. VS Code remote access requires [SSH access](/human-tools/ssh-access), which is only available with the Daytona sandbox provider. ## Prerequisites * [VS Code](https://code.visualstudio.com/) with the [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) extension installed * A running Daytona sandbox with SSH access enabled ## Connecting to a sandbox 1. Start a workflow with a preserved Daytona sandbox: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro --environment cloud --preserve-sandbox ``` 2. Use `fabro sandbox ssh` to get the connection command: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro sandbox ssh --print ``` 3. In VS Code, open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) and run **Remote-SSH: Connect to Host...** 4. Paste the hostname from the SSH command (e.g. `daytona@fabro-20260307-143022-a3f2.ssh.daytona.io`) 5. Once connected, open the workspace folder at `/home/daytona/workspace` VS Code installs its server component on the sandbox automatically. After the initial setup, you have full IDE access to the remote environment. ## What you can do Once connected, VS Code operates as if the sandbox filesystem were local: * **Edit files** — Full IntelliSense, code navigation, and refactoring on the sandbox filesystem * **Terminal** — Open integrated terminals that run directly on the sandbox * **Debug** — Attach debuggers to processes running in the sandbox using standard launch configurations * **Port forwarding** — Access web servers and other services running in the sandbox from your local browser * **Extensions** — VS Code extensions install on the sandbox automatically, so linters, formatters, and language servers work as expected ## Tips * **Use `--preserve-sandbox`** — Without it, the sandbox is destroyed when the workflow finishes and your VS Code session disconnects. Combine with `auto_stop_interval` in your [run config](/execution/run-configuration) to control idle timeout. * **Pair with human gates** — When a workflow pauses at a [human gate](/workflows/human-in-the-loop), connect via VS Code to review the agent's changes before approving. * **SSH credential lifetime** — Daytona SSH credentials expire after 60 minutes by default. If your VS Code session disconnects, run `fabro sandbox ssh ` again to get fresh credentials (use `--ttl` to set a custom expiry). # Amazon Bedrock Source: https://docs.fabro.sh/integrations/bedrock Route Fabro models through Amazon Bedrock with SigV4 or API-key auth [Amazon Bedrock](https://aws.amazon.com/bedrock/) hosts Anthropic, Amazon, Meta, Mistral, DeepSeek, Qwen, Moonshot, Z.AI, MiniMax, NVIDIA, and OpenAI open-weight models behind one AWS endpoint. Fabro ships a disabled `bedrock` provider entry with a curated model catalog over Bedrock's unified Converse API, so you can opt in from `settings.toml` without changing Fabro code. ## Prerequisites * An AWS account with [Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) granted for the models you want (see [Model access and approvals](#model-access-and-approvals)) * Either a [Bedrock API key](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) or working AWS credentials (environment keys, profile, IMDS, IRSA, SSO) ## Model access and approvals Access is granted per Region and varies by model family — enabling the provider in Fabro is necessary but not sufficient. * **IAM.** Converse and ConverseStream have no dedicated IAM actions; they're authorized by `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream`. A Bedrock API key additionally needs `bedrock:CallWithBearerToken`. * **Anthropic (Claude) models** require a one-time use-case submission in the Bedrock console (**Model access**) before first use, and the grant is **per Region** — approval in `us-east-1` does not cover `us-east-2`. An un-approved Region returns `AccessDeniedException`. * **Third-party models** (OpenAI gpt-oss, DeepSeek, Qwen, Moonshot, Z.AI, MiniMax, NVIDIA) auto-enable on first call, which needs `aws-marketplace:Subscribe` and `aws-marketplace:ViewSubscriptions` on the calling principal. The first call may take a moment while the subscription activates. * **Claude Fable 5 / Mythos-class** models additionally require opting into data sharing via the [Data Retention API](https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html) (`provider_data_share`, 30-day retention) **before** they can be invoked. With the account/project on the `default` retention mode, Converse rejects the request with *"data retention mode 'default' is not available for this model."* ## Enable the provider Add the provider override to `~/.fabro/settings.toml`: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [llm.providers.bedrock] base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" enabled = true ``` The SigV4 signing region is derived from `base_url` — change it to your Region's endpoint (`https://bedrock-runtime..amazonaws.com`, FIPS and China endpoints included). ## Configure credentials Two auth modes, tried in order: **Bedrock API key** (simplest): store the key and Fabro sends it as a bearer token. The key is read from either `AWS_BEARER_TOKEN_BEDROCK` (AWS's canonical name, also honored by the AWS SDKs and CLI) or `BEDROCK_API_KEY` (the `_API_KEY` convention) — use whichever you prefer. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set AWS_BEARER_TOKEN_BEDROCK bedrock-api-key-... # or, equivalently fabro secret set BEDROCK_API_KEY bedrock-api-key-... ``` Runs read the bearer token from the vault only. Workers start from a cleared environment and the bearer token is not on the inherited allowlist, so exporting it in the server's shell has no effect on runs. `fabro exec` and direct `fabro-llm` SDK usage do read it from process env. **AWS SigV4** (IAM-scoped): with no API key configured, Fabro signs each request using the AWS default credential chain — environment keys, shared profile, EC2/ECS instance roles, IRSA/web identity, SSO. Expiring session credentials refresh automatically. The order is fixed by lithos-llm: `AWS_BEARER_TOKEN_BEDROCK`, then `BEDROCK_API_KEY`, then the AWS default chain. Each name is read from the process environment first and the server vault (`fabro secret set`) second — so on a server, prefer `secret set`. To select a non-default AWS profile for SigV4, set `AWS_PROFILE` (it, and the rest of the AWS credential-chain variables, are passed through to workflow workers). **Bearer-vs-SigV4 precedence.** Because the bearer key is tried before SigV4, setting `AWS_BEARER_TOKEN_BEDROCK` makes the `bedrock` (Converse) provider authenticate with that key too — not just the `bedrock-openai` mantle provider below. If your key is valid only for mantle (it lacks `bedrock:InvokeModel*` on the runtime), every Converse model then fails with *"Authentication failed."* Use a key that covers both surfaces, or keep Converse on SigV4 by leaving both Bedrock secret names unset and enabling only `bedrock`. ## Included models The built-in catalog curates Converse-capable models, using cross-region inference profile ids (`us.`/`global.` prefixes) where on-demand access requires them: | Fabro model ID | Notes | | - | - | | `us.anthropic.claude-sonnet-4-6` | Provider default; Anthropic cache billing | | `us.anthropic.claude-opus-4-8` | Anthropic cache billing | | `us.anthropic.claude-haiku-4-5` | Provider small default | | `us.anthropic.claude-fable-5` | Frontier; sampling params pinned by Bedrock (Fabro drops `temperature`/`top_p` automatically); requires the account-level `provider_data_share` data-sharing opt-in (see [Model access](#model-access-and-approvals)) | | `openai.gpt-oss-120b`, `openai.gpt-oss-20b` | OpenAI open-weights | | `amazon.nova-2-lite` | Vision | | `meta.llama4-maverick` | Vision | | `mistral.mistral-large-3`, `mistral.devstral-2` | | | `deepseek.v3-2` | | | `moonshotai.kimi-k2.5`, `zai.glm-5` | | | `minimax.minimax-m2.5`, `nvidia.nemotron-3-super` | | Any other Converse-capable Bedrock model can be added under `[llm.providers.bedrock.models.""]` with the Bedrock model or inference-profile id as `api_model`. Not included on this provider: Claude Mythos 5 (Anthropic-Messages-only on `bedrock-mantle`, limited preview). OpenAI's frontier models live on the companion `bedrock-openai` provider below. ## OpenAI frontier models (GPT-5.5 / GPT-5.4) GPT-5.5 and GPT-5.4 on Bedrock are served only by the `bedrock-mantle` endpoint's OpenAI Responses API — a different surface than Converse. Fabro ships a companion `bedrock-openai` provider for them: the same AWS account and `AWS_BEARER_TOKEN_BEDROCK` key, pointed at the mantle endpoint over the OpenAI dialect. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.bedrock-openai] enabled = true # regional: change to https://bedrock-mantle..api.aws/openai/v1 ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model test --model openai.gpt-5.5 ``` Auth on this provider is Bedrock-API-key only (mantle SigV4 uses a different signing name than the runtime endpoint). Fabro always sends `store: false`, so nothing is retained under mantle's default 30-day response storage. ## Use Bedrock models ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --provider bedrock fabro model test --model us.anthropic.claude-sonnet-4-6 fabro run workflow.fabro --model deepseek.v3-2 ``` ## Prompt caching Claude models cache automatically when the catalog row declares `prompt_cache`: Fabro places Converse `cachePoint` blocks after the system prompt, the tool definitions, and the conversation prefix — the same placement as the direct Anthropic provider. Cache reads and writes price at the row's `cached_input_usd_micros_per_million` and `cache_write_usd_micros_per_million` rates. ## Converse extensions Bedrock-specific request fields pass through verbatim via `provider_options.bedrock` on API/SDK requests — the keys merge into the top level of the Converse envelope: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "model": "us.anthropic.claude-sonnet-4-6", "provider_options": { "bedrock": { "additionalModelRequestFields": { "top_k": 200 }, "guardrailConfig": { "guardrailIdentifier": "gr-abc", "guardrailVersion": "1" }, "serviceTier": { "type": "flex" } } } } ``` ## Troubleshooting **"no AWS credentials provider found"** — Neither an API key nor any AWS chain source resolved. Set `AWS_BEARER_TOKEN_BEDROCK` (or `BEDROCK_API_KEY`), or configure standard AWS credentials. **`AccessDeniedException` / 403** — The IAM principal lacks `bedrock:InvokeModel*` for the model, or model access has not been granted in the Bedrock console for your Region (Claude needs the per-Region use-case approval; third-party models need `aws-marketplace:Subscribe`). **"Authentication failed: Please make sure your API Key is valid."** — A Bedrock API key was sent but rejected by the runtime. Common cause: a mantle-scoped key used against Converse — see the bearer-vs-SigV4 [warning above](#configure-credentials). Verify the key is valid for `bedrock-runtime` in this Region, or pin Converse to `aws_sigv4`. **"data retention mode 'default' is not available for this model"** — Fable 5 / Mythos-class models require opting into data sharing first; see [Model access and approvals](#model-access-and-approvals). **"The provided model identifier is invalid"** — The wire id sent to Bedrock isn't a recognized model or inference-profile id. Set an explicit `api_model` (from `aws bedrock list-inference-profiles`) on the model entry. **`ValidationException` mentioning on-demand throughput** — The model requires an inference-profile id; use the `us.`/`global.`-prefixed id from the catalog rather than the bare model id. **`ValidationException` mentioning maximum tokens** — The requested `max_tokens` exceeds the model's per-request output cap; lower the model's `max_output` to the documented limit. **`ThrottlingException`** — Account-level Bedrock quota; consider cross-region inference profiles or a quota increase. ## Further reading How Fabro routes model IDs, providers, and fallbacks. Full reference for provider settings and provider-scoped model offerings. # Brave Search Source: https://docs.fabro.sh/integrations/brave-search Give Fabro agents web search capabilities via the Brave Search API Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. It uses the [Brave Search API](https://brave.com/search/api/) to return titles, URLs, and descriptions for any query. Fabro selects the backend from the credentials in its vault. Direct Brave Search is preferred whenever `BRAVE_SEARCH_API_KEY` is present. When that key is absent, Fabro can use [Venice Search](/integrations/venice-search) with `VENICE_API_KEY` instead. ## Setup 1. Get a Brave Search API key from the [Brave Search API dashboard](https://brave.com/search/api/) 2. Store it on the Fabro server: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set BRAVE_SEARCH_API_KEY BSA... ``` 3. Verify the key is working: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro doctor ``` The doctor output should show **Web Search** as `brave: configured and reachable`. If the Brave key is missing but a Venice key exists, Fabro checks Venice instead. If neither key exists, web search is reported as a warning. Workflows still run, but the `web_search` tool is omitted from the agent's tool set and its system prompt. The Fabro server reads this key from the vault only. It does not read `BRAVE_SEARCH_API_KEY` from process env or `server.env`. ## How it works Agents call the `web_search` tool with a query string. Fabro sends the query to the Brave Web Search API (`/res/v1/web/search`) and returns numbered results with title, URL, and description: ``` 1. Rust Lang https://rust-lang.org A systems language 2. Rust Book https://doc.rust-lang.org/book The Rust book ``` If `BRAVE_SEARCH_API_KEY` is not configured, Fabro uses Venice when `VENICE_API_KEY` is available. If neither key is configured, the tool is not registered. See the [`web_search` tool reference](/agents/tools#web_search) for parameters and details. ## Permissions `web_search` is classified as a `shell` category tool, requiring the `full` [permission level](/agents/permissions) for auto-approval. At lower permission levels: * **Interactive mode** — the user is prompted to approve each call * **Non-interactive mode** (`--auto-approve`) — calls are denied ## Example workflow A workflow that researches a topic before writing about it: Research workflow: Start → Research → Summarize → Exit ```dot title="research.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Research { graph [goal="Research and summarize a topic"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] research [label="Research", prompt="Use web_search to find recent information about {{ goal }}. Save your findings to research.md."] summarize [label="Summarize", shape=tab, prompt="Read research.md and write a concise summary of the key findings."] start -> research -> summarize -> exit } ``` ## Troubleshooting **"BRAVE\_SEARCH\_API\_KEY is not configured"** — Add the key with `fabro secret set BRAVE_SEARCH_API_KEY `. Run `fabro doctor` to verify. **"Brave Search API returned status 401"** — The API key is invalid or expired. Generate a new key from the [Brave Search API dashboard](https://brave.com/search/api/). **"Brave Search API returned status 429"** — Rate limit exceeded. The Brave Search free tier has usage limits. Upgrade your plan or reduce the frequency of `web_search` calls. ## Further reading Full `web_search` tool reference — parameters, output format, and error handling. How tool permissions control web search access. # Daytona Source: https://docs.fabro.sh/integrations/daytona Run Fabro workflows in sandboxed Daytona cloud environments [Daytona](https://daytona.io) provides cloud-hosted sandbox VMs for Fabro workflows. Each run gets an ephemeral, isolated environment with its own filesystem, network, and compute resources — keeping your host machine clean and giving agents a reproducible workspace. ## What the Daytona integration enables | Feature | How it's used | | - | - | | **Sandboxed execution** | Agent tool calls (shell commands, file edits, grep, glob) run inside a cloud VM instead of on the host | | **Snapshots** | Pre-built environment images so each run starts with dependencies already installed | | **SSH access** | Connect to a running sandbox for live debugging | | **Network controls** | Restrict agent egress with block-all or CIDR-based allow lists | | **MCP sandbox transport** | Run [MCP servers](/agents/mcp#sandbox) inside the sandbox — e.g., Playwright for browser automation | ## Prerequisites * A `DAYTONA_API_KEY` saved in the Fabro server vault (get one from [app.daytona.io](https://app.daytona.io)) * GitHub access configured via the default `token` strategy or a [GitHub App](/integrations/github) (required for private repository cloning and checkpoint pushing) The Daytona key must include the snapshot and sandbox scopes Fabro uses to create and clean up environments: `write:snapshots`, `delete:snapshots`, `write:sandboxes`, and `delete:sandboxes`. Fabro validates these scopes during install, when you run `fabro secret set DAYTONA_API_KEY`, and in `fabro doctor`. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set DAYTONA_API_KEY ``` The Fabro server runtime reads the Daytona API key from the vault only. It does not read `DAYTONA_API_KEY` from process env or `server.env`; non-secret Daytona settings such as API URL or organization ID remain normal configuration. ## Configuration Select a Daytona environment in your run config TOML or via CLI flag: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro --environment cloud ``` ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment] id = "cloud" [environments.cloud] provider = "daytona" ``` A full configuration example with all Daytona-specific options: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment] id = "cloud" [environments.cloud] provider = "daytona" [environments.cloud.lifecycle] preserve = false auto_stop = "60m" [environments.cloud.labels] project = "fabro" env = "staging" team = "platform" Fabro also adds reserved labels to every managed Daytona sandbox: `sh.fabro.managed=true` and, when available, `sh.fabro.run_id=`. These match Docker sandbox labels and are useful for filtering provider resources back to Fabro-managed runs. User-provided values for these reserved keys are overwritten. [environments.cloud.image] docker = "rust:1.85-slim-bookworm" # Or replace docker with an inline or path-based Dockerfile: # dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep" # dockerfile = { path = "./Dockerfile" } [environments.cloud.resources] cpu = 4 memory = "8GB" disk = "20GB" ``` See [Environments](/execution/environments) for the full reference on all environment fields and server defaults. ### Network access control Control outbound network access with the `network` field. Three modes are available: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} # Full access (default) [environments.cloud.network] mode = "allow_all" # Block all egress [environments.cloud.network] mode = "block" # CIDR-based allow list [environments.cloud.network] mode = "cidr_allow_list" allow = ["208.80.154.232/32", "10.0.0.0/8"] ``` Use `"block"` or a CIDR allow list when running untrusted or generated code to prevent agents from making arbitrary network requests. ## Snapshots Snapshots let you pre-build an environment image so each run starts with dependencies already installed rather than installing them in setup commands every time. ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.cloud.image] docker = "node:20-slim" [environments.cloud.resources] cpu = 4 memory = 8 disk = 20 ``` Set either `image.docker` or `image.dockerfile`. `image.docker` can name any image that Daytona can pull, so a Dockerfile is not required. Use `image.dockerfile` when the image needs extra packages or other build steps: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.cloud.image] dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git" ``` The sandbox driver builds the image or Dockerfile into a Daytona snapshot named by its inputs (the image reference or Dockerfile text, the resources, and the Daytona API key) and creates the sandbox from it. If that snapshot already exists, it is reused immediately; otherwise the driver builds it and waits for it to reach `Active` state. A Dockerfile can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. The exact `image.docker` value is part of the snapshot identity. Prefer a digest such as `registry.example.com/team/image@sha256:...` when the image must be reproducible. If a mutable tag moves without its text changing, the existing snapshot continues to be reused. If neither image source is configured, sandboxes are created from the `daytona-medium` snapshot, which includes standard dev tools such as Git. To force a new Dockerfile snapshot, change the Dockerfile text, for example by adding a comment. ## Private repositories Fabro automatically clones the run's GitHub origin into the sandbox at `/home/daytona/workspace`. Public repositories work without extra configuration. Private repositories require GitHub access. In `token` mode, Fabro uses the stored token directly. In `app` mode, Fabro uses short-lived Installation Access Tokens scoped to the specific repository. Set `[run.clone] enabled = false` when a workflow should start with an empty Daytona workspace instead of cloning the run origin: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment] id = "cloud" [environments.cloud] provider = "daytona" [run.clone] enabled = false ``` Daytona clones 100 commits by default. To keep only the newest commit, set a smaller clone depth: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.clone] depth = 1 ``` Set `depth = 0` to clone the full repository history. If the clone fails without GitHub access configured, Fabro suggests running the setup flow: ``` Git clone failed: ... If this is a private repository, run `gh auth login` or `fabro install` to configure GitHub access. ``` ## SSH access Connect to a running Daytona sandbox via SSH for live debugging: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro sandbox ssh ``` This creates temporary SSH credentials (valid for 60 minutes) and connects directly. Use `--print` to print the SSH command instead of connecting, or `--ttl` to set the credential expiry. To keep the sandbox alive after the run completes, pass `--preserve-sandbox` to `fabro run`. ## Sandbox lifecycle Each sandbox gets a unique timestamped name (e.g. `fabro-20260307-143022-a3f2`) and is created as ephemeral. By default, sandboxes are destroyed when the run finishes. ### Preservation To keep a sandbox alive for debugging: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro --environment cloud --preserve-sandbox ``` Or in the run config: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.environment] id = "cloud" [environments.cloud] provider = "daytona" [environments.cloud.lifecycle] preserve = true ``` When preserved, Fabro prints the sandbox name so you can find it in the [Daytona dashboard](https://app.daytona.io/dashboard/sandboxes). ### Auto-stop The `lifecycle.auto_stop` setting tells Daytona to stop the sandbox after a period of inactivity, saving costs for preserved or long-running sandboxes: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.cloud.lifecycle] auto_stop = "30m" ``` When `auto_stop` is unset, Fabro applies a default of 120 minutes so a sandbox leaked by an interrupted run is still reclaimed. Set `auto_stop = "0s"` to disable auto-stop and let the sandbox run indefinitely. Daytona counts inactivity from the last sandbox interaction (a command, file operation, or other API call). Time an agent spends on LLM inference does not touch the sandbox, so intervals shorter than your longest inference call risk stopping the sandbox mid-run. ## Server defaults When running via `fabro server start`, the server config at `~/.fabro/settings.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely). See [Server Configuration](/administration/server-configuration) for details. ## Troubleshooting ### "Failed to create Daytona sandbox" The `DAYTONA_API_KEY` vault secret is missing, invalid, or missing the required snapshot/sandbox scopes. Store it with `fabro secret set DAYTONA_API_KEY ...`, then run `fabro doctor` to verify that Daytona reports the key as valid. If doctor reports missing scopes, regenerate the Daytona key with `write:snapshots`, `delete:snapshots`, `write:sandboxes`, and `delete:sandboxes`, then save it again with `fabro secret set DAYTONA_API_KEY`. ### Custom snapshot did not roll Custom Daytona snapshot names (`sandbox-driver-`) are computed by the sandbox driver from the image reference or Dockerfile, the resources, and the Daytona API key. For `image.docker`, use an immutable digest and update it when the image changes. For `image.dockerfile`, change the Dockerfile text under the selected `[environments..image]`. ### "Timed out waiting for snapshot to become active" Snapshot creation took longer than the sandbox driver's build budget. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active. ### Git clone fails for private repositories See [Private repositories](#private-repositories) above. You need a GitHub App configured and installed on the repository's organization or account. ### Stall watchdog kills the run Long-running commands in Daytona sandboxes may trigger the 1800-second stall watchdog if they don't produce events. For workflows with long-running operations, increase the `stall_timeout` in the workflow graph: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [stall_timeout="1200"] } ``` # DeepSeek Source: https://docs.fabro.sh/integrations/deepseek Run DeepSeek V4 Flash and Pro through DeepSeek's direct API or supported gateways [DeepSeek](https://www.deepseek.com/) provides an OpenAI-compatible API for DeepSeek V4. Fabro includes direct access to V4 Flash and V4 Pro. The same Fabro model IDs also work through the optional Fireworks AI and OpenRouter providers. ## Prerequisites * A [DeepSeek Platform](https://platform.deepseek.com/) account * A DeepSeek API key from [platform.deepseek.com/api\_keys](https://platform.deepseek.com/api_keys) * A running Fabro server ## Configure direct access The direct `deepseek` provider is enabled in the built-in catalog. Store its key in the target Fabro server vault: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider login --provider deepseek # For a non-default remote server: fabro provider login --server https://your-fabro.example --provider deepseek # Or set the vault token directly: fabro secret set DEEPSEEK_API_KEY fabro secret --server https://your-fabro.example set DEEPSEEK_API_KEY ``` Standalone SDK usage outside a Fabro server can use an env-backed credential source explicitly: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} export DEEPSEEK_API_KEY= ``` Fabro sends bearer-authenticated Chat Completions requests to `https://api.deepseek.com`. ## Included models | Fabro model ID | DeepSeek API model ID | Context | Max output | Role | | - | - | -: | -: | - | | `deepseek-v4-flash` | `deepseek-v4-flash` | 1,048,576 | 384,000 | Provider default, small default, and connectivity probe; aliases `deepseek`, `deepseek-v4`, `deepseek-flash` | | `deepseek-v4-pro` | `deepseek-v4-pro` | 1,048,576 | 384,000 | Higher-capability V4 model | Both models support text input, tool calling, native reasoning, streaming, JSON output, and automatic prompt caching. They do not support image input. Thinking mode is enabled by default. ## Agent profile DeepSeek V4 uses Fabro's `openai` agent profile on every route. This profile is the closest match for DeepSeek's general coding behavior: it supplies project `AGENTS.md` instructions, standard JSON function tools, and a JSON-compatible file editor on Chat Completions routes. The setting is model-specific, so it remains the same through direct DeepSeek, Fireworks AI, and OpenRouter. Fabro does not use the `gpt56` profile for DeepSeek. That profile has a smaller Codex-specific tool set for GPT-5.6 models. ## Use DeepSeek models ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --provider deepseek fabro model test --provider deepseek --model deepseek-v4-flash --tools fabro run workflow.fabro --provider deepseek --model deepseek ``` In workflow stylesheets: ```dot title="workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [ model_stylesheet=" * { model: deepseek-v4-flash; } .difficult { model: deepseek-v4-pro; } " ] start [shape=Mdiamond, label="Start"] work [label="Implement", prompt="Implement and verify the requested change."] exit [shape=Msquare, label="Exit"] start -> work -> exit } ``` ## Thinking and reasoning effort DeepSeek enables thinking by default at `high` effort. Fabro advertises only effort values that produce a distinct model behavior on each route: | Route | V4 Flash | V4 Pro | | - | - | - | | Direct DeepSeek | `low`, `high`, `max` | `high`, `max` | | Fireworks AI | `high`, `max` | `high`, `max` | | OpenRouter | `low`, `high`, `max` | `high`, `xhigh` | DeepSeek currently maps a V4 Pro request for `low` to `high`; its documentation says this mapping will change in early August 2026. Fireworks maps `low` and `medium` to `high`, and maps `xhigh` to `max`. OpenRouter names the Pro maximum tier `xhigh`. Fabro omits `temperature` and `top_p` for these models because DeepSeek ignores sampling parameters while thinking is enabled. To disable thinking on the direct provider, omit typed `reasoning_effort` and pass DeepSeek's native toggle through provider options: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "provider_options": { "deepseek": { "thinking": { "type": "disabled" } } } } ``` DeepSeek requires `reasoning_content` from an assistant tool call to appear in every later request in that tool-use turn. Fabro captures this content and replays it with the assistant message. This keeps multi-step tool calls valid and prevents DeepSeek's HTTP 400 response for missing reasoning history. ## Prompt caching and pricing DeepSeek applies prefix caching automatically. Fabro reads DeepSeek's `prompt_cache_hit_tokens` usage field and reports cache-read tokens separately from uncached input tokens. The built-in catalog uses DeepSeek's published prices per million tokens: | Model | Uncached input | Cache hit | Output | | - | -: | -: | -: | | `deepseek-v4-flash` | \$0.14 | \$0.0028 | \$0.28 | | `deepseek-v4-pro` | \$0.435 | \$0.003625 | \$0.87 | DeepSeek does not return an in-band dollar cost. Fabro calculates an estimated cost from these catalog rates and the reported token buckets. ## Use a gateway The `deepseek-v4-flash`, `deepseek-v4-pro`, `deepseek`, `deepseek-v4`, and `deepseek-flash` selectors are portable across direct DeepSeek, Fireworks AI, and OpenRouter routes. Use `--provider` or a provider-qualified model selector when you need a specific route. See the [Fireworks AI integration](/integrations/fireworks) and [OpenRouter integration](/integrations/openrouter) for gateway setup and provider-specific pricing. ## Troubleshooting **"No API key configured"** — Store `DEEPSEEK_API_KEY` on the target server with `fabro provider login --provider deepseek`. The server runtime resolves the key from its vault, not from process env. **Unknown model** — Use `deepseek-v4-flash` or `deepseek-v4-pro`. The retired `deepseek-chat` and `deepseek-reasoner` API IDs are not in the Fabro catalog. **A tool continuation returns HTTP 400** — Keep the assistant thinking content in conversation history. Fabro does this automatically when it replays tool-call turns. ## Further reading Official authentication, endpoints, and API reference. Official limits, features, and token prices. Official thinking toggles, effort mappings, and tool-call replay rules. # Fireworks AI Source: https://docs.fabro.sh/integrations/fireworks Run open-weights models on Fireworks AI's serverless inference platform [Fireworks AI](https://fireworks.ai/) serves open-weights models (Kimi, DeepSeek, GLM, Qwen, GPT-OSS, and more) behind an OpenAI-compatible API. Fabro ships an enabled `fireworks` provider entry with a curated model catalog; it needs only an API key, and `settings.toml` can adjust it without changing Fabro code. ## Prerequisites * A [Fireworks AI account](https://fireworks.ai/) with serverless credit * An API key from [app.fireworks.ai/settings/users/api-keys](https://app.fireworks.ai/settings/users/api-keys) ## Enable the provider Fabro runs execute through a Fabro server. Add the provider override to the settings file used by that server. For a local server, this is usually `~/.fabro/settings.toml`; for a remote deployment, update the server host's Fabro settings. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [llm.providers.fireworks] enabled = true ``` ## Configure credentials Store the key in the target Fabro server vault: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider login --provider fireworks # For a non-default remote server: fabro provider login --server https://your-fabro.example --provider fireworks # Or set the vault token directly: fabro secret set FIREWORKS_API_KEY fw_... fabro secret --server https://your-fabro.example set FIREWORKS_API_KEY fw_... ``` Direct SDK usage outside a Fabro server can use an env-backed credential source explicitly: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} export FIREWORKS_API_KEY=fw_... ``` ## Included models The built-in catalog gives Fireworks offerings the same human-facing model slugs used by other providers. Fireworks account-scoped model paths remain opaque `api_model` values: | Fabro model slug | Fireworks API ID / notes | | - | - | | `kimi-k3` | `accounts/fireworks/models/kimi-k3` | | `kimi-k3-fast` | `accounts/fireworks/routers/kimi-k3-fast`; Fast tier at 50% above standard rates | | `kimi-k2.7-code` | `accounts/fireworks/models/kimi-k2p7-code`; provider default | | `kimi-k2.6` | `accounts/fireworks/models/kimi-k2p6` | | `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `accounts/fireworks/models/deepseek-v4-...` | | `glm-5.2` | `accounts/fireworks/models/glm-5p2` | | `minimax-m2.7` | `accounts/fireworks/models/minimax-m2p7` | | `qwen3.7-plus` | `accounts/fireworks/models/qwen3p7-plus` | | `gpt-oss-120b` | `accounts/fireworks/models/gpt-oss-120b` | | `gpt-oss-20b` | `accounts/fireworks/models/gpt-oss-20b`; provider small default | Any other Fireworks serverless model can be added under the provider. Choose a stable Fabro model slug as the table key and put the Fireworks account-scoped path in `api_model` (dots in upstream model names become `p`, e.g. `glm-5.2` → `glm-5p2`): ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.fireworks.models."llama-4-maverick"] display_name = "Llama 4 Maverick" api_model = "accounts/fireworks/models/llama4-maverick-instruct-basic" limits = { context_tokens = 1000000, max_output_tokens = 16384 } capabilities = { text = true, tools = true } ``` Note that Fireworks' `GET /v1/models` endpoint only returns a featured subset of serverless models; a model absent from that list may still be servable. Verify custom additions with `fabro model test`. ## Use Fireworks models ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --provider fireworks fabro model test --provider fireworks --model kimi-k3-fast fabro run workflow.fabro --provider fireworks --model kimi-k3-fast ``` When targeting a non-default remote server, pass the same `--server` value to verification commands: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --server https://your-fabro.example --provider fireworks fabro model test --server https://your-fabro.example --provider fireworks --model kimi-k3-fast ``` In workflow stylesheets: ```dot title="workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [ model_stylesheet=" * { model: fireworks/kimi-k3-fast; } " ] start [shape=Mdiamond, label="Start"] work [label="Work", prompt="Use the configured Fireworks model."] exit [shape=Msquare, label="Exit"] start -> work -> exit } ``` ## Prompt caching Fireworks caches prompt prefixes automatically — no cache breakpoints or request changes are needed. Serverless responses report cached tokens in the usage body, and cached input tokens are billed at a per-model discount (typically 50% or better). Fabro reads the cached-token counts and applies the catalog's `cache_input_cost_per_mtok` rates when estimating costs. ## Costs Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/serverless/pricing). Fireworks does not return in-band billing, so Fabro reports the cost source as `catalog`. `kimi-k3-fast` uses the published 50% Fast tier premium. Other Fast model variants and the Priority service tier are not included in the built-in catalog. ## Troubleshooting **"No API key configured"** — Set the key on the target server with `fabro provider login --provider fireworks` or `fabro secret set FIREWORKS_API_KEY ...`. For direct SDK usage outside a Fabro server, export `FIREWORKS_API_KEY` in the invoking shell. **"provider 'fireworks' is not configured in the server model catalog"** — Confirm the server host's `settings.toml` has `[llm.providers.fireworks]` with `enabled = true`. Fabro live-reloads `settings.toml` within a few seconds; after that, `fabro model list --provider fireworks` against the same server should show the enabled catalog. **402 / insufficient credits** — Serverless inference requires prepaid credit; check your balance in the [Fireworks billing dashboard](https://app.fireworks.ai/settings/billing). **Unknown model** — Confirm the model's `api_model` matches a Fireworks account-scoped model or router path exactly (`accounts/fireworks/models/...` or `accounts/fireworks/routers/...`), then run `fabro model test --model `. Remember that `GET /v1/models` only lists a featured subset, so absence from that list is not conclusive. ## Further reading How Fabro routes model IDs, providers, and fallbacks. Full reference for provider settings and provider-scoped model offerings. # GitHub Source: https://docs.fabro.sh/integrations/github Integrate Fabro with GitHub for repository access and OAuth login Fabro supports two GitHub integration strategies: * `token` — the default for local and individual use. Fabro captures `gh auth token` during `fabro install`, stores it as `GITHUB_TOKEN`, and uses that token directly for repo access, pull requests, and sandbox `GITHUB_TOKEN` injection. * `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, enables browser OAuth, and supports webhook delivery when you configure a [webhook strategy](#webhook-delivery-strategies). `token` changes GitHub integration auth only. It does not provide browser sign-in, so the embedded web UI is disabled when `strategy = "token"`. ## Strategy matrix | Capability | `token` | `app` | | - | - | - | | CLI pull requests | Yes | Yes | | Private repo cloning | Yes | Yes | | Sandbox `GITHUB_TOKEN` | Direct token | Scoped installation token | | Browser sign-in | No | Yes | | Web UI routes | Disabled | Enabled | | Webhooks | No | Strategy-dependent | ## GitHub App mode The rest of this page describes the `app` strategy, which is required for browser auth and for any webhook delivery strategy. | Feature | How it's used | | - | - | | **OAuth login** | Users sign in to the web UI with their GitHub account | | **Private repo cloning** | Daytona and Docker sandboxes fetch private repositories using short-lived, read-only Installation Access Tokens | | **Run branch pushing** | Fabro pushes from the sandbox after each checkpoint and again before a successful run finishes | | **Auto-PR** | When `[run.pull_request] enabled = true` in the [run config](/execution/run-configuration#runpull_request), Fabro opens a PR from the agent's working branch after a successful run | | **Auto-merge** | When `[run.pull_request] auto_merge = true`, Fabro enables GitHub's auto-merge on created PRs so they merge automatically once required checks pass | | **Sandbox GITHUB\_TOKEN** | When `[run.integrations.github.permissions]` are declared at any layer (workflow, project, or user settings), Fabro mints a scoped Installation Access Token and injects it as `GITHUB_TOKEN` in the sandbox | ## Setup ### Prerequisites * A GitHub account (personal or organization) * Shell access to the host that runs the Fabro server * The Fabro web app running (`cd apps/fabro-web && bun run dev`) if you want browser sign-in after setup ### Register the GitHub App with `fabro install` Run the installer on the same machine that will run the Fabro server: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro install ``` When you choose the GitHub App strategy, the CLI opens GitHub with a pre-filled [App Manifest](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) containing: | Permission | Level | Purpose | | - | - | - | | Contents | Write | Clone repos, push run branches and checkpoints | | Metadata | Read | Look up repository installation status | | Pull requests | Write | Create and update PRs from workflows | | Checks | Write | Report workflow status on commits | | Issues | Write | Create issues from workflows | | Emails | Read | Read verified email for OAuth login | | Dependabot alerts | Write | Read and manage repository vulnerability alerts | | Organization projects | Write | Read and update organization Projects V2 | | Packages | Read | Download private GitHub Packages (e.g. npm registry) with the sandbox `GITHUB_TOKEN` | These permissions are included when Fabro registers a new app. For an existing GitHub App, add the missing permissions in the app's settings, then approve the permission update on each installation before workflows can use them. Fabro's App manifest does not request the Workflows permission, so Apps registered through Fabro cannot publish changes under `.github/workflows/`. The installer: 1. Lets you choose where to register the app. If you have the `gh` CLI installed, Fabro detects your GitHub username and any organizations you administer and lets you pick. If `gh` is not available, the installer prompts for a GitHub token and uses it to look up your identity and organizations. 2. Opens GitHub in your browser so you can review the manifest and click **Create GitHub App**. 3. Receives GitHub's temporary callback on localhost, exchanges it for permanent app credentials, and writes: * `app_id`, `client_id`, and `slug` to `~/.fabro/settings.toml` * `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, and `GITHUB_APP_PRIVATE_KEY` to the server vault 4. Prints the resulting app slug so you can install the app on the repositories Fabro should access. After setup completes, restart the Fabro server before attempting browser login. ### Install the app on GitHub Go to `https://github.com/settings/apps//installations` and install it on the repositories Fabro should access. ### Verify the configuration Run the doctor command to check that all GitHub App credentials are in place: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro doctor ``` The GitHub App check verifies five fields: | Field | Source | | - | - | | `server.integrations.github.app_id` | `~/.fabro/settings.toml` | | `server.integrations.github.client_id` | `~/.fabro/settings.toml` | | `GITHUB_APP_CLIENT_SECRET` | server vault | | `GITHUB_APP_WEBHOOK_SECRET` | server vault | | `GITHUB_APP_PRIVATE_KEY` | server vault | If all five are set, the check passes. If none are set, it warns (GitHub integration is optional). If some are set but others are missing, it errors with the specific missing fields. ## Configuration The GitHub App configuration lives in two places: ### `~/.fabro/settings.toml` ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.integrations.github] app_id = "123456" client_id = "Iv1.abc123def" slug = "fabro-a3f2" ``` | Field | Description | | - | - | | `app_id` | Numeric GitHub App ID | | `client_id` | OAuth Client ID for the app | | `slug` | App slug, used for linking to the GitHub App settings page | ### Server vault Fabro stores the GitHub App secrets in the server vault under these keys: * `GITHUB_APP_CLIENT_SECRET` * `GITHUB_APP_WEBHOOK_SECRET` * `GITHUB_APP_PRIVATE_KEY` The private key is stored as base64-encoded PEM. Fabro also accepts raw PEM format (starting with `-----BEGIN`). Install mode writes these automatically. If you rotate them manually, use `fabro secret set` on the server: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set GITHUB_APP_CLIENT_SECRET fabro secret set GITHUB_APP_WEBHOOK_SECRET fabro secret set --type file GITHUB_APP_PRIVATE_KEY ``` ### Webhook delivery strategies Fabro receives GitHub webhooks on `POST /api/v1/webhooks/github` whenever `GITHUB_APP_WEBHOOK_SECRET` is configured. The webhook `strategy` controls how that route becomes reachable from GitHub: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.integrations.github] strategy = "app" app_id = "123456" client_id = "Iv1.abc123def" slug = "fabro-a3f2" [server.api] url = "https://fabro-api.example.com" [server.integrations.github.webhooks] strategy = "server_url" ``` * `server_url`: recommended for production. Fabro assumes `server.api.url` is already publicly reachable and best-effort updates the GitHub App webhook URL to `/api/v1/webhooks/github` each time `fabro server start` runs. * `tailscale_funnel`: opt-in for machines reachable through Tailscale but not through a stable public URL. Fabro runs `tailscale funnel` against the main server port and best-effort updates the GitHub App webhook URL to the resulting Funnel origin. * Unset `strategy`: Fabro still serves the webhook route if the secret is present, but it does not expose the route for you and does not mutate the GitHub App webhook URL. `tailscale_funnel` has host-wide side effects: it changes Tailscale Funnel state on the server and rewrites the GitHub App webhook URL on startup. Use `server_url` when you already have HTTPS and a stable hostname. ### Reconfigure GitHub after install To switch GitHub strategies or re-register the app without re-running the full install wizard, use `fabro install github`: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro install github ``` This walks through just the GitHub setup steps — choosing a strategy, registering an app, or storing a token — and restarts the server when done. Stale settings and secrets from the previous strategy are cleaned up automatically. ## `token` mode Choose **GitHub CLI** in `fabro install` to use the default local-user flow. The installer: 1. Runs `gh auth token` 2. Stores the token as vault secret `GITHUB_TOKEN` 3. Writes `strategy = "token"` under `[server.integrations.github]` After install, Fabro reads `GITHUB_TOKEN` from the server vault only. To update it after install, run: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set GITHUB_TOKEN ``` Short-lived GitHub installation tokens (`ghs_*`) are rejected as static tokens; use a PAT for `token` mode, or use GitHub App mode so Fabro can refresh installation tokens for you. In this mode, Fabro disables the embedded web UI and browser auth routes. Machine API routes and `/health` continue to work. ## How it works ### OAuth login The web app uses the GitHub App's OAuth credentials to authenticate users: 1. User clicks **Sign in with GitHub** on the login page 2. Fabro redirects to GitHub's OAuth authorization endpoint with scopes `read:user` and `user:email` 3. User authorizes the app on GitHub 4. GitHub redirects back with an authorization code 5. Fabro exchanges the code for an access token and fetches the user's profile and verified email 6. Fabro checks the username against the `allowed_usernames` list in `settings.toml` Configure allowed users in `settings.toml`: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.auth.github] allowed_usernames = ["alice", "bob"] ``` An empty `allowed_usernames` list rejects all users. ### Repository cloning in sandboxes When a run targets a GitHub repository, its workspace is checked out inside the sandbox (Docker or Daytona) before the first stage runs: 1. When the run's worker starts, it signs a short-lived JWT using the App ID and private key (RS256, 10-minute validity) 2. Using the JWT, Fabro looks up the GitHub App installation for the repository (`GET /repos/\{owner\}/\{repo\}/installation`) 3. Fabro requests a scoped Installation Access Token with `contents: read` permission on the specific repository 4. Inside the sandbox, Fabro fetches the selected revision from `https://github.com//` at the run's `[run.clone] depth`, presenting the token as an HTTP header on that one command, and checks out the working branch 5. The workspace's `origin` is the plain HTTPS URL: the token is never written into the repository, its configuration, or its remote The files belong to the user the sandbox runs commands as. For public repositories the fetch works without credentials when none are configured. #### Git targets for run intents The `RunIntent` create body always names a GitHub repository and a working branch. It may also select a bare tag, pin a full 40-character commit SHA, or include both: | Target fields | Revision selected when the worker starts | | - | - | | `branch` | The branch HEAD | | `branch` + `sha` | The exact commit | | `branch` + `tag` | The tag's peeled commit | | `branch` + `tag` + `sha` | The exact commit; the tag remains part of the run's identity | `branch` is always the attached branch inside the sandbox. `tag` is a bare tag name such as `v1.2.3`; `refs/tags/v1.2.3` and `tags/v1.2.3` are rejected. An unpinned tag is resolved when the worker starts, so moving a tag before that point changes the selected commit. Creating the run validates the selectors and lowercase-normalizes `sha`, but does not contact GitHub or prove ancestry. An exact SHA is authoritative: Fabro does not prove it belongs to the branch or matches the accompanying tag. If a requested tag or exact commit is unavailable, sandbox setup fails without falling back to a same-named branch or the branch's newer HEAD. ### GITHUB\_TOKEN injection When any settings layer declares `[run.integrations.github.permissions]`, Fabro prepares a scoped GitHub App token source and exposes it as the `GITHUB_TOKEN` environment variable in sandbox command and agent execution. Agents running inside the sandbox can use this token for GitHub API calls and pushes within the granted permissions. The GitHub CLI (`gh`) reads `GITHUB_TOKEN` automatically, so command stages can run `gh pr list`, `gh issue create`, and similar commands without an explicit `gh auth login`. ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.integrations.github.permissions] contents = "write" pull_requests = "write" ``` Only the listed permissions are requested. If no GitHub credentials are configured, Fabro does not inject a managed token. If configured credentials cannot resolve a token, the execution scope fails to initialize. GitHub-target runs also receive Git read access to their origin without declaring an integration permission map. In App mode this default token requests only `contents = "read"`, and it is used by Git's credential helper. Declaring integration permissions additionally supplies `GITHUB_TOKEN` to command and agent processes. The managed token carries exactly the access the run declares, so it replaces any `GITHUB_TOKEN` set in the workflow environment, an ACP agent's environment or the sandbox's own environment. To give a stage different GitHub access, change the declared permissions or repositories instead. One-shot containers a stage runs (such as `docker://` steps) receive the managed `GITHUB_TOKEN` but not the Git credential helper, which reads a store inside the stage's sandbox. In App mode, the token covers only the run's origin repository unless the run declares [additional repositories](#additional-repositories). Injecting `GITHUB_TOKEN` alone does not make other private repositories reachable. ### Additional repositories A run can declare extra GitHub repositories that its stages may access through the same `GITHUB_TOKEN`: ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.integrations.github] additional_repositories = ["fabro-sh/keystone"] permissions = { contents = "read" } ``` The run origin stays implicit — never list it. Each entry is a full `owner/repository` slug (no scheme, host, ref, or extra path component). Fabro mints **one** installation token scoped to the origin plus every declared repository, with the one shared `permissions` map applying to all of them. What works against every declared repository, within the granted permissions: * **`gh` CLI and raw GitHub API calls** through `GITHUB_TOKEN`. * **Plain Git over HTTPS** (`git clone https://github.com/owner/repo`), through a credential helper that reads a private, renewable store outside the workspace. * **The common SSH spellings** `git@github.com:owner/repo[.git]` and `ssh://git@github.com/owner/repo[.git]`, through SSH-to-HTTPS rewrites injected into the stage environment. Fabro does not clone additional repositories for you; a workflow that needs one on disk adds its own clone step (`git clone https://github.com/owner/repo` or `gh repo clone owner/repo`). Requirements and validation: * Every repository in the effective set must share **one owner** and be reachable by the origin repository's GitHub App installation, because one App installation covers one account. Cross-owner declarations fail configuration validation; a same-owner repository outside the installation fails preflight and run initialization with the repository named. * A non-empty `additional_repositories` requires `contents = "read"` or `contents = "write"` in the permission map. * Malformed slugs, duplicates (repository identity is case-insensitive), and sets larger than 499 entries fail configuration validation with indexed error paths. * Unlike permissions-only configuration, declared additional repositories are a hard requirement: missing GitHub credentials, a missing origin, or an inaccessible declared repository fails preflight and run initialization instead of continuing without the token. * Layering: the higher-precedence `additional_repositories` list replaces the lower one wholesale (no union, no `...` splice), and `additional_repositories = []` explicitly clears an inherited list. `permissions` keeps its existing whole-map replacement behavior. If layering leaves repositories declared with permissions cleared, configuration resolution reports the invalid combination. Behavior notes: * **Token strategy (PAT):** the configured PAT is used as-is. The repository list drives validation and preflight probes, but it cannot narrow the PAT's inherent GitHub scope — App mode remains the least-authority option. * **`GH_TOKEN` precedence:** `gh` checks `GH_TOKEN` before `GITHUB_TOKEN`. If the resolved run environment defines `GH_TOKEN`, `gh` uses it instead of the managed token; Fabro never sets or removes `GH_TOKEN`, and preflight warns when additional repositories are declared alongside one. * **GitHub SSH URLs are rewritten to HTTPS.** The helper supplies credentials only for the origin and declared repository paths, including their `.git` spellings. An undeclared private repository does not receive a credential from this helper. #### Security boundary Workflow authors may name any repository reachable by the server's GitHub App installation; Fabro applies no second server-side repository intersection. The token is scoped server-side to exactly the declared set — a request to an undeclared repository fails at GitHub, and Fabro never mints an unscoped installation-wide token. With `contents = "write"`, **any stage can push to any declared repository**. Declare the smallest repository set and the weakest permissions that work. Installation Access Tokens are short-lived. Fabro reuses cached tokens until they are within ten minutes of expiry. Each process launch resolves its current credentials, and a background task checks the Git credential store every minute while the execution scope is acquired. Command stages, native agent tools, ACP agents, and resumed scopes use the same mechanism. A long-lived ACP agent's later Git operations read the renewed store. The token never appears in the repository's remote URL, Git configuration, or workspace snapshots; the private store is removed when the scope releases. Background refresh failures are logged and retried, and a still-valid cached token remains usable. `GITHUB_TOKEN` is a process environment variable, so a process that is already running keeps its launch-time value. The renewable Git helper continues to work across token rotation, but a long-running process that calls the GitHub API through `GITHUB_TOKEN` must restart or obtain a new token separately. Static PATs cannot be renewed by Fabro. Publication uses a separate token source with the permissions needed to push and open pull requests. The permissions table follows the standard layer-merge order (workflow > project > user > defaults). Set defaults at `[run.integrations.github.permissions]` in `~/.fabro/settings.toml` so every run inherits a baseline; tighten or override per-workflow as needed. A higher layer that defines `permissions = {}` clears the inherited map (no token requested). #### Security model The upper bound on what Fabro will mint is whatever permissions the GitHub App installation has been granted. Fabro does **not** impose a separate server-side cap on the run-level `permissions` map: any value the App has been granted can be requested by run config. Operators must not run untrusted workflow, project, or user TOML against a broadly-scoped GitHub App installation. Preflight prints the resolved permission set so reviewers can see what each run will request. ### Commit attribution Every commit a run creates is authored and committed by the run's GitHub credential identity. In App mode that is the App's bot account, `[bot] `, so GitHub attributes checkpoint commits and workflow-created commits to the App. In token mode it is the token's user with their GitHub noreply address. Fabro resolves the identity once per run and passes it to every prepare step, command stage, agent tool, and ACP agent as `GIT_AUTHOR_*` / `GIT_COMMITTER_*` variables, so a workflow that clones or initializes another repository commits as the same identity without configuring Git itself. `[run.git.author]` overrides either field; see [server configuration](/administration/server-configuration#rungitauthor-section). ### Checkpoint pushing After each workflow stage, Fabro [checkpoints](/execution/checkpoints) the workspace on the run branch, `fabro/run/`, and pushes it directly from that workspace. Fabro keeps no second Git repository or checkpoint bundles on the server. In App mode, the worker pushes with an Installation Access Token with `contents: write`, reusing one token until it nears expiry and then minting the next, and passes it only to the sandbox Git command. The token is not saved in the repository's remote URL or configuration. `[run.run_branch] push = false` keeps the branch only in the run workspace. An intermediate push failure logs a warning; later checkpoints and final publication retry the push. After a successful run's last stage and before the run finishes, Fabro awaits one final push. When pull request creation is enabled and the run changed files, Fabro then checks that GitHub reports the run branch at the exact final commit and opens the pull request. A failed final push, publication preparation, branch check, or PR creation marks the run as failed with `publish_failed`; the terminal run event is emitted only after this step finishes. ## Troubleshooting ### "GitHub App is not installed for \{owner}" The GitHub App exists but hasn't been installed on the organization or user account that owns the repository. Install it at: ``` https://github.com/organizations/{owner}/settings/installations ``` Or for personal accounts: ``` https://github.com/settings/installations ``` ### "GitHub App is private but this repo belongs to a different owner" GitHub Apps are **private by default**, meaning they can only be installed on repositories owned by the same account or organization that owns the app. If you need to install the app on a repository owned by a different user or organization, you must make the app public: 1. Go to **GitHub → Settings → Developer settings → GitHub Apps → \{your app}** 2. Scroll to the bottom under **Danger zone** 3. Click **Make public** Once the app is public, anyone with the installation link can install it on their repositories. This does not grant the app any additional permissions — repository owners still choose which repositories the app can access during installation. ### "GitHub App installation is suspended" The installation was disabled in GitHub's settings. Re-enable it in the organization's GitHub App settings. ### "GitHub App does not have access to repository \{repo}" The app is installed but doesn't have access to this specific repository. Update the installation's repository permissions to include it (the app may be configured for "Only select repositories"). ### "GitHub App authentication failed" The `app_id` in `settings.toml` or the `GITHUB_APP_PRIVATE_KEY` vault secret is incorrect. Re-run `fabro install` on the server host or verify the values match your GitHub App. ### Clone fails for private repositories If you see `Git clone failed ... If this is a private repository, configure a GitHub App`, the GitHub App credentials are not configured. Run `fabro install` on the server host or verify with `fabro doctor`. # LiteLLM Source: https://docs.fabro.sh/integrations/litellm Route Fabro models through a LiteLLM proxy [LiteLLM](https://docs.litellm.ai/) can run as an OpenAI-compatible proxy in front of many model providers. Fabro includes a disabled `litellm` provider entry so you can opt in from `settings.toml` without changing Fabro code. ## Prerequisites * A running LiteLLM proxy reachable from the Fabro process * At least one LiteLLM model name you want Fabro to route to * A LiteLLM key or placeholder key available to Fabro Fabro's built-in LiteLLM provider points at `http://localhost:4000/v1`. Change `base_url` if your proxy is hosted elsewhere. ## Enable the provider Add the provider override and one or more model entries to `~/.fabro/settings.toml`: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [llm.providers.litellm] base_url = "http://localhost:4000/v1" default_model = "litellm-gpt-5" enabled = true [llm.providers.litellm.models."litellm-gpt-5"] display_name = "LiteLLM GPT-5" api_model = "gpt-5" limits = { context_tokens = 128000, max_output_tokens = 8192 } capabilities = { text = true, tools = true } ``` `api_model` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy. ## Configure credentials For server-backed runs, store `LITELLM_API_KEY` in the Fabro server vault. For a server-owned secret: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set LITELLM_API_KEY sk-proxy-key ``` `fabro exec` and direct `fabro-llm` SDK usage can use an env-backed credential source explicitly: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} export LITELLM_API_KEY=sk-proxy-key ``` If your local LiteLLM proxy does not enforce authentication, use a placeholder value such as `anything`; the OpenAI-compatible client still needs a credential value. ## Use LiteLLM models Once the provider is enabled and at least one model is declared, use the Fabro model ID like any other catalog model: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --provider litellm fabro model test --model litellm-gpt-5 fabro run workflow.fabro --model litellm-gpt-5 ``` In workflow stylesheets: ```dot title="workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [ model_stylesheet=" * { model: litellm-gpt-5; } " ] start [shape=Mdiamond, label="Start"] work [label="Work", prompt="Use the configured LiteLLM model."] exit [shape=Msquare, label="Exit"] start -> work -> exit } ``` ## Declaring more models Declare each LiteLLM-routed model explicitly so Fabro knows its provider, context window, tool support, and routing defaults: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.litellm.models."litellm-fast"] display_name = "LiteLLM Fast" aliases = ["fast"] api_model = "fast-model" limits = { context_tokens = 64000, max_output_tokens = 4096 } capabilities = { text = true, tools = true } ``` The provider's `default_model` names its default. You may also mark one small utility model with `small_default = true`; Fabro uses it for metadata tasks such as generated run titles and falls back to the provider default when it is omitted. ## Troubleshooting **"No API key configured"** — For runs, set `vault:LITELLM_API_KEY` with `fabro secret set LITELLM_API_KEY ...`. Exporting it in the server's shell has no effect on runs: workers start from a cleared environment and provider keys are not inherited. For `fabro exec` or direct SDK usage, export `LITELLM_API_KEY` in the invoking shell and use an env-backed credential source. **Connection refused** — Confirm the LiteLLM proxy is running and that `base_url` is reachable from the Fabro process. For Docker deployments, `localhost` means the Fabro container unless you point it at a host or service name. **Unknown model from LiteLLM** — Check that the model's `api_model` matches the model name configured in LiteLLM, then run `fabro model test --model `. ## Further reading How Fabro routes model IDs, providers, and fallbacks. Full reference for provider settings and provider-scoped model offerings. # Modal Source: https://docs.fabro.sh/integrations/modal Run Kimi K3 through Modal's OpenAI-compatible inference endpoints [Modal](https://modal.com/) serves Kimi K3 through an OpenAI-compatible Shared API and through dedicated Auto Endpoints. Fabro ships a disabled `modal` provider entry for Kimi K3. Enable it after Modal gives you an endpoint URL. ## Prerequisites * A [Modal account](https://modal.com/signup) * A [Kimi K3 Shared API or Auto Endpoint](https://modal.com/library/moonshot/kimi-k3) * A Modal proxy-token pair ## Create or select an endpoint Use the Kimi K3 Shared API from the Modal model library, or create a dedicated Auto Endpoint: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} modal endpoint create --model moonshotai/Kimi-K3 ``` Find the endpoint URL in the Modal dashboard or with `modal endpoint list`. Modal serves its OpenAI-compatible API under `/v1`. ## Create a proxy token Modal endpoints are authenticated with two headers. Create a proxy-token pair: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} modal workspace proxy-tokens create ``` The command prints a token ID that starts with `wk-` and a secret that starts with `ws-`. Modal shows the secret only once, so save both values immediately. If your Modal workspace uses RBAC, allow the token in the endpoint's environment: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} modal workspace proxy-tokens allow wk-... main ``` ## Enable the provider Add the provider override to the settings file used by the Fabro server. Include `/v1` in the endpoint URL and omit a trailing slash. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [llm.providers.modal] base_url = "https://your-endpoint.modal.run/v1" enabled = true ``` The endpoint URL is not built into Fabro because Modal assigns it to your Shared API or Auto Endpoint. ## Configure credentials Store both proxy-token values in the target Fabro server vault: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set MODAL_TOKEN_ID wk-... fabro secret set MODAL_TOKEN_SECRET ws-... # For a non-default remote server: fabro secret --server https://your-fabro.example set MODAL_TOKEN_ID wk-... fabro secret --server https://your-fabro.example set MODAL_TOKEN_SECRET ws-... ``` `fabro provider login --provider modal` is not supported in this release because that command accepts one credential value. Use the two `fabro secret set` commands above. Modal does not use a bearer API key for these endpoints. Fabro sends the vault values as `Modal-Key` and `Modal-Secret` headers and does not send an `Authorization` header. ## Included model | Fabro model slug | Modal API ID | Context | Input / cached input / output | Estimated speed | | - | - | - | - | - | | `kimi-k3` | `moonshotai/Kimi-K3` | 1M tokens | $3.00 / $0.30 / \$15.00 per MTok | 460 tok/s | The catalog marks Kimi K3 as supporting tools, vision, reasoning, and prompt caching. Modal's model ID is case-sensitive. ## Use Kimi K3 ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --provider modal fabro model test --provider modal --model kimi-k3 fabro run workflow.fabro --provider modal --model kimi-k3 ``` When targeting a non-default remote server, pass the same `--server` value: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --server https://your-fabro.example --provider modal fabro model test --server https://your-fabro.example --provider modal --model kimi-k3 ``` In workflow stylesheets: ```dot title="workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [ model_stylesheet=" * { model: modal/kimi-k3; } " ] start [shape=Mdiamond, label="Start"] work [label="Work", prompt="Use Kimi K3 through Modal."] exit [shape=Msquare, label="Exit"] start -> work -> exit } ``` ## Direct SDK environment credentials The built-in Modal provider authenticates with two headers, `Modal-Key` and `Modal-Secret`, read from the secrets `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. Direct SDK use reads the same two names from the process environment. For direct SDK use, enable Modal and set its endpoint URL in the `[llm]` overlay, then build the client with `fabro_llm::build_client` over a `VaultCredentialSource` whose vault holds both secrets. The catalog you pass to the client must be built from the same settings file with `fabro_llm::build_catalog`. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.modal] base_url = "https://your-endpoint.modal.run/v1" enabled = true ``` ## Costs Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports the cost source as `catalog`. Dedicated Auto Endpoints use Modal compute billing instead of the Shared API token prices. The Fabro estimate does not represent that compute bill. ## Troubleshooting **Modal requests fail with 404** — Add the Modal endpoint URL as `base_url` under `[llm.providers.modal]`. Include `/v1`. **Modal is not configured** — Set both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` in the target server vault. One value is not sufficient. **401 or 403** — Confirm that the token pair belongs to the correct Modal workspace and environment. If the workspace uses RBAC, allow the token in that environment. **404** — Confirm that the base URL is the endpoint URL followed by `/v1`, with no trailing slash. **Unknown model** — The built-in API ID is exactly `moonshotai/Kimi-K3`. Run `fabro model test --provider modal --model kimi-k3` to test the configured offering. ## Further reading Shared API prices, model specifications, and Auto Endpoint setup. Proxy-token headers and endpoint calling conventions. # OpenRouter Source: https://docs.fabro.sh/integrations/openrouter Route Fabro models through OpenRouter's multi-provider gateway [OpenRouter](https://openrouter.ai/) is an aggregator that fronts hundreds of models behind one OpenAI-compatible API. Fabro ships an enabled `openrouter` provider entry with a curated model catalog; it needs only an API key, and `settings.toml` can adjust it without changing Fabro code. ## Prerequisites * An [OpenRouter account](https://openrouter.ai/) with credit for paid models * An API key from [openrouter.ai/keys](https://openrouter.ai/keys) ## Enable the provider Fabro runs execute through a Fabro server. Add the provider override to the settings file used by that server. For a local server, this is usually `~/.fabro/settings.toml`; for a remote deployment, update the server host's Fabro settings. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [llm.providers.openrouter] enabled = true ``` ## Configure credentials Store the key in the target Fabro server vault: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider login --provider openrouter # For a non-default remote server: fabro provider login --server https://your-fabro.example --provider openrouter # Or set the vault token directly: fabro secret set OPENROUTER_API_KEY sk-or-v1-... fabro secret --server https://your-fabro.example set OPENROUTER_API_KEY sk-or-v1-... ``` Direct SDK usage outside a Fabro server can use an env-backed credential source explicitly: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} export OPENROUTER_API_KEY=sk-or-v1-... ``` ## Included models The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_model` values: | Fabro model slug | OpenRouter API ID / notes | | - | - | | `claude-fable-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7` | Matching `anthropic/...` API IDs; Anthropic-style cache billing | | `claude-sonnet-4-6` | `anthropic/claude-sonnet-4.6`; provider default | | `claude-haiku-4-5` | `anthropic/claude-haiku-4.5`; provider small default | | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4`, `gpt-5.5` | Matching `openai/...` API IDs | | `gemini-3.1-pro-preview`, `gemini-3.5-flash` | `google/...` API IDs | | `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `deepseek/...` API IDs; Flash uses the `deepseek-v4-flash-0731` release | | `kimi-k3`, `kimi-k2.6`, `qwen3-coder`, `qwen3.6-flash` | Vendor-prefixed API IDs | | `laguna-s-2.1`, `laguna-xs-2.1` | `poolside/...`; native reasoning and tool use | | `glm-5.2` (`glm`, `glm5`, `glm52`, `glm5.2`), `glm-4.6` | `z-ai/...` API IDs | | `minimax-m2.7`, `mimo-v2.5-pro` | Vendor-prefixed API IDs | | `nemotron-3-super-120b-a12b`, `devstral-2512` | Vendor-prefixed API IDs | Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_model`: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.openrouter.models."llama-4-maverick"] display_name = "Llama 4 Maverick" api_model = "meta-llama/llama-4-maverick" limits = { context_tokens = 1000000, max_output_tokens = 16384 } capabilities = { text = true, tools = true } ``` ## Use OpenRouter models ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --provider openrouter fabro model test --provider openrouter --model claude-sonnet-4-6 fabro run workflow.fabro --provider openrouter --model deepseek-v4-flash ``` When targeting a non-default remote server, pass the same `--server` value to verification commands: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --server https://your-fabro.example --provider openrouter fabro model test --server https://your-fabro.example --model anthropic/claude-sonnet-4-6 ``` In workflow stylesheets: ```dot title="workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [ model_stylesheet=" * { model: anthropic/claude-sonnet-4-6; } " ] start [shape=Mdiamond, label="Start"] work [label="Work", prompt="Use the configured OpenRouter model."] exit [shape=Msquare, label="Exit"] start -> work -> exit } ``` ## Cost telemetry Every OpenRouter response includes an inline `usage.cost` with authoritative USD billing. Fabro surfaces it as the response `cost` with source `provider`. Other providers populate the same field from catalog price estimates with source `catalog`. The catalog prices on OpenRouter model rows are best-effort estimates used only before the authoritative figure arrives (for example, mid-stream rollups). ## Provider routing OpenRouter's [provider routing preferences](https://openrouter.ai/docs/guides/routing/provider-selection) pass through verbatim via `provider_options.openrouter` on API/SDK requests — the keys merge into the top level of the request body: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "model": "anthropic/claude-sonnet-4-6", "provider_options": { "openrouter": { "provider": { "sort": "throughput", "data_collection": "deny" }, "models": ["anthropic/claude-sonnet-4.6", "deepseek/deepseek-v4-pro"] } } } ``` ## Attribution headers Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `X-Title`) by default, so self-hosted installations stay anonymous on OpenRouter's public app leaderboard. Workflow runs do send `x-session-id: ` for request grouping; an explicit provider `default_headers` value for that header takes precedence. To opt in to attribution: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.openrouter.default_headers] "HTTP-Referer" = "https://your-site.example" "X-Title" = "Your App" ``` ## Troubleshooting **"No API key configured"** — Set the key on the target server with `fabro provider login --provider openrouter` or `fabro secret set OPENROUTER_API_KEY ...`. For direct SDK usage outside a Fabro server, export `OPENROUTER_API_KEY` in the invoking shell. **"provider 'openrouter' is not configured in the server model catalog"** — Confirm the server host's `settings.toml` has `[llm.providers.openrouter]` with `enabled = true`. Fabro live-reloads `settings.toml` within a few seconds; after that, `fabro model list --provider openrouter` against the same server should show the enabled catalog. **402 / insufficient credits** — Paid OpenRouter models require prepaid credit; check your balance at [openrouter.ai/credits](https://openrouter.ai/credits). **Unknown model** — Confirm the model's `api_model` matches an OpenRouter slug exactly (including the vendor prefix), then run `fabro model test --model `. ## Further reading How Fabro routes model IDs, providers, and fallbacks. Full reference for provider settings and provider-scoped model offerings. # Poolside Source: https://docs.fabro.sh/integrations/poolside Run Laguna coding models through Poolside's direct API or OpenRouter [Poolside](https://poolside.ai/) trains the Laguna family of agentic coding models. Fabro includes Poolside's OpenAI-compatible API as a built-in provider, with Laguna S 2.1 and Laguna XS 2.1 available through both Poolside directly and the opt-in [OpenRouter integration](/integrations/openrouter). ## Prerequisites * A Poolside developer API key from [platform.poolside.ai](https://platform.poolside.ai/) * A running Fabro server Poolside Platform currently offers free developer access for a limited time. The built-in catalog records Poolside's published paid endpoint prices so Fabro can produce durable estimated cost telemetry when the preview ends or an account uses paid access. ## Configure direct access The direct `poolside` provider is enabled in the built-in catalog. Store its key in the target Fabro server vault: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider login --provider poolside # For a non-default remote server: fabro provider login --server https://your-fabro.example --provider poolside # Or set the vault token directly: fabro secret set POOLSIDE_API_KEY fabro secret --server https://your-fabro.example set POOLSIDE_API_KEY ``` Standalone SDK usage outside a Fabro server can use an env-backed credential source explicitly: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} export POOLSIDE_API_KEY= ``` The provider sends OpenAI-compatible Chat Completions requests to `https://inference.poolside.ai/v1` using bearer authentication. ## Included models | Fabro model ID | Poolside API model ID | Context | Max output | Role | | - | - | -: | -: | - | | `laguna-s-2.1` | `poolside/laguna-s-2.1` | 1,048,576 | 131,072 | Provider default; aliases `laguna`, `laguna-s` | | `laguna-xs-2.1` | `poolside/laguna-xs-2.1` | 262,144 | 32,768 | Small default and connectivity probe; alias `laguna-xs` | Both models support text input, tool calling, native reasoning, streaming, and automatic prompt-cache usage reporting. They do not support image input. ## Use direct Poolside models ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --provider poolside fabro model test --model laguna-xs-2.1 --tools fabro run workflow.fabro --model laguna-s-2.1 ``` In workflow stylesheets: ```dot title="workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [ model_stylesheet=" * { model: laguna-s-2.1; } .quick { model: laguna-xs-2.1; } " ] start [shape=Mdiamond, label="Start"] work [label="Implement", prompt="Implement and verify the requested change."] exit [shape=Msquare, label="Exit"] start -> work -> exit } ``` ## Reasoning behavior Laguna S 2.1 and XS 2.1 support two thinking modes: off and max. Max thinking is enabled by default. These releases do not expose low, medium, or high reasoning-effort levels, so Fabro intentionally does not advertise typed `reasoning_effort` controls for them. Fabro preserves Poolside's `reasoning_content` between assistant and tool messages. This is important for interleaved thinking across multi-step tool calls. For direct API or SDK requests, disable thinking through `provider_options.poolside`: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "model": "laguna-s-2.1", "provider_options": { "poolside": { "chat_template_kwargs": { "enable_thinking": false } } } } ``` ## Use Laguna through OpenRouter Enable OpenRouter and configure its separate API key as described in the [OpenRouter integration](/integrations/openrouter): ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.openrouter] enabled = true ``` The OpenRouter routes use vendor-namespaced model IDs so they can coexist with direct Poolside routes: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model test --model poolside/laguna-xs-2.1 --tools fabro run workflow.fabro --model poolside/laguna-s-2.1 ``` OpenRouter returns authoritative in-band cost telemetry. Its current paid rates per million input, output, and cache-read tokens are: | Model | Input | Output | Cache read | | - | -: | -: | -: | | `poolside/laguna-s-2.1` | \$0.10 | \$0.20 | \$0.01 | | `poolside/laguna-xs-2.1` | \$0.06 | \$0.12 | \$0.03 | The XS rate reflects OpenRouter's current promotional discount and can change. Fabro prefers OpenRouter's authoritative `usage.cost` over catalog estimates. Fabro does not include promotional `:free` OpenRouter variants in the built-in catalog because their availability and limits can change. Add one as a custom model if you explicitly want that route. ## Troubleshooting **"No API key configured"** — Store `POOLSIDE_API_KEY` on the target server with `fabro provider login --provider poolside`. The server runtime resolves the key from its vault, not from process env. **Unknown model** — Direct Poolside routes use `laguna-s-2.1` and `laguna-xs-2.1`. OpenRouter routes include the `poolside/` prefix. **Reasoning effort rejected** — Laguna's hosted endpoints support thinking off or max, not low/medium/high effort. Omit `reasoning_effort`; use the provider option above only when you need to disable thinking. ## Further reading Official API endpoints, authentication, model listing, and tool-use examples. Current model capabilities, weights, context windows, and release information. # Slack Source: https://docs.fabro.sh/integrations/slack Answer human-in-the-loop questions and receive run lifecycle notifications in Slack Fabro's Slack integration lets your team answer [human-in-the-loop](/workflows/human-in-the-loop) questions without leaving Slack and receive opt-in run lifecycle notifications. When a workflow reaches a human gate, Fabro posts an interactive message to a Slack channel with buttons for each option. Team members click a button (or reply in a thread for freeform input), and the workflow continues. The integration uses Slack's [Socket Mode](https://api.slack.com/apis/socket-mode), so no public URL or webhook endpoint is required — Fabro connects outbound over a WebSocket. ## What it looks like When a workflow hits a human gate like this: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve Plan"] approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] ``` Fabro posts a message to your configured Slack channel: > **Approve Plan** > > stage `approve` > > Open in Fabro > > *(context from the upstream stage, when available)* > > `[Approve]` `[Revise]` A team member clicks a button, the message updates to show the selection, and the workflow resumes on the chosen path. ## Supported question types | Question type | Slack UI | | - | - | | **Yes/No** | Two buttons: Yes, No | | **Confirmation** | Two buttons: Yes, No | | **Multiple choice** | One button per option | | **Multi-select** | Checkboxes with a Submit button | | **Freeform** | Prompt to reply in a thread | For freeform questions, Fabro posts a message asking the user to reply in the thread. The reply text (with any `@mention` prefix stripped) becomes the answer. ## Setup ### 1. Create a Slack App 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** 2. Choose **From scratch**, give it a name (e.g. "Fabro"), and select your workspace ### 2. Enable Socket Mode 1. In the app settings sidebar, go to **Socket Mode** 2. Toggle **Enable Socket Mode** on 3. Create an **App-Level Token** with the `connections:write` scope 4. Copy the token (starts with `xapp-`) — this is your `FABRO_SLACK_APP_TOKEN` ### 3. Add bot token scopes 1. Go to **OAuth & Permissions** in the sidebar 2. Under **Bot Token Scopes**, add the scopes you need: * `chat:write` — required to post and update Slack messages * `chat:write.public` — optional, only needed to post lifecycle notifications or interview prompts to public channels without inviting the bot first * `channels:history` — optional, only needed for freeform thread replies in public channels * `groups:history` — optional, only needed for freeform thread replies in private channels ### 4. Enable interactivity For human-in-the-loop buttons, multi-select menus, and other interactive controls: 1. Go to **Interactivity & Shortcuts** in the sidebar 2. Toggle **Interactivity** on Because Socket Mode is enabled, Slack delivers interactive payloads over the WebSocket connection instead of requiring a public Request URL. Lifecycle notifications are one-way messages and do not require interactivity. ### 5. Subscribe to events for freeform replies Skip this step if you only use lifecycle notifications, buttons, confirmations, multiple choice, or multi-select questions. 1. Go to **Event Subscriptions** in the sidebar 2. Toggle **Enable Events** on 3. Under **Subscribe to bot events**, add: * `message.channels` — receive public channel messages, needed for freeform thread replies in public channels * `message.groups` — receive private channel messages, needed for freeform thread replies in private channels If you add scopes or event subscriptions after installing the app, reinstall it so Slack grants the new permissions. ### 6. Install the app 1. Go to **Install App** in the sidebar 2. Click **Install to Workspace** and authorize 3. Copy the **Bot User OAuth Token** (starts with `xoxb-`) — this is your `FABRO_SLACK_BOT_TOKEN` ### 7. Configure Fabro Add both tokens to the Fabro server vault: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set FABRO_SLACK_BOT_TOKEN xoxb-your-bot-token fabro secret set FABRO_SLACK_APP_TOKEN xapp-your-app-token ``` The server runtime does not read Slack tokens from process env or `server.env`. `server.env` is reserved for bootstrap secrets such as `SESSION_SECRET`, `FABRO_DEV_TOKEN`, and object-store credentials. Restart the server after changing Slack credentials so the Socket Mode connection is recreated with the new tokens. Enable Slack in your [server configuration](/administration/server-configuration). You can leave `default_channel` out if you only use per-route lifecycle notification channels: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.integrations.slack] enabled = true default_channel = "#fabro-reviews" ``` `default_channel` is a literal channel name used only for human-in-the-loop interview prompts; Fabro does not interpolate it. Run lifecycle notifications use per-run or per-workflow `[run.notifications]` routes instead, whose channel values support `{{ vars.NAME }}` interpolation. ### 8. Invite the bot Invite the bot to any channel where you want it to post interview questions or lifecycle notifications: ``` /invite @Fabro ``` The `chat:write.public` scope allows posting to public channels without an invite. Private channels always require an invite. ### 9. Verify startup Start or restart the Fabro server and check its logs. A working Slack connection should include: ```text theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} INFO Slack integration enabled INFO Slack Socket Mode WebSocket connected INFO Slack Socket Mode handshake complete ``` If either token is missing or empty, startup logs the missing keys instead: ```text theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} INFO Slack integration disabled; missing credentials ``` Foreground servers log to stdout by default. Daemonized servers log under `/logs/server.log` unless you configure `[server.logging]` differently. You can also check **Settings > Integrations** in the web UI. The Slack row is computed from server configuration, vault credential presence, and the live Socket Mode connection state, so it can distinguish missing credentials, connecting, connected, and error states. ## How it works Fabro uses the same [web interviewer](/execution/interviews#web) that powers the web UI. When a human gate fires: 1. Fabro builds a [Block Kit](https://api.slack.com/block-kit) message from the question, stage hint, upstream context, optional run link, and answer controls 2. Posts the message to the configured Slack channel 3. The workflow blocks, waiting for an answer When a user interacts: * **Button click** — Slack delivers the interaction over the Socket Mode WebSocket. Fabro maps the button's action ID back to the question and submits the answer. The original message is updated to show the selection. * **Thread reply** (freeform) — The user replies in the message thread. Fabro receives the reply as a message event, matches it to the pending question via the thread timestamp, and submits the text as the answer. The connection is maintained with automatic reconnection and exponential backoff (1s initial, 30s max). If the WebSocket disconnects, Fabro reconnects transparently — no manual intervention needed. ## Run lifecycle notifications Slack run lifecycle notifications are opt-in per run or workflow through the `[run.notifications]` namespace. Notification settings do not live in server config. ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.notifications.deploys] enabled = true provider = "slack" events = ["run.started", "run.completed", "run.failed"] [run.notifications.deploys.slack] channel = "#deploys" ``` Each enabled route posts one message when a matching event is emitted. Lifecycle messages include the run ID, a link back to Fabro when the server has a web URL, the workflow label, result and duration for terminal events, and pull request details when the run has already created or linked a PR. `run.failed` is a terminal run event. A stage can fail and still be followed by another graph edge that lets the run complete; in that case a route listening for `run.completed` fires, not `run.failed`. The route-level Slack channel is required for lifecycle notifications. The channel may be a literal (`"#deploys"`) or a server variable (`"{{ vars.DEPLOYS_SLACK_CHANNEL }}"`). If the channel is missing, empty, or cannot be resolved, Fabro logs a warning and skips that route without affecting the run or other notification routes. Lifecycle notifications are one-way and fire-and-forget. They never accept answers, register reply threads, update prior messages, or interact with interview state. ### Smoke test lifecycle notifications To verify delivery end to end, add a Slack route to any workflow and run it on the server: ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [workflow] graph = "workflow.fabro" [run] goal = "Verify Slack notifications" [run.notifications.smoke] enabled = true provider = "slack" events = ["run.started", "run.completed", "run.failed"] [run.notifications.smoke.slack] channel = "#fabro-reviews" ``` ```dot title="workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Smoke { start [shape=Mdiamond, label="Start"] ping [shape=parallelogram, label="Ping", script="echo slack smoke"] done [shape=Msquare, label="Done"] start -> ping -> done } ``` Then launch it against the server: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run --server http://127.0.0.1:32276/api/v1 ./workflow.toml ``` The channel should receive `run.started` and `run.completed`. To verify `run.failed`, replace the graph with a failing command stage that has no normal exit path, then run it again: ```dot title="workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph SmokeFailure { start [shape=Mdiamond, label="Start"] fail [shape=parallelogram, label="Fail", script="exit 1"] start -> fail } ``` ## Environment variables | Variable | Required | Description | | - | - | - | | `FABRO_SLACK_BOT_TOKEN` | Yes | Bot User OAuth Token (`xoxb-...`). Used to post and update messages. | | `FABRO_SLACK_APP_TOKEN` | Yes | App-Level Token (`xapp-...`). Used to connect via Socket Mode. | Both must be set for the Slack integration to activate. If either is missing or empty, Slack is disabled and the startup log names the missing variables. A server-level `default_channel` is not required for lifecycle notifications; it is only needed when you want interview prompts to use a default Slack channel. ## Limitations * **Single workspace** — Fabro connects to one Slack workspace at a time. * **One answer per question** — The first person to click a button or reply in a thread provides the answer. Subsequent interactions on the same question are ignored. * **No Slack steering** — Slack does not support [steering](/human-tools/steering) running agents. * **Lifecycle notifications are one-way** — `run.started`, `run.completed`, and `run.failed` messages are posted once per matching route and are not updated later. # Venice Source: https://docs.fabro.sh/integrations/venice Run Kimi, Grok, GLM, DeepSeek, and Qwen models through Venice [Venice](https://venice.ai/) provides an OpenAI-compatible API for hosted text models. Fabro enables the `venice` provider in its built-in catalog and maps stable Fabro model slugs to Venice's API model IDs. ## Prerequisites * A Venice account * An inference API key from [venice.ai/settings/api](https://venice.ai/settings/api) * A running Fabro server ## Configure credentials Store the API key in the target Fabro server vault: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider login --provider venice # For a non-default remote server: fabro provider login --server https://your-fabro.example --provider venice # Or set the vault token directly: fabro secret set VENICE_API_KEY fabro secret --server https://your-fabro.example set VENICE_API_KEY ``` Standalone SDK usage outside a Fabro server can use an env-backed credential source explicitly: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} export VENICE_API_KEY= ``` Fabro sends bearer-authenticated Chat Completions requests to `https://api.venice.ai/api/v1`. ## Included models | Fabro model slug | Venice API ID | Context | Max output | Role and aliases | | - | - | -: | -: | - | | `kimi-k3` | `kimi-k3` | 1,000,000 | 131,072 | Alias `kimi` | | `kimi-k3-fast` | `kimi-k3-fast-api` | 1,000,000 | 131,072 | Alias `kimi-fast` | | `grok-4.6` | `grok-4-6` | 500,000 | 32,000 | Aliases `grok`, `grok46`, `grok-46` | | `glm-5.3` | `z-ai-glm-5-3` | 1,000,000 | 131,072 | Aliases `glm`, `glm5`, `glm53`, `glm5.3`, `glm-5-3` | | `deepseek-v4-flash` | `deepseek-v4-flash-0731` | 1,000,000 | 32,768 | Provider default; aliases `deepseek`, `deepseek-v4`, `deepseek-flash` | | `deepseek-v4-pro` | `deepseek-v4-pro-0813` | 1,000,000 | 32,768 | Alias `deepseek-pro` | | `qwen3.8-max` | `qwen-3-8-max` | 1,000,000 | 131,072 | Aliases `qwen`, `qwen-max`, `qwen3.8`, `qwen-3.8`, `qwen38`, `qwen-3.8-max`, `qwen38-max` | | `qwen3.8-27b` | `qwen-3-8-27b` | 262,144 | 131,072 | Aliases `qwen-27b`, `qwen-3.8-27b`, `qwen38-27b` | Venice API IDs are also valid provider-scoped selectors. Fabro persists the stable Fabro slug and the selected provider when it creates a run. ## Select Venice explicitly Some Venice models use the same stable slugs as direct providers. An unqualified selector chooses the highest-priority ready provider. For example, `deepseek` can select the direct DeepSeek provider when both API keys are configured. Pin Venice when the run must use Venice: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --provider venice fabro model test --provider venice --model deepseek-v4-flash --tools fabro run workflow.fabro --provider venice --model deepseek-v4-flash ``` In a workflow stylesheet: ```dot title="workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [ model_stylesheet=" * { provider: venice; model: deepseek-v4-flash; } .complex { provider: venice; model: qwen; } .fast { provider: venice; model: kimi-fast; } " ] start [shape=Mdiamond, label="Start"] work [label="Implement", class="complex"] check [label="Check", class="fast"] exit [shape=Msquare, label="Exit"] start -> work -> check -> exit } ``` The generic Qwen aliases `qwen` and `qwen3.8` select Qwen 3.8 Max. Use a size-specific alias such as `qwen-27b` to select Qwen 3.8 27B. ## Capabilities and reasoning All included models support tool calling and reasoning. Kimi K3, Kimi K3 Fast, Grok 4.6, Qwen 3.8 Max, and Qwen 3.8 27B also accept image input. Fabro exposes native reasoning-effort controls only when Venice supports them: | Model | Reasoning effort values | | - | - | | `grok-4.6` | `low`, `medium`, `high`, `xhigh` | | `glm-5.3` | `low`, `high`, `max` | | `deepseek-v4-flash` | `low`, `high`, `max` | | `qwen3.8-27b` | `low`, `medium`, `xhigh` | The other models reason by default but do not expose a Venice reasoning-effort control. Fabro omits sampling parameters for Kimi and DeepSeek because those routes do not use them with their configured reasoning behavior. ## Pricing and prompt caching The built-in catalog uses Venice's published prices per million tokens: | Model | Uncached input | Cache hit | Output | | - | -: | -: | -: | | `kimi-k3` | \$3.75 | \$0.375 | \$18.75 | | `kimi-k3-fast` | \$4.50 | \$0.45 | \$22.50 | | `grok-4.6` | \$2.27 | \$0.57 | \$6.80 | | `glm-5.3` | \$1.75 | \$0.325 | \$5.50 | | `deepseek-v4-flash` | \$0.175 | \$0.035 | \$0.35 | | `deepseek-v4-pro` | \$1.65 | \$0.165 | \$4.95 | | `qwen3.8-max` | \$2.50 | \$0.3125 | \$7.50 | | `qwen3.8-27b` | \$0.45 | n/a | \$3.20 | Fabro reports cached input separately when Venice returns cache usage for the selected model. Prices and model availability can change upstream; use `fabro model list --provider venice` to inspect the catalog shipped with your Fabro version and the [Venice model catalog](https://docs.venice.ai/models/overview) for the current upstream service. ## Troubleshooting **"No credential was found for provider 'venice'"** — Store `VENICE_API_KEY` in the server vault with `fabro provider login --provider venice`. Pass `--server` when configuring a remote Fabro server. **A shared model used another provider** — Pin Venice with `--provider venice` or `provider: venice` in the workflow stylesheet. Unqualified selectors use provider priority. **A Venice API model ID is rejected without a provider** — Use the stable Fabro slug for portable selection, or qualify the API ID with the provider, such as `venice:qwen-3-8-max`. # Venice Search Source: https://docs.fabro.sh/integrations/venice-search Give Fabro agents web search capabilities via Venice's augment/search API Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web during workflow execution. Fabro uses [Venice Search](https://docs.venice.ai/api-reference/endpoint/augment/search) automatically when `VENICE_API_KEY` is available and a direct [Brave Search](/integrations/brave-search) key is not. Venice Search reuses the same `VENICE_API_KEY` as the Venice LLM provider. Agents keep calling `web_search`; only the HTTP backend changes. ## Setup 1. Store a Venice API key on the Fabro server (skip this if the Venice LLM provider is already logged in): ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider login --provider venice # or fabro secret set VENICE_API_KEY venice-... ``` Fabro prefers direct Brave Search whenever `BRAVE_SEARCH_API_KEY` is also present. To select Venice, leave that key unset or remove it: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret rm BRAVE_SEARCH_API_KEY ``` 2. Verify the key is working: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro doctor ``` The doctor output should show **Web Search** as `venice: configured and reachable`. If neither Venice nor Brave is configured, web search is reported as a warning. Workflows still run, but the `web_search` tool is omitted from the agent's tool set. The Fabro server reads this key from the vault only. It does not read `VENICE_API_KEY` from process env or `server.env`. ## How it works Agents call the `web_search` tool with a query string. Fabro `POST`s to Venice `https://api.venice.ai/api/v1/augment/search` with the Brave search engine and returns numbered results with title, URL, description, and date when Venice includes one: ``` 1. Rust Lang https://rust-lang.org A systems language 2026-01-02 ``` Venice Search is billed by Venice at \$0.01 per request and is rate-limited to 20 requests per minute on the Venice side. Queries longer than 400 characters are rejected before the HTTP call. If `VENICE_API_KEY` is absent but `BRAVE_SEARCH_API_KEY` exists, Fabro uses direct Brave Search. If neither key exists, the tool is not registered. After selecting Venice, a failed call returns an error; Fabro does not retry through direct Brave Search. See the [`web_search` tool reference](/agents/tools#web_search) for parameters and details. ## Permissions `web_search` is classified as a `shell` category tool, requiring the `full` [permission level](/agents/permissions) for auto-approval. At lower permission levels: * **Interactive mode** — the user is prompted to approve each call * **Non-interactive mode** (`--auto-approve`) — calls are denied ## Troubleshooting **"VENICE\_API\_KEY is not configured"** — Add the key with `fabro secret set VENICE_API_KEY ` or `fabro provider login --provider venice`. Run `fabro doctor` to verify. **"Venice Search API returned status 401"** — The API key is invalid or expired. Create a new key at [venice.ai](https://venice.ai). **"Venice Search API returned status 402"** — The Venice account is out of credits. The error may include a remaining-balance hint. **"Venice Search API returned status 429"** — Rate limit exceeded (20 requests per minute on Venice Search). Reduce the frequency of `web_search` calls. ## Further reading Full `web_search` tool reference — parameters, output format, and error handling. Direct Brave Search backend, preferred whenever its key is configured. # Acknowledgements Source: https://docs.fabro.sh/reference/acknowledgements Projects and people that inspired Fabro ## Attractor Fabro was inspired by [Attractor](https://github.com/strongdm/attractor), created by [Justin McCarthy](https://github.com/strongdm) at [StrongDM](https://www.strongdm.com/). Attractor pioneered the idea of orchestrating AI coding agents with graph-based workflows, and its design deeply influenced Fabro's architecture. [Dan Shapiro](https://github.com/danshapiro)'s [Kilroy](https://github.com/danshapiro/kilroy), an independent implementation of the Attractor concept, was also a helpful reference during Fabro's development. ## AI researchers and labs Fabro is built on top of the large language models produced by leading AI research labs, including [Anthropic](https://www.anthropic.com/), [OpenAI](https://openai.com/), and [Google DeepMind](https://deepmind.google/). Their foundational work in AI research and model development makes agentic coding workflows possible. Fabro wouldn't exist without the breakthroughs these teams and the broader AI research community have achieved. # Architecture Source: https://docs.fabro.sh/reference/architecture How Fabro's CLI and API modes work under the hood Fabro provides two interfaces — a CLI for local development and an HTTP API for production use. Both submit runs to the same server, and every run executes on the same engine. ## The engine Every run executes on Petri, the workflow engine. At create time the server hands Petri the workflow bundle and the run's settings; Petri lowers the Graphviz graph, checks it, pins the models its nodes name, and either admits the graph or refuses the run with its diagnostics. At execution a worker process runs the admitted graph: it walks the nodes, dispatches each stage (agent, command, human, etc.), selects edges, and records every step in the run's log, from which the run resumes after an interruption. Human-in-the-loop questions reach the server's questions API; the CLI answers them from the terminal, the web app from the run page. ## CLI mode ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro --goal "Implement the login feature" ``` The CLI builds the run manifest, submits it to the server (starting a local one when none is running), and follows the run: events are printed to stderr, progress is shown with terminal indicators, and human-in-the-loop questions are answered via interactive terminal prompts. When the run finishes, the process exits. CLI mode is ideal for: * Local development and iteration on workflows * One-off runs and debugging * Scripting and CI/CD pipelines ## API mode ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server start ``` `fabro server start` starts an HTTP server, binding to `~/.fabro/fabro.sock` by default (or plain TCP when configured), with persistent run storage. Runs are submitted via the REST API and executed asynchronously. Public HTTPS, when needed, is terminated upstream by a reverse proxy or platform ingress. ### Configuration The server reads `~/.fabro/settings.toml` for default settings (model, sandbox, variables, authentication). This file is live-reloaded — changes take effect within seconds without restarting the server. Key server config options: | Setting | Description | | - | - | | `server.listen` | Bind transport: Unix socket or plain TCP listener | | `server.api.url` / `server.web.url` | External API base URL and the single canonical browser/API origin | | `server.auth.methods` | Bootstrap auth methods: `dev-token`, `github`, or both | | `server.scheduler.max_concurrent_runs` | Scheduler concurrency limit (default 5) | | `[run.*]` | Defaults applied to every run (overridable per-run) | ### Run lifecycle 1. **Submit** — Register immutable workflow content with `POST /api/v1/workflow-versions`, then submit `POST /api/v1/runs` with its `workflow_version_id` and an explicit target. The run is created with status `submitted` and the response returns immediately with the run ID. 2. **Start request** — `POST /api/v1/runs/{id}/start` moves normal runs to `runnable`. Parent-generated [child runs](/execution/child-runs) may move to `pending` with `approval_required`. 3. **Approve if needed** — `POST /api/v1/runs/{id}/approve` moves an approval-gated run to `runnable`; `deny` fails it with `approval_denied`. 4. **Schedule** — A background scheduler promotes `runnable` runs to `running` in FIFO order, up to the concurrency limit. 5. **Execute** — A worker runs the admitted graph on Petri, streaming events to all subscribers. 6. **Complete** — The run transitions to `succeeded`, `failed`, or `dead`. ### Event streaming The API streams run events via [Server-Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). Every significant action — stage starts, LLM calls, tool invocations, edge selections — is emitted as a structured JSON event. The web UI uses this endpoint for real-time run monitoring. Any HTTP client that supports SSE can subscribe. See the [run events endpoint](/api-reference/runs/stream-run-events) in the API reference. ### Human-in-the-loop In API mode, human-in-the-loop questions are served over HTTP instead of terminal prompts. The engine blocks the current stage until an answer is received, then continues execution. See the [list questions](/api-reference/human-in-the-loop/list-run-questions) and [submit answer](/api-reference/human-in-the-loop/submit-run-answer) API reference pages. ### Authentication API mode supports two bootstrap auth methods, configurable in `settings.toml`: * **`dev-token`** — Bearer token access for operators and automation * **`github`** — GitHub OAuth for browser users, resulting in a session cookie ### Demo mode Demo mode is per-request: send the `X-Fabro-Demo: 1` HTTP header to get static mock data with authentication disabled. The web UI sends this header automatically when configured with `FABRO_DEMO=1`. This lets you explore the UI without API keys or real workflow execution. ## Web UI The web UI is a React app (`apps/fabro-web`) that connects to the API server. Start it alongside `fabro server start`: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server start # API on ~/.fabro/fabro.sock by default cd apps/fabro-web && bun run dev # rebuilds web assets on change; refresh the browser ``` The UI provides: * **Run board** — List and monitor all runs * **Run detail** — Real-time stage progress, event stream, diffs, usage stats * **Start new run** — Submit a Graphviz workflow from the browser * **Human-in-the-loop** — Answer agent questions through the web interface * **Sessions** — Interactive chat interface (coming soon) * **Insights** — SQL-based analysis across runs via DuckDB ## Comparison See [Deployment](/administration/deployment) for where the Fabro server runs and the trade-offs between local and self-hosted modes. # CLI Reference Source: https://docs.fabro.sh/reference/cli Fabro command-line interface reference ## Global options These flags apply to all subcommands: | Flag | Description | | - | - | | `--json` | Output machine-readable JSON when the command supports it | | `--debug` | Enable DEBUG-level logging (default is INFO) | | `--no-upgrade-check` | Skip the automatic background upgrade check | | `--quiet` | Suppress non-essential output | | `--verbose` | Enable verbose output | | `-h, --help` | Print help | | `-V, --version` | Print version | Connection-target flags like `--storage-dir` and `--server` are command-specific, not global. Fabro no longer auto-loads `~/.fabro/.env`; persist server-owned credentials with `fabro provider login` / `fabro secret set`, or provide environment variables in the invoking shell. ## Configuration CLI defaults can be set in `~/.fabro/settings.toml` so you don't have to pass common flags every time: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [cli.exec.model] provider = "anthropic" name = "claude-opus-4-6" [cli.exec.agent] permissions = "read-write" [cli.output] format = "text" [run.model] name = "claude-sonnet-4-5" [cli.target] type = "http" url = "https://fabro.example.com/api/v1" ``` `[cli.exec]` config applies to `fabro exec`. `[run.model]` sets the default workflow model/provider for commands like `fabro run` and `fabro preflight`. `[cli.target]` stores connection info for commands that can target a remote Fabro server. `fabro model` uses `[cli.target]` by default when no explicit `--storage-dir` is passed. `fabro exec` remains a local session unless you pass `--server`, even if `[cli.target]` is configured. Explicit `http(s)://...` targets are always treated as remote servers. They do not inherit identity from a local daemon, a local storage dir, or `~/.fabro/dev-token`. For remote URLs, use `fabro auth login --server ...` or a stored CLI auth session. CLI flags always override `settings.toml` values, which override hardcoded defaults. ## `fabro` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro [OPTIONS] [COMMAND] ``` #### Subcommands | Command | Description | | - | - | | `fabro approve` | Approve a pending run for execution | | `fabro archive` | Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings | | `fabro artifact` | Inspect and copy run artifacts (screenshots, reports, traces) | | `fabro ask` | Ask Fabro a read-only question about a run | | `fabro attach` | Attach to a running or finished workflow run | | `fabro auth` | Manage CLI authentication state | | `fabro completion` | Generate shell completions | | `fabro create` | Register a workflow version and create a submitted run | | `fabro deny` | Deny execution of a pending run | | `fabro discord` | Open the Discord community in the browser | | `fabro docs` | Open the docs website in the browser | | `fabro doctor` | Check environment and integration health | | `fabro dump` | Export a run's durable state to a directory | | `fabro events` | View the event log of a workflow run | | `fabro fork` | Fork a workflow run from an earlier checkpoint into a new run | | `fabro graph` | Render a workflow graph as SVG | | `fabro inspect` | Show detailed information about a workflow run | | `fabro install` | Set up the Fabro environment (LLMs, certs, GitHub) | | `fabro logs` | View the raw worker tracing log of a workflow run | | `fabro mcp` | Model Context Protocol server | | `fabro model` | List and test LLM models | | `fabro parent` | Manage run parent links | | `fabro pr` | Pull request operations | | `fabro preflight` | Validate run configuration without executing | | `fabro provider` | Provider operations | | `fabro repo` | Repository commands | | `fabro resume` | Resume an interrupted workflow run | | `fabro retry` | Retry a finished workflow from the start in a new run | | `fabro rewind` | Rewind a workflow run to an earlier checkpoint, replacing it | | `fabro rm` | Remove one or more workflow runs | | `fabro run` | Register a workflow version, create a run, and start it | | `fabro sandbox` | Sandbox operations (cp, ssh, preview) | | `fabro secret` | Manage server-owned secrets | | `fabro server` | Server operations | | `fabro settings` | Inspect effective settings | | `fabro start` | Start a created workflow run on the server | | `fabro steer` | Steer a running agent mid-execution | | `fabro system` | System maintenance commands | | `fabro timeline` | Show the checkpoint timeline of a workflow run | | `fabro unarchive` | Restore archived runs to their prior terminal status | | `fabro uninstall` | Uninstall Fabro from this machine | | `fabro upgrade` | Upgrade fabro to the latest version | | `fabro validate` | Validate a workflow | | `fabro variable` | Manage server-owned variables | | `fabro version` | Show client and server version information | | `fabro wait` | Block until a workflow run completes | | `fabro workflow` | Workflow operations | ### `fabro approve` Approve a pending run for execution ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro approve [OPTIONS] ... ``` #### Arguments | Name | Description | | - | - | | `RUNS` | Run IDs or workflow names to approve | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro archive` Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro archive [OPTIONS] ... ``` #### Arguments | Name | Description | | - | - | | `RUNS` | Run IDs or workflow names to archive | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro artifact` Inspect and copy run artifacts (screenshots, reports, traces) ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro artifact [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro artifact cp` | Copy artifacts from a workflow run | | `fabro artifact list` | List artifacts for a workflow run | #### `fabro artifact cp` Copy artifacts from a workflow run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro artifact cp [OPTIONS] [DEST] ``` #### Arguments | Name | Description | | - | - | | `SOURCE` | Source: RUN\_ID (all artifacts) or RUN\_ID:path (specific artifact) | | `DEST` | Destination directory (defaults to current directory)
Default: `.` | #### Options | Option | Description | | - | - | | `--node ` | Filter to artifacts from a specific node | | `--retry ` | Filter to artifacts from a specific retry attempt | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--stage ` | Filter to artifacts from a specific stage visit (node\@visit) | | `--tree` | Preserve node\[/visit\_]/retry\_/ directory structure | #### `fabro artifact list` List artifacts for a workflow run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro artifact list [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID (or prefix) | #### Options | Option | Description | | - | - | | `--node ` | Filter to artifacts from a specific node | | `--retry ` | Filter to artifacts from a specific retry attempt | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--stage ` | Filter to artifacts from a specific stage visit (node\@visit) | ### `fabro ask` Ask Fabro a read-only question about a run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro ask [OPTIONS] --prompt ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID prefix or workflow name | #### Options | Option | Description | | - | - | | `--model ` | Optional model name | | `-p, --prompt ` | Question to ask | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro attach` Attach to a running or finished workflow run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro attach [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID prefix or workflow name | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro auth` Manage CLI authentication state ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro auth [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro auth login` | Log in to a Fabro server | | `fabro auth logout` | Log out from a Fabro server | | `fabro auth status` | Show offline CLI auth status | #### `fabro auth login` Log in to a Fabro server ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro auth login [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--dev-token ` | Log in with a dev-token instead of browser OAuth | | `--no-browser` | Print the browser URL instead of opening it automatically | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--timeout ` | Timeout in seconds waiting for the browser flow to complete
Default: `300` | #### `fabro auth logout` Log out from a Fabro server ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro auth logout [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--all` | Log out from every stored server | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### `fabro auth status` Show offline CLI auth status ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro auth status [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro completion` Generate shell completions ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro completion [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `SHELL` | Shell to generate completions for
Values: `bash`, `elvish`, `fish`, `powershell`, `zsh` | ### `fabro create` Register a workflow version and create a submitted run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro create [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `WORKFLOW` | Workflow name, path, or OWNER/REPO\[@REF]:WORKFLOW | #### Options | Option | Description | | - | - | | `--auto-approve` | Auto-approve all human gates | | `-d, --detach` | Run the workflow in the background and print the run ID | | `--dry-run` | Simulate execution; workflow source may still be fetched and uploaded | | `--environment ` | Named environment for agent tools | | `--goal ` | Override the workflow goal (available as in prompts) | | `--goal-file ` | Read a per-run goal value from a local file | | `--label ` | Attach a label to this run (repeatable, format: KEY=VALUE) | | `--model ` | Override default LLM model | | `--parent ` | Link this run to an existing orchestration parent run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) | | `--provider ` | Override default LLM provider | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--target-branch ` | Target working branch (default: remote default branch), pinned to its observed commit | | `--target-from ` | Observe this target directory instead of cwd; Folder targets require server filesystem access | | `--target-repo ` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials | | `--target ` | Target GitHub repository and optional working branch | | `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | | `--workflow-ref ` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names | | `--workflow-repo ` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials | ### `fabro deny` Deny execution of a pending run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro deny [OPTIONS] ... ``` #### Arguments | Name | Description | | - | - | | `RUNS` | Run IDs or workflow names to deny | #### Options | Option | Description | | - | - | | `--reason ` | Reason for denying execution | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro discord` Open the Discord community in the browser ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro discord [OPTIONS] ``` ### `fabro docs` Open the docs website in the browser ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro docs [OPTIONS] ``` ### `fabro doctor` Check environment and integration health ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro doctor [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `-v, --verbose` | Show detailed information for each check | ### `fabro dump` Export a run's durable state to a directory ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro dump [OPTIONS] --output ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID prefix or workflow name | #### Options | Option | Description | | - | - | | `-o, --output ` | Output directory (must not exist or be empty) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro events` View the event log of a workflow run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro events [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID prefix or workflow name (most recent run) | #### Options | Option | Description | | - | - | | `-f, --follow` | Follow event output | | `-p, --pretty` | Formatted colored output with rendered assistant text | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--since ` | Events since timestamp or relative (e.g. "42m", "2h", "2026-01-02T13:00:00Z") | | `-n, --tail ` | Lines from end (default: all) | ### `fabro fork` Fork a workflow run from an earlier checkpoint into a new run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro fork [OPTIONS] [TARGET] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID (or unambiguous prefix) | | `TARGET` | Target checkpoint: node name, node\@visit, or @ordinal (omit to fork from latest) | #### Options | Option | Description | | - | - | | `--list` | Show the checkpoint timeline instead of forking | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro graph` Render a workflow graph as SVG ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro graph [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `WORKFLOW` | Path to the .fabro workflow file, .toml task config, or project workflow name | #### Options | Option | Description | | - | - | | `--allow-invalid` | Render even when workflow validation reports errors | | `-d, --direction ` | Graph layout direction (overrides the DOT file's rankdir)
Values: `lr`, `tb` | | `--format ` | Output format
Values: `svg`
Default: `svg` | | `-o, --output ` | Output file path (defaults to stdout) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro inspect` Show detailed information about a workflow run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro inspect [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID prefix or workflow name (most recent run) | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro install` Set up the Fabro environment (LLMs, certs, GitHub) ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro install [OPTIONS] [COMMAND] ``` #### Options | Option | Description | | - | - | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | | `--web-url ` | Base URL for the web UI (used for OAuth callback URLs and generated settings)
Default: `http://127.0.0.1:32276` | #### Subcommands | Command | Description | | - | - | | `fabro install github` | Configure GitHub integration (token or GitHub App) | #### `fabro install github` Configure GitHub integration (token or GitHub App) ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro install github [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--owner ` | GitHub App owner: `personal` or `org:` (app only, requires --non-interactive) | | `--strategy ` | GitHub authentication strategy (requires --non-interactive)
Values: `token`, `app` | ### `fabro logs` View the raw worker tracing log of a workflow run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro logs [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID prefix or workflow name (most recent run) | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `-n, --tail ` | Lines from end (default: all) | ### `fabro mcp` Model Context Protocol server ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro mcp [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro mcp config` | Print MCP client configuration JSON | | `fabro mcp init` | Configure an MCP client to launch Fabro | | `fabro mcp start` | Start the Fabro MCP server over stdio | #### `fabro mcp config` Print MCP client configuration JSON ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro mcp config [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--name ` | Name of the mcpServers entry; use distinct names to register multiple Fabro servers
Default: `fabro` | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | #### `fabro mcp init` Configure an MCP client to launch Fabro ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro mcp init [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `AGENT` | Values: `claude`, `cursor`, `windsurf` | #### Options | Option | Description | | - | - | | `--name ` | Name of the mcpServers entry; use distinct names to register multiple Fabro servers
Default: `fabro` | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | #### `fabro mcp start` Start the Fabro MCP server over stdio ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro mcp start [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | ### `fabro model` List and test LLM models ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model [OPTIONS] [COMMAND] ``` #### Subcommands | Command | Description | | - | - | | `fabro model list` | List available models | | `fabro model test` | Test model availability by sending a simple prompt | #### `fabro model list` List available models ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list [OPTIONS] ``` #### Options | Option | Description | | - | - | | `-p, --provider ` | Filter by provider | | `-q, --query ` | Search for models matching this string | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### `fabro model test` Test model availability by sending a simple prompt ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model test [OPTIONS] ``` #### Options | Option | Description | | - | - | | `-j, --jobs ` | Number of model tests to run concurrently in bulk mode
Default: `4` | | `-m, --model ` | Test a specific model | | `-p, --provider ` | Filter by provider | | `--reasoning-effort ` | Request a reasoning-effort level (minimal, low, medium, high, xhigh, max) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--tools` | Run a multi-turn tool-use test | ### `fabro parent` Manage run parent links ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro parent [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro parent link` | Link or replace a run's orchestration parent | | `fabro parent unlink` | Unlink a run from its orchestration parent | #### `fabro parent link` Link or replace a run's orchestration parent ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro parent link [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `CHILD_RUN` | Child run selector | | `PARENT_RUN` | Parent run selector | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### `fabro parent unlink` Unlink a run from its orchestration parent ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro parent unlink [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `CHILD_RUN` | Child run selector | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro pr` Pull request operations ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro pr [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro pr close` | Close a pull request | | `fabro pr create` | Create a pull request from a completed run | | `fabro pr link` | Link or replace the GitHub pull request associated with a run | | `fabro pr merge` | Merge a pull request | | `fabro pr unlink` | Unlink the pull request associated with a run | | `fabro pr view` | View pull request details | #### `fabro pr close` Close a pull request ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro pr close [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID or prefix | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### `fabro pr create` Create a pull request from a completed run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro pr create [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID or prefix | #### Options | Option | Description | | - | - | | `-f, --force` | Create PR even if the run status is not succeeded/partially\_succeeded | | `--model ` | LLM model for generating PR description | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### `fabro pr link` Link or replace the GitHub pull request associated with a run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro pr link [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID or prefix | | `URL` | GitHub pull request URL to associate with the run | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### `fabro pr merge` Merge a pull request ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro pr merge [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID or prefix | #### Options | Option | Description | | - | - | | `--method ` | Merge method: merge, squash, or rebase
Values: `merge`, `squash`, `rebase`
Default: `squash` | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### `fabro pr unlink` Unlink the pull request associated with a run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro pr unlink [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID or prefix | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### `fabro pr view` View pull request details ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro pr view [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID or prefix | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro preflight` Validate run configuration without executing ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro preflight [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `WORKFLOW` | Path to a .fabro workflow file or .toml task config | #### Options | Option | Description | | - | - | | `--environment ` | Named environment for agent tools | | `--goal ` | Override the workflow goal (available as in prompts) | | `--goal-file ` | Read the workflow goal from a file | | `--model ` | Override default LLM model | | `--provider ` | Override default LLM provider | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | ### `fabro provider` Provider operations ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro provider login` | Log in to an LLM provider | #### `fabro provider login` Log in to an LLM provider ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro provider login [OPTIONS] --provider ``` #### Options | Option | Description | | - | - | | `--api-key-stdin` | Read an API key from stdin instead of prompting | | `--provider ` | LLM provider to authenticate with | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro repo` Repository commands ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro repo [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro repo deinit` | Remove .fabro/ project directory | | `fabro repo init` | Initialize a new project | #### `fabro repo deinit` Remove .fabro/ project directory ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro repo deinit [OPTIONS] ``` #### `fabro repo init` Initialize a new project ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro repo init [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro resume` Resume an interrupted workflow run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro resume [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID or unambiguous prefix | #### Options | Option | Description | | - | - | | `-d, --detach` | Run in the background and print the run ID | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro retry` Retry a finished workflow from the start in a new run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro retry [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID (or unambiguous prefix) | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro rewind` Rewind a workflow run to an earlier checkpoint, replacing it ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro rewind [OPTIONS] [TARGET] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID (or unambiguous prefix) | | `TARGET` | Target checkpoint: node name, node\@visit, or @ordinal (omit with --list) | #### Options | Option | Description | | - | - | | `--list` | Show the checkpoint timeline instead of rewinding | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro rm` Remove one or more workflow runs ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro rm [OPTIONS] ... ``` #### Arguments | Name | Description | | - | - | | `RUNS` | Run IDs or workflow names to remove | #### Options | Option | Description | | - | - | | `-f, --force` | Force removal of active runs | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro run` Register a workflow version, create a run, and start it ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `WORKFLOW` | Workflow name, path, or OWNER/REPO\[@REF]:WORKFLOW | #### Options | Option | Description | | - | - | | `--auto-approve` | Auto-approve all human gates | | `-d, --detach` | Run the workflow in the background and print the run ID | | `--dry-run` | Simulate execution; workflow source may still be fetched and uploaded | | `--environment ` | Named environment for agent tools | | `--goal ` | Override the workflow goal (available as in prompts) | | `--goal-file ` | Read a per-run goal value from a local file | | `--label ` | Attach a label to this run (repeatable, format: KEY=VALUE) | | `--model ` | Override default LLM model | | `--parent ` | Link this run to an existing orchestration parent run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) | | `--provider ` | Override default LLM provider | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--target-branch ` | Target working branch (default: remote default branch), pinned to its observed commit | | `--target-from ` | Observe this target directory instead of cwd; Folder targets require server filesystem access | | `--target-repo ` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials | | `--target ` | Target GitHub repository and optional working branch | | `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | | `--workflow-ref ` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names | | `--workflow-repo ` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials | ### `fabro sandbox` Sandbox operations (cp, ssh, preview) ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro sandbox [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro sandbox cp` | Copy files to/from a run's sandbox | | `fabro sandbox preview` | Get a preview URL for a port on a run's sandbox | | `fabro sandbox ssh` | SSH into a run's sandbox | #### `fabro sandbox cp` Copy files to/from a run's sandbox ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro sandbox cp [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `SRC` | Source: `:` or local path | | `DST` | Destination: `:` or local path | #### Options | Option | Description | | - | - | | `-r, --recursive` | Recurse into directories | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### `fabro sandbox preview` Get a preview URL for a port on a run's sandbox ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro sandbox preview [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID or prefix | | `PORT` | Port number | #### Options | Option | Description | | - | - | | `--open` | Open URL in browser (implies --signed) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--signed` | Generate a signed URL (embeds auth token, no headers needed) | | `--ttl ` | Signed URL expiry in seconds (default 3600, requires --signed)
Default: `3600` | #### `fabro sandbox ssh` SSH into a run's sandbox ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro sandbox ssh [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID or prefix | #### Options | Option | Description | | - | - | | `--print` | Print the SSH command instead of connecting | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--ttl ` | SSH access expiry in minutes (default 60)
Default: `60` | ### `fabro secret` Manage server-owned secrets ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### Subcommands | Command | Description | | - | - | | `fabro secret list` | List secret names | | `fabro secret rm` | Remove a secret | | `fabro secret set` | Set a secret value | #### `fabro secret list` List secret names ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret list [OPTIONS] ``` #### `fabro secret rm` Remove a secret ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret rm [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `KEY` | Name of the secret to remove | #### `fabro secret set` Set a secret value ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro secret set [OPTIONS] [VALUE] ``` #### Arguments | Name | Description | | - | - | | `KEY` | Name of the secret | | `VALUE` | Value to store (omit to enter interactively) | #### Options | Option | Description | | - | - | | `--description ` | Optional human-readable description | | `--type ` | Secret storage type
Values: `token`, `file`
Default: `token` | | `--value-stdin` | Read the secret value from stdin | ### `fabro server` Server operations ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro server restart` | Stop and restart the HTTP API server | | `fabro server start` | Start the HTTP API server | | `fabro server status` | Show server status | | `fabro server stop` | Stop the HTTP API server | #### `fabro server restart` Stop and restart the HTTP API server ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server restart [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--bind ` | Address to bind to (IP or IP:port for TCP, or path containing / for Unix socket) | | `--config ` | Path to server config file (default: \~/.fabro/settings.toml) | | `--environment ` | Named environment for agent tools | | `--foreground` | Run in the foreground instead of daemonizing | | `--max-concurrent-runs ` | Maximum number of concurrent run executions | | `--model ` | Override default LLM model | | `--no-web` | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | | `--provider ` | Override default LLM provider | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | | `--timeout ` | Seconds to wait for graceful shutdown before SIGKILL
Default: `10` | | `--watch-web` | Run `bun run dev` in apps/fabro-web to watch/recompile web assets (debug only) | | `--web` | Enable the embedded web UI and browser auth routes | #### `fabro server start` Start the HTTP API server ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server start [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--bind ` | Address to bind to (IP or IP:port for TCP, or path containing / for Unix socket) | | `--config ` | Path to server config file (default: \~/.fabro/settings.toml) | | `--environment ` | Named environment for agent tools | | `--foreground` | Run in the foreground instead of daemonizing | | `--max-concurrent-runs ` | Maximum number of concurrent run executions | | `--model ` | Override default LLM model | | `--no-web` | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | | `--provider ` | Override default LLM provider | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | | `--watch-web` | Run `bun run dev` in apps/fabro-web to watch/recompile web assets (debug only) | | `--web` | Enable the embedded web UI and browser auth routes | #### `fabro server status` Show server status ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server status [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--json` | Output as JSON | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | #### `fabro server stop` Stop the HTTP API server ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server stop [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | | `--timeout ` | Seconds to wait for graceful shutdown before SIGKILL
Default: `10` | ### `fabro settings` Inspect effective settings ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro settings [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro start` Start a created workflow run on the server ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro start [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID prefix or workflow name | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro steer` Steer a running agent mid-execution ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro steer [OPTIONS] [TEXT] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID prefix to steer | | `TEXT` | Steer message text (omit when --text-stdin is used) | #### Options | Option | Description | | - | - | | `--interrupt` | Cancel the in-flight LLM stream / tool calls and deliver the message as the next user turn (default: append to the steering queue) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--stage ` | Agent stage to steer, as its stage id (node\@visit) or node name (default: the run's one live agent stage) | | `--text-stdin` | Read steer text from stdin instead of a positional arg | ### `fabro system` System maintenance commands ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro system [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro system df` | Show disk usage | | `fabro system events` | Stream run events from the server | | `fabro system info` | Show server runtime information | | `fabro system prune` | Delete old workflow runs | | `fabro system repair` | Inspect and repair durable server data | #### `fabro system df` Show disk usage ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro system df [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | | `-v, --verbose` | Show per-run breakdown | #### `fabro system events` Stream run events from the server ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro system events [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--run-id ` | Filter by run ID (repeatable) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | #### `fabro system info` Show server runtime information ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro system info [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | #### `fabro system prune` Delete old workflow runs ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro system prune [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--before ` | Only include runs started before this date (YYYY-MM-DD prefix match) | | `--label ` | Filter by label (KEY=VALUE, repeatable, AND semantics) | | `--older-than ` | Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h when no explicit filters are set | | `--orphans` | Include orphan directories (no matching durable run) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | | `--workflow ` | Filter by workflow name (substring match) | | `--yes` | Actually delete (default is dry-run) | #### `fabro system repair` Inspect and repair durable server data ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro system repair [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro system repair runs` | List runs that cannot be loaded from durable storage | ##### `fabro system repair runs` List runs that cannot be loaded from durable storage ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro system repair runs [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--delete` | Preview deleting unreadable runs | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--storage-dir ` | Local storage directory (default: \~/.fabro/storage) | | `--yes` | Actually delete unreadable runs (default is dry-run) | ### `fabro timeline` Show the checkpoint timeline of a workflow run ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro timeline [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN_ID` | Run ID (or unambiguous prefix) | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro unarchive` Restore archived runs to their prior terminal status ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro unarchive [OPTIONS] ... ``` #### Arguments | Name | Description | | - | - | | `RUNS` | Run IDs or workflow names to unarchive | #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro uninstall` Uninstall Fabro from this machine ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro uninstall [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--yes` | Skip confirmation prompt | ### `fabro upgrade` Upgrade fabro to the latest version ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro upgrade [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--dry-run` | Preview what would happen without making changes | | `--force` | Upgrade even if already on the target version | | `--prerelease` | Include prereleases (alpha, beta, rc) when selecting the latest version | ### `fabro validate` Validate a workflow ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro validate [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `WORKFLOW` | Path to the .fabro workflow file | ### `fabro variable` Manage server-owned variables ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro variable [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | #### Subcommands | Command | Description | | - | - | | `fabro variable get` | Get a variable value | | `fabro variable list` | List variables | | `fabro variable rm` | Remove a variable | | `fabro variable set` | Set a variable value | #### `fabro variable get` Get a variable value ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro variable get [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `NAME` | Name of the variable to get | #### `fabro variable list` List variables ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro variable list [OPTIONS] ``` #### `fabro variable rm` Remove a variable ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro variable rm [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `NAME` | Name of the variable to remove | #### `fabro variable set` Set a variable value ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro variable set [OPTIONS] [VALUE] ``` #### Arguments | Name | Description | | - | - | | `NAME` | Name of the variable | | `VALUE` | Value to store | #### Options | Option | Description | | - | - | | `--description ` | Optional human-readable description | | `--value-stdin` | Read the variable value from stdin | ### `fabro version` Show client and server version information ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro version [OPTIONS] ``` #### Options | Option | Description | | - | - | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | ### `fabro wait` Block until a workflow run completes ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro wait [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `RUN` | Run ID prefix or workflow name (most recent run) | #### Options | Option | Description | | - | - | | `--interval ` | Poll interval in milliseconds
Default: `1000` | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `--timeout ` | Maximum time to wait in seconds | ### `fabro workflow` Workflow operations ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro workflow [OPTIONS] ``` #### Subcommands | Command | Description | | - | - | | `fabro workflow create` | Create a new workflow | | `fabro workflow list` | List available workflows | #### `fabro workflow create` Create a new workflow ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro workflow create [OPTIONS] ``` #### Arguments | Name | Description | | - | - | | `NAME` | Name of the workflow | #### Options | Option | Description | | - | - | | `-g, --goal ` | Goal description for the workflow | #### `fabro workflow list` List available workflows ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro workflow list [OPTIONS] ``` # Fabro Language Source: https://docs.fabro.sh/reference/dot-language Complete reference for Fabro's Graphviz workflow language Fabro workflows are written in a subset of the [Graphviz DOT language](https://graphviz.org/doc/info/lang.html) with extensions for agent orchestration. This page is the complete syntax reference. For conceptual introductions, see [Workflows](/core-concepts/workflows) and [Nodes & Stages](/workflows/stages-and-nodes). ## File structure Every workflow is a `digraph` (directed graph) with a name and a body of statements: ```dot title="my-workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph MyWorkflow { graph [goal="Describe the project"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] scan [label="Scan Files", shape=parallelogram, script="find . -type f | head -30"] analyze [label="Analyze", shape=tab, prompt="Summarize the project structure."] start -> scan -> analyze -> exit } ``` Only `digraph` is supported — `graph` (undirected) and `strict` are not. The graph name is required. Semicolons after statements are optional. ## Comments ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} // Line comment — everything to end of line /* Block comment spanning multiple lines */ ``` Comments inside quoted strings are preserved as literal text. ## Value types Attribute values in `[key=value]` blocks can be: | Type | Syntax | Examples | | - | - | - | | String | Double-quoted | `"Run tests"`, `"line1\nline2"` | | Integer | Bare digits, optional sign | `42`, `-1`, `0` | | Float | Digits with decimal point | `3.14`, `-0.5`, `.5` | | Boolean | Keywords | `true`, `false` | | Duration | Integer with unit suffix | `250ms`, `30s`, `15m`, `2h`, `1d` | | Bare string | Identifier with hyphens/dots | `claude-sonnet-4-5`, `gpt-5.4` | | Identifier | Bare word | `LR`, `box`, `Mdiamond` | **Escape sequences** in quoted strings: `\"`, `\\`, `\n`, `\t`. **Duration units:** `ms` (milliseconds), `s` (seconds), `m` (minutes), `h` (hours), `d` (days). ## Statements The body of a digraph can contain these statement types: ### Graph attributes Set workflow-level configuration: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} // Block syntax graph [goal="Build a feature", model_stylesheet="* { model: claude-haiku-4-5; }"] // Declaration syntax rankdir=LR ``` | Attribute | Type | Description | | - | - | - | | `goal` | String | Workflow objective — guides agent behavior | | `rankdir` | Identifier | Layout direction: `LR` (left-to-right) or `TB` (top-to-bottom) | | `model_stylesheet` | String | CSS-like rules for model assignment. The root value supports a MiniJinja template with `inputs` and `vars` (see [Model Stylesheets](/workflows/stylesheets)) | | `default_max_retries` | Integer | Default retry count for all nodes (default: 0) | | `on_failure` | String | Failed-node policy when no explicit recovery route matches: `route` (default), `exit`, or `succeed` | | `retry_target` | String | Default node ID to jump to on retry | | `fallback_retry_target` | String | Fallback retry target if primary target fails | | `default_fidelity` | String | Default [fidelity level](/execution/context) for all nodes | | `default_thread` | String | Default thread ID for all nodes | | `max_node_visits` | Integer | Max visits per node across the run (0 = unlimited) | | `stall_timeout` | Duration | Timeout for stalled workflows (default: `1800s`, 0 = disabled) | | `loop_restart_signature_limit` | Integer | Max times the same failure signature can repeat before aborting (default: 3) | ### Node defaults Apply default attributes to all subsequently declared nodes: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} node [shape=box, timeout="900s"] ``` Defaults are scoped to their enclosing subgraph. Explicit attributes on individual nodes override defaults. ### Edge defaults Apply default attributes to all subsequently declared edges: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} edge [weight=5] ``` ### Node declarations Declare a node with optional attributes: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} plan [label="Plan", prompt="Create an implementation plan."] ``` **Node identifiers** must start with a letter or underscore, followed by letters, digits, or underscores (e.g. `run_tests`, `gate_1`, `_private`). Every node used by an edge needs its own declaration. Validation fails when an edge names a node the workflow never declares, because that is nearly always a typo or a rename that missed an edge. The declaration can come before or after the edges that use it, and it can live in a subgraph. ### Edge declarations Connect nodes with directed edges: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} start -> plan -> implement -> exit ``` Chained edges like `A -> B -> C` expand to individual edges `A -> B` and `B -> C`, all sharing the same attributes. Edges can have attributes: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate -> exit [label="Pass", condition="outcome=succeeded"] gate -> implement [label="Fix"] ``` ### Subgraphs Group nodes visually and apply scoped defaults: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} subgraph cluster_impl { label = "Implementation" node [thread_id="impl", fidelity="full"] plan [label="Plan"] implement [label="Implement"] review [label="Review"] } ``` When a subgraph has a `label`, it is converted to a CSS class name and applied to all nodes within the subgraph (e.g. `"Implementation"` becomes class `implementation`, `"Loop A"` becomes `loop-a`). This enables [stylesheet](/workflows/stylesheets) targeting. Node and edge defaults declared inside a subgraph are scoped — they don't leak to the outer graph. Edges can cross subgraph boundaries. ## Node types Fabro uses an explicit `type` attribute to select a node's handler. Without `type`, the `shape` attribute selects the handler. See [Nodes & Stages](/workflows/stages-and-nodes) for detailed documentation of each type. | Shape | Handler | Purpose | | - | - | - | | `Mdiamond` | start | Workflow entry point (exactly one required) | | `Msquare` | exit | Workflow terminal (exactly one required) | | `box` (default) | agent | Multi-turn LLM with tool access | | `tab` | prompt | Single LLM call, no tools | | `parallelogram` (inferred from `script` when `shape` and `type` are omitted) | command | Execute a shell script | | `hexagon` | human | Human-in-the-loop decision gate | | `diamond` | conditional | Route based on conditions | | `component` | parallel | Fan-out to concurrent branches | | `tripleoctagon` | parallel.fan\_in | Merge parallel branch results | | `insulator` | wait | Pause for a duration | | `house` | stack.manager\_loop | Sub-workflow orchestration | The `type` attribute can be set explicitly to override the shape-based mapping and attribute inference. Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be identified by ID (`exit`, `Exit`, `end`, or `End`). ### Omitting the shape The two most common node types don't need a `shape` or `type`. When both are omitted, a node that sets `script` is a command node. Every other node defaults to an agent node: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} plan [prompt="Plan the work"] // agent build [script="cargo build"] // command ``` An explicit `shape` or `type` always wins. For example, `shape=box` with a `script` is still an agent node, and the `script` has no effect. Setting both `script` and `prompt` on one node is an error because command handlers consume `script`, while LLM handlers consume `prompt`. Other node types still need their shape, because their attributes don't identify them uniquely. In particular, agent and prompt nodes take the same attributes, so a prompt node needs `shape=tab`. ## Node attributes ### All nodes | Attribute | Type | Description | | - | - | - | | `label` | String | Display name in the graph visualization | | `shape` | Identifier | Graphviz shape — determines handler type (see table above) | | `type` | String | Explicit handler type (overrides shape) | | `class` | String | Classes for [stylesheet](/workflows/stylesheets) targeting. Separate multiple classes with spaces. Commas are also accepted for compatibility. | | `timeout` | Duration | Execution timeout (e.g. `900s`). An agent's wait for human input does not consume this budget. On a human node, this is the response deadline. | | `max_visits` | Integer | Max times this node can execute in a run. Overrides the graph-level `max_node_visits` for this node. | | `on_failure` | String | Failed-node policy for this node: `route`, `exit`, or `succeed`. Overrides the graph-level `on_failure`. See [Node Outcomes](/execution/outcomes#succeed-on-failure). | | `max_retries` | Integer | Override default retry count | | `retry_policy` | String | Named preset: `none`, `standard`, `aggressive`, `linear`, `patient` | | `retry_target` | String | Node ID to jump to on retry | | `fallback_retry_target` | String | Fallback node ID if primary `retry_target` is unreachable | | `goal_gate` | Boolean | When `true`, workflow fails if this node didn't finish with `succeeded` or `partially_succeeded`. See [Node Outcomes](/execution/outcomes#goal-gate-interaction). | | `auto_status` | Boolean | Deprecated alias for `on_failure="succeed"`. Validation warns when it is present. | | `allow_partial` | Boolean | When `true` and retries are exhausted on a retry-requesting failure, promotes the outcome to `partially_succeeded` instead of `failed`. Default `false`. See [Node Outcomes](/execution/outcomes#allow_partial). | | `selection` | String | Edge tiebreaking strategy: `deterministic` (default) or `random` (weighted-random). Cannot be combined with conditional edges. | ### Agent and prompt nodes | Attribute | Type | Description | | - | - | - | | `prompt` | String | Task instructions for the LLM. Supports file references with `@path/to/file.md` | | `reasoning_effort` | String | `low`, `medium`, or `high` (default: `high`) | | `max_tokens` | Integer | Maximum output tokens | | `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate`. On a node entered directly from a parallel fork, this is overridden by the fork-to-branch edge; explicit `full` degrades to `summary:high`. | | `thread_id` | String | Groups nodes into a shared conversation thread. Inert when the node is entered directly from a parallel fork. | | `model` | String | Explicit model ID (overrides stylesheet) | | `provider` | String | Explicit provider name (overrides stylesheet). Auto-inferred from the model catalog when omitted. | | `project_memory` | Boolean | When `true` (default), prompt nodes discover and include project docs (`AGENTS.md`, `CLAUDE.md`, etc.) as a system prompt. Set to `false` to disable. | | `output_schema` | String | Optional structured output validation. Use `routing` for Fabro's built-in routing directive schema, `@path/to/schema.json` for a JSON Schema file, or an inline JSON Schema object string. Supported on agent, prompt, and command nodes. | | `output_retries` | Integer | Corrective structured-output turns inside the same prompt conversation or agent session. Default `2`; `0` validates once and fails without repair; negative values are treated as `0`. Separate from `max_retries`. | | `backend` | String | Agent execution backend: `api` (default) or `acp`. `api` runs Fabro's tool loop through provider APIs; `acp` runs an Agent Client Protocol stdio agent inside the active sandbox. Prompt nodes are API-only. See [Agents — Backends](/core-concepts/agents#backends). | | `acp.command` | String | Shell command for nodes with `backend="acp"`. Mutually exclusive with `acp.config`. The value is always parsed as a command string, not JSON. | | `acp.config` | String | JSON stdio ACP config for nodes with `backend="acp"`. Mutually exclusive with `acp.command`. | #### Structured output validation `output_schema` opts an agent, prompt, or command node into strict JSON validation: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review [ shape=tab, output_schema="routing", output_retries=2 ] audit [ shape=tab, output_schema="@schemas/audit-result.schema.json", output_retries=2 ] ``` * `output_schema="routing"` requires a JSON object with at least one recognized routing field: `preferred_next_label`, `outcome`, `failure_reason`, `suggested_next_ids`, or `context_updates`. * `output_schema="@schemas/audit-result.schema.json"` loads a JSON Schema file using workflow file-reference rules and validates the final JSON object in the response text. Inline JSON Schema object strings are also accepted, but file references are usually easier to read. * API-backed agent nodes receive the resolved output contract in their task instructions. The contract applies only to the final response, so the agent can still use tools and send intermediate progress while it works. * On validation failure, Fabro sends validation feedback to the same active context before failing: prompt nodes keep the prior assistant response in the message list, and API-backed agent nodes repair in the same live session. * `output_retries` defaults to `2` and controls only these corrective structured-output turns. Negative values are treated as `0`. It is not the same as `max_retries` and does not consume workflow retry attempts. * Custom schema output is stored in context at `output.{node_id}`. Routing schema output updates routing fields and any `context_updates`. * Agent routing fallbacks still apply to `output_schema="routing"`: response text first, then `status.json`, then the last file touched by the agent. The last-file fallback only accepts `.json` and `.md` files (case-insensitive) whose final JSON object contains the routing directive; only whitespace may follow it. Custom schemas and prompt nodes validate response text only. * Command nodes validate merged stdout and stderr only after the script exits with code `0`, using the same object selection as agents and prompts: custom schemas validate the last JSON object, and `routing` validates the last JSON object containing a recognized routing field. Print the intended JSON object last. A validation error is a deterministic, non-retryable failure with no repair turn or `status.json` fallback. `output_retries`, `retry_policy`, and `max_retries` do not retry it. Nonzero exits retain normal command failure behavior without schema validation. * For commands, custom schema output is stored at `output.{node_id}`; edge conditions cannot traverse into its fields. The `routing` schema applies routing fields and merges `context_updates` into flat context keys, which conditions can read (for example, `context.kept_count`). * `backend="acp"` with `output_schema` is unsupported in this release. ### Command nodes | Attribute | Type | Description | | - | - | - | | `script` | String | Shell command to execute (required). Its presence infers a command node when `shape` and `type` are omitted. | | `language` | String | `"shell"` (default) or `"python"` | | `stdin_source` | String | Flat runtime context key to pass to standard input. `context.NAME` first checks that exact key, then falls back to `NAME`. Strings are passed unchanged; other values use compact JSON. No newline is added. | | `output_schema` | String | Optional structured output validation. Accepts `routing`, `@path/to/schema.json`, or an inline JSON Schema object string. See [Structured output validation](#structured-output-validation). | ### Parallel (fan-out) nodes | Attribute | Type | Description | | - | - | - | | `max_parallel` | Integer | Maximum concurrent branches (default: 4). The node always waits for every branch. | | `for_each` | String | Flat runtime context key containing a JSON array. Runs the node's single agent or prompt target once per item. `context.items` first checks that exact key, then falls back to `items`. | For the first node in each branch, `fidelity` resolves from the fork-to-branch edge, then the branch node; without either, the fork preamble is inherited unchanged. Branch-specific preambles are rendered before fan-out from the fork's context snapshot. Concurrent branches cannot share sessions, so explicit branch `full` becomes `summary:high`, and branch-level `thread_id` is inert. When `for_each` is set, the parallel node must have exactly one outgoing edge, and its target must be an agent or prompt node. Fabro resolves the source as an inline array or a managed `blob://` or `file://` JSON artifact, then clones the target once per item. Nested `for_each` is not supported. Each clone receives the target's normal prompt followed by the item as pretty JSON inside a fresh `` fence with a matching closing tag, plus a fixed notice that the content is data, not instructions. There is no item interpolation syntax. The fence prevents an item from closing its own data block, but it does not restrict an agent's tools; workflow authors must give the target only the tool access appropriate for untrusted item data. A source array above 1000 items fails the parallel stage before any branch starts. Filter the array in the node that produces it, or split the work across runs. Dynamic results retain input order. Each result uses the template target ID and adds a zero-based `index` plus `item_label`, derived from the item's `name`, then `label`, then its index. An empty array succeeds and proceeds directly to the target's fan-in node without executing the unparameterized target. ### Wait nodes | Attribute | Type | Description | | - | - | - | | `duration` | Duration | How long to pause (required). E.g. `"30s"`, `"2m"` | ### Human nodes | Attribute | Type | Description | | - | - | - | | `question_type` | String | Optional interview question type override: `yes_no`, `confirmation`, `multiple_choice`, `multi_select`, or `freeform`. Defaults to `freeform` when the gate only has a freeform edge; otherwise defaults to `multiple_choice`. | | `review_target` | Boolean | When `true`, read and validate the typed `review_target` context value, then present it as the primary link in the question. Fabro generates the question text, so the node's `label` is not used. See [Review targets](/workflows/human-in-the-loop#review-targets). | | `human.default_choice` | String | Target node to use when the question times out. | ### Manager loop (sub-workflow) nodes | Attribute | Type | Description | | - | - | - | | `stack.child_workflow` | String | Path to child workflow `.fabro` file (required unless inline source given) | | `stack.child_dot_source` | String | Inline DOT source for the child workflow (alternative to file path) | | `manager.poll_interval` | Duration | How often the manager checks the child workflow (default: `45s`) | | `manager.max_cycles` | Integer | Maximum polling cycles before timeout (default: 1000) | | `manager.stop_condition` | String | Condition expression — when true, the child run is stopped early | ## Edge attributes | Attribute | Type | Description | | - | - | - | | `label` | String | Display text; also used for human gate option matching | | `condition` | String | Boolean expression for conditional routing (see below) | | `weight` | Integer | Priority for tiebreaking (higher wins, default: 0) | | `fidelity` | String | Override fidelity level for this transition. On a fork-to-branch edge, takes precedence over the branch node; explicit `full` degrades to `summary:high`. | | `thread_id` | String | Override thread ID for this transition. Inert on fork-to-branch edges. | | `loop_restart` | Boolean | Restart the workflow from this edge's target when taken: stage history and retry counts clear and the context resets to empty (visit counts are kept). Failed outcomes may only take it for `transient_infra` failures — see [Failures](/execution/failures#loop-restart-edges) | | `freeform` | Boolean | When `true` on a human-gate edge, accept free-text input instead of fixed choices | ## Condition expressions Edge conditions are boolean expressions evaluated against the stage outcome and run context. See [Transitions](/workflows/transitions) for the full routing logic. ### Grammar ``` Expr ::= OrExpr OrExpr ::= AndExpr ('||' AndExpr)* AndExpr ::= UnaryExpr ('&&' UnaryExpr)* UnaryExpr ::= '!' UnaryExpr | Clause Clause ::= Key Op Value | Key (bare key = truthy check) Value ::= BareWord | '"' QuotedString '"' Op ::= '=' | '!=' | '>' | '<' | '>=' | '<=' | 'contains' | 'matches' ``` ### Keys | Key | Resolves to | | - | - | | `outcome` | Stage outcome: `succeeded`, `failed`, `partially_succeeded`, or `skipped`. See [Node Outcomes](/execution/outcomes#outcome-in-edge-conditions). | | `preferred_label` | Label selected by a human gate or LLM routing directive | | `context.KEY` | Value from the run context | | `KEY` | Shorthand for context lookup (without the `context.` prefix) | ### Operators | Operator | Example | Description | | - | - | - | | `=` | `outcome=succeeded` | Equality | | `!=` | `outcome!=failed` | Inequality | | `>` | `context.score > 80` | Greater than (numeric) | | `<` | `context.count < 5` | Less than (numeric) | | `>=` | `context.score >= 80` | Greater than or equal | | `<=` | `context.count <= 10` | Less than or equal | | `contains` | `context.message contains error` | Substring match or array membership | | `matches` | `context.version matches ^v\d+` | Regular expression match | | `&&` | `a=1 && b=2` | Logical AND (binds tighter than `\|\|`) | | `\|\|` | `a=1 \|\| b=2` | Logical OR | | `!` | `!outcome=failed` | Logical NOT | A bare key with no operator is a **truthiness check** — it passes if the value is non-empty, not `"false"`, and not `"0"`. ### Examples ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} // Simple outcome check gate -> exit [condition="outcome=succeeded"] // Compound condition gate -> deploy [condition="outcome=succeeded && context.tests_passed=true"] // Either outcome gate -> proceed [condition="outcome=succeeded || outcome=partially_succeeded"] // Negation gate -> retry [condition="!outcome=succeeded"] // Numeric comparison gate -> fast_path [condition="context.score > 80"] // Substring search gate -> alert [condition="context.log contains error"] // Regex match gate -> v2 [condition="context.version matches ^v2\\."] // Unconditional fallback (no condition attribute) gate -> slow_path ``` ## Prompt and schema file references Instead of inlining long prompts or JSON Schemas, reference an external file: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} simplify [label="Simplify", prompt="@prompts/simplify.md"] audit [shape=tab, output_schema="@schemas/audit-result.schema.json"] ``` The `@` prefix tells Fabro to load the referenced file relative to the workflow file. Paths support `~` (home directory) and `..` (parent directory): ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} shared [prompt="@~/shared-prompts/review.md"] parent [prompt="@../common/plan.md"] ``` Untracked `@file` references (files not committed to git) are inlined into the Graphviz source at prepare time, so they work even inside sandboxes that only see the git tree. Fabro validates `@file` references at parse time — if the referenced file does not exist, validation fails with a clear error pointing to the bad reference. ## Validation Fabro validates workflows at parse time and reports diagnostics. Key rules: * Exactly one start node and one exit node * All nodes reachable from start * No incoming edges to start, no outgoing edges from exit * Edge targets reference existing nodes * Condition expressions parse correctly * Stylesheet syntax is valid * LLM nodes (agent, prompt) have a `prompt` attribute * `@file` references point to existing files * Conditional nodes have multiple outgoing edges with conditions * Retry targets reference existing nodes * Goal gates have retry configuration * `thread_id` requires `fidelity="full"` (session reuse depends on full fidelity) * Known handler types only ## Complete example ```dot title="implement-feature.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph ImplementFeature { graph [ goal="Implement a feature with tests and code review", model_stylesheet=" * { model: claude-haiku-4-5;reasoning_effort: low; } .coding { model: claude-sonnet-4-5;reasoning_effort: high; } #review { model: claude-sonnet-4-5;reasoning_effort: high; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] // Planning phase plan [label="Plan", shape=tab, prompt="Create a detailed implementation plan for: {{ goal }}"] // Human approval approve [shape=hexagon, label="Approve Plan"] // Implementation (threaded for context continuity) subgraph cluster_impl { label = "Implementation" node [thread_id="impl", fidelity="full"] implement [label="Implement", class="coding", prompt="Implement the approved plan."] test [label="Write Tests", class="coding", prompt="Write comprehensive tests."] } // Validation validate [label="Run Tests", shape=parallelogram, script="cargo test 2>&1 || true"] gate [shape=diamond, label="Tests passing?"] // Review review [label="Code Review", shape=tab, prompt="Review the implementation for correctness."] // Wiring start -> plan -> approve approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] implement -> test -> validate -> gate gate -> review [label="Pass", condition="outcome=succeeded"] gate -> implement [label="Fix"] review -> exit } ``` # Run Directory Source: https://docs.fabro.sh/reference/run-directory Structure of Fabro's per-run directory The run directory structure and file formats described here are internal implementation details and subject to change without notice. Do not build tooling that relies on them. The authoritative source of run state is the event-sourced run store; the scratch directory is now mostly local runtime state and caches. Each `fabro run` invocation creates a timestamped directory under `~/.fabro/storage/scratch/`: ``` ~/.fabro/storage/scratch/20260307-01JQXYZ123ABC456DEF789/ ``` The naming format is `YYYYMMDD-{run_id}`, where `run_id` is the ULID assigned to the run. You can override the base storage directory with the global `--storage-dir` flag (the scratch directory will be `/scratch/`). ## Root-level files | File | Format | When written | Description | | - | - | - | - | | `workflow_bundle.json` | JSON | Run create | Bundled workflow input used to restart the run without re-reading the original workflow files. Includes the root workflow path plus bundled child workflow sources and inline files. | | `run.pid` | Text | Legacy only | Legacy process ID file from older runs. Current detached launches use launcher records instead, and current attach/resume no longer read `run.pid`. | ## Local-only directories These paths are local runtime state and caches, not the canonical run state. * **`worktree/`** — When running in worktree mode, Fabro creates a Git worktree here as the working directory for agents and commands. * **`runtime/`** — Local runtime files. Today this includes `runtime/server.log` for the raw per-run worker tracing log and materialized blob payloads under `runtime/blobs/`. * **`nodes/{manager_node}_{visit}/child/`** — Nested scratch directories for manager-loop child workflows. Large durable values, event streams, checkpoints, diffs, and conclusions are no longer projected into live scratch by default. Use `fabro events`, `fabro inspect`, the API, or `fabro dump` for those surfaces. Use `fabro logs` for the raw per-run worker tracing log when it is available. ## Reconstructed and export-only layouts Metadata branch snapshots and `fabro dump` exports now use the same core layout: * `run.json` for the current projection snapshot, including the current checkpoint * `graph.fabro` for workflow source * `stages/{rank:03}-{node_id}@{visit}/...` for execution-order-prefixed per-stage prompt, response, status, diff, and command output files `fabro dump` adds export-only history surfaces on top of that shared layout: * `events.jsonl` for the durable event stream * `checkpoints/*.json` for checkpoint history snapshots * `artifacts/{node_id}@{visit}/...` for exported artifact payloads ## Browsing runs Use `fabro ps` to scan the scratch directory and display a table of all runs with their status, workflow name, and timestamps. Pass `--json` for machine-readable output. ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro ps fabro ps --json fabro ps --filter workflow=my-workflow ``` ## Full directory tree ``` ~/.fabro/storage/scratch/ ├── 20260307-01JQXYZ123ABC456DEF789/ # One directory per run │ ├── workflow_bundle.json │ ├── run.pid # Legacy only; older runs may contain this │ ├── runtime/ │ │ └── blobs/ │ │ └── 01JT5Y3KJ0N5S9E1Y7YFBR2G4D.json │ ├── cache/ │ │ └── artifacts/ │ │ └── files/ │ │ └── test/ │ │ └── retry_1/ │ │ ├── test-results/ │ │ │ └── screenshot.png │ ├── nodes/ │ │ └── manager/ │ │ └── child/ │ │ ├── workflow_bundle.json │ │ ├── runtime/ │ │ ├── cache/ │ │ └── worktree/ │ └── worktree/ # Git worktree (git checkpoint mode) ``` # Fabro SDK Source: https://docs.fabro.sh/reference/sdk Using Fabro as a Rust library for AI agents and multi-provider LLM completions Fabro can be used as a Rust SDK with two primary entry points: * **`pebble-coding-agent`** — the coding agent Fabro runs its agent stages, Ask Fabro sessions, hook evaluators, and `fabro exec` on. Use it with `fabro-sandbox` when you want an agent that can read files, run commands, and interact with a codebase. * **`fabro-llm`** — a standalone LLM client for multi-provider completions, streaming, and tool execution loops. Use this when you want direct control over LLM calls without the agent layer. Both can be used independently of Fabro's workflow engine. ## Agent (`pebble-coding-agent` over `fabro-sandbox`) Fabro does not ship its own agent loop. Its agent stages run pebble's `CodingAgent`, and `fabro-sandbox`'s `RunSandbox` is the `Environment` the agent's tools act through: the local filesystem, a Docker container, or a cloud sandbox. The agent loop streams model responses, executes tool calls (`shell`, `read_file`, `write_file`, `edit_file`, `apply_patch`, `glob`, `grep`, `web_fetch`, `web_search`, subagents), feeds results back, and repeats until the model answers or a limit is hit. ```toml title="Cargo.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [dependencies] fabro-auth = { git = "https://github.com/fabro-sh/fabro" } fabro-llm = { git = "https://github.com/fabro-sh/fabro" } fabro-sandbox = { git = "https://github.com/fabro-sh/fabro" } pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble" } tokio = { version = "1", features = ["full"] } ``` Pin `pebble-coding-agent` to the revision Fabro's workspace `Cargo.toml` pins; `RunSandbox` implements that revision's `Environment` contract. ### Quick start ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use std::path::PathBuf; use std::sync::Arc; use fabro_auth::VaultCredentialSource; use fabro_llm::ClientOptions; use fabro_sandbox::local_sandbox; use pebble_coding_agent::environment::Environment; use pebble_coding_agent::events::CodingEvent; use pebble_coding_agent::tools::PermissionLevel; use pebble_coding_agent::{CodingAgent, ShutdownReason}; #[tokio::main] async fn main() -> Result<(), Box> { let catalog = fabro_llm::default_catalog(); let client = fabro_llm::build_client( catalog, Arc::new(VaultCredentialSource::environment_only()), ClientOptions::standard(), ) .await? .client; let sandbox: Arc = Arc::new(local_sandbox(PathBuf::from(".")).await?); let mut agent = CodingAgent::builder(client, sandbox) .model("anthropic/claude-sonnet-4.5") .permission_level(PermissionLevel::Full) .build() .await?; // Subscribe to events before sending input let mut events = agent.subscribe(); tokio::spawn(async move { while let Ok(event) = events.recv().await { if let CodingEvent::TextDelta { delta } = &event.event { print!("{delta}"); } } }); let report = agent.prompt("List the files in this directory").await; agent.shutdown(ShutdownReason::Completed).await?; report.result?; Ok(()) } ``` ### CodingAgent `CodingAgent` is the core type. `CodingAgent::builder(client, environment)` takes the lithos client and the environment; the builder picks the model (`provider/model`), the permission level, tool middleware, application tools, a human-input provider, a system prompt transform, an event sink, options, and subagent limits. `build()` initializes the agent: it probes the environment, loads memory files and skills, and assembles the system prompt. **Lifecycle methods:** | Method | Description | | - | - | | `prompt(input).await` | Runs one user prompt and every queued follow-up to completion. Returns a `PromptReport` with the result, token usage, cost, and timing. | | `prompt_with_cancellation(input, &token).await` | The same, ending early when the token fires. The agent stays reusable. | | `continue_prompt_with_cancellation(&token).await` | Continues an unfinished prompt on the history as it stands, such as after a model failover. | | `shutdown(reason).await` | Ends the agent, emits `SessionEnded`, and flushes events. | | `control_handle()` | A cloneable handle for steering, interrupting, and aborting from another task. | **Inspection:** | Method | Description | | - | - | | `history()` | The conversation as `History` (a sequence of `Message` values). | | `snapshot()` | The agent's identity, route, tools, memory, and skills at the last committed event. | | `subscribe()` | A broadcast receiver for `CodingAgentEvent` values. | | `to_record()` | The durable `SessionRecord`, restored with `CodingAgent::resume`. | **Steering** goes through the control handle: `queue_steering(message)` injects guidance at the next turn boundary, `steer_now(message)` interrupts the round first, `interrupt()` parks the prompt until a steer arrives, and `queue_follow_up(message)` queues another user turn. ### CodingAgentOptions Set with the builder's `.options(...)`. Key settings with their defaults: | Setter | Default | Description | | - | - | - | | `with_reasoning_effort` / `with_speed` | `None` | Request controls for the model. | | `with_max_tokens` | catalog default | The most tokens the model may produce per turn. | | `with_loop_detection` | `true` | Stop a session that is repeating itself. | | `with_context_compaction` | `true` | Summarize old turns when approaching the context window limit. | | `with_compaction_threshold_percent` | `80` | Context window usage that triggers compaction. | | `with_wall_clock_timeout` | `None` | Hard timeout for a prompt. Reported as `InterruptReason::WallClockTimeout`. | | `with_max_turns` | unlimited | The most model turns one prompt may use. | | `with_memory_files` | none | Files loaded into the system prompt as memory (Fabro passes `AGENTS.md` and the profile's own file). | | `with_skill_dirs` | none | Directories searched for `SKILL.md` files. | Subagents are enabled with `.subagents(SubagentOptions::enabled())`; `SubagentLimits` bounds how many child sessions may be open at once. ### Sandbox `RunSandbox` is where tools execute: the local filesystem, a Docker container, or a cloud sandbox. It is one concrete type over a [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) sandbox, and every tool operation goes through it. Paths resolve against the run's working directory, commands run as Bash with fabro's timeout and stop policy, and output is drained even when the retained copy is capped. ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} impl RunSandbox { pub async fn read_file_bytes(&self, path: &str) -> Result>; pub async fn read_file_text(&self, path: &str) -> Result; pub async fn read_file(&self, path: &str, offset: Option, limit: Option) -> Result; pub async fn write_file(&self, path: &str, content: &str) -> Result<()>; pub async fn delete_file(&self, path: &str) -> Result<()>; pub async fn file_exists(&self, path: &str) -> Result; pub async fn list_directory(&self, path: &str, depth: Option) -> Result>; pub async fn exec_command( &self, command: &str, timeout_ms: u64, working_dir: Option<&str>, env_vars: Option<&HashMap>, cancel_token: Option, ) -> Result; pub async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result>; pub async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result>; pub async fn glob(&self, pattern: &str, path: Option<&str>) -> Result>; pub async fn initialize(&self) -> Result<()>; pub async fn cleanup(&self) -> Result<()>; pub fn working_directory(&self) -> &str; pub fn platform(&self) -> &str; pub fn os_version(&self) -> String; // ... plus git setup and push, credentials refresh, preview URLs, and access commands. } ``` `RunSandbox` also implements pebble's `Environment` trait, so an `Arc` is what a `CodingAgent` is built over. The mapping lives in `fabro_sandbox::environment` and is checked against pebble's environment contract suite. `DirEntry`, `GrepMatch`, `GrepOptions`, and `WalkOptions` are the driver's own types, re-exported from `fabro_sandbox`. **Constructors:** | Function | Description | | - | - | | `local_sandbox(directory)` | Executes directly on the local filesystem through the sandbox driver Host provider. | | `provider_sandbox(kind, ...)` | Runs on any sandbox driver provider by kind: the bundled `docker` and `daytona` providers in process, or a configured plugin. | **Testing:** `fabro_sandbox::test_support::MockSandbox` (behind the `test-support` feature) describes a scripted sandbox by its fields — seeded files, the result every command returns, the platform — and hands out the `RunSandbox` with `.sandbox()`. Afterwards it reads back what the code did: `captured_commands()`, `written_files()`, `deleted_files()`, and so on. ### Provider profiles Pebble picks the harness profile (system prompt, tool vocabulary, and capability defaults) from the catalog: `metadata.agent.profile` on the model, else on the provider. The `AgentProfileKind` values are `anthropic`, `claude-5`, `openai`, `gemini`, `kimi`, `gpt56`, and `gpt6`. Every lithos built-in provider declares its profile; `fabro_llm::build_catalog` fills in the profile implied by the adapter for an operator-defined provider that declares none, and `fabro_llm::catalog::agent_profile(catalog, provider, model)` reports the resolved profile. ### Events All operations emit `CodingAgentEvent` values (a `CodingEvent` plus session ids, a sequence number, and a timestamp) through a tokio broadcast channel. Subscribe before calling `prompt()`. For a complete durable record install an `EventSink` with the builder; the broadcast channel is bounded and can lag. ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} let mut rx = agent.subscribe(); tokio::spawn(async move { while let Ok(event) = rx.recv().await { match event.event { CodingEvent::TextDelta { delta } => print!("{delta}"), CodingEvent::ToolCallStarted { tool_name, .. } => { println!("[calling {tool_name}]"); } CodingEvent::ToolCallCompleted { tool_name, is_error, .. } => { println!("[{tool_name} done, error={is_error}]"); } CodingEvent::LoopDetected => println!("[loop detected]"), CodingEvent::CompactionCompleted { .. } => println!("[context compacted]"), _ => {} } } }); ``` Key `CodingEvent` variants: | Variant | Description | | - | - | | `SessionStarted` / `SessionEnded` | Session lifecycle. | | `TextDelta { delta }` | Incremental text from the model. | | `ReasoningDelta { delta }` | Incremental reasoning/thinking text. | | `AssistantMessage { text, model, usage, tool_call_count, .. }` | Complete assistant turn with token usage. | | `ToolCallStarted { tool_name, tool_call_id, arguments }` | A tool call is about to execute. | | `ToolCallCompleted { tool_name, tool_call_id, output, is_error, .. }` | A tool call finished. | | `Error { error }` | An `ErrorData` occurred. | | `LoopDetected` | The agent is repeating itself. | | `CompactionStarted` / `CompactionCompleted` | Context window compaction. | | `SubAgentSpawned` / `SubAgentCompleted` | Sub-agent lifecycle. | | `SteeringInjected` / `RoundInterrupted` | Steering and interrupts. | Fabro stores every one of these as an `agent.*` run event whose properties are the `CodingAgentEvent` envelope; `fabro_types::coding_event_name` maps a variant to its run event name. ### Tool middleware Implement pebble's `ToolMiddleware` to intercept tool calls for approval, logging, or transformation, and install it with the builder's `.tool_middleware(...)`. Petri's Attractor agent step installs one for Fabro's `pre_tool_use` and `post_tool_use` hooks. ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use async_trait::async_trait; use pebble_agent::{ToolCallNext, ToolCallRequest, ToolErrorKind, ToolMiddleware, ToolOutcome, ToolSystemError}; struct MyHooks; #[async_trait] impl ToolMiddleware for MyHooks { async fn call( &self, request: ToolCallRequest, next: ToolCallNext<'_>, ) -> Result { if request.call().name == "shell" { return Ok(ToolOutcome::failure(ToolErrorKind::Denied, "shell is not allowed")); } next.run(request).await } } ``` For permission gating, `PermissionMiddleware::new(policy)` hides tools a `ToolPermissionPolicy` denies and routes the rest through an optional `ToolApprovalService`; `PermissionLevelPolicy::new(level)` is the read-only, read-write, full ladder `fabro exec --permissions` uses. ### Error handling `PromptReport::result` is `Result`: | Variant | Description | | - | - | | `Llm(lithos_llm::Error)` | An error from the LLM provider. `llm_source()` reaches it from any variant that wraps one. | | `SessionClosed` | A prompt was sent to a closed agent. | | `InvalidState(String)` | The agent is in an unexpected state. | | `ToolExecution(String)` | A tool execution failed in a way that stops the prompt. | | `Interrupted(InterruptReason)` | The prompt was cancelled, timed out, or used every allowed turn. | | `EventSink(EventSinkError)` | The durable event sink refused an event; the recorded stream is untrustworthy. | *** ## LLM client (`fabro-llm`) The `fabro-llm` crate is Fabro's integration layer over [lithos-llm](https://docs.rs/lithos-llm), a provider-neutral LLM catalog and client. lithos owns the request and response vocabulary, the provider catalog, the wire codecs, streaming, and retries. `fabro-llm` adds what Fabro needs on top: building the catalog from lithos built-ins plus Fabro policy and the operator `[llm]` overlay, constructing a client from a Fabro credential source, inlining local file attachments, normalizing reasoning output, one-shot structured output, model probes, and the `fabro exec` server gateway adapter. Everything below the Fabro layer is the lithos API. `fabro_llm` re-exports the pieces Fabro code touches most: `Client`, `Request`, `Response`, `StreamEvent`, `Error`, `ErrorKind`, `FinishReason`, and the `lithos_catalog`, `types`, `middleware`, `adapter`, and `credentials` modules. See the lithos-llm README for the full client, middleware, and streaming contract. ```toml title="Cargo.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [dependencies] fabro-auth = { git = "https://github.com/fabro-sh/fabro" } fabro-llm = { git = "https://github.com/fabro-sh/fabro" } fabro-types = { git = "https://github.com/fabro-sh/fabro" } tokio = { version = "1", features = ["full"] } serde_json = "1" ``` ### Quick start Build a catalog, build a client over a credential source, then send a lithos `Request`. `VaultCredentialSource::environment_only()` reads provider keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` from the process environment. ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use std::sync::Arc; use fabro_auth::VaultCredentialSource; use fabro_llm::{ClientOptions, Request}; #[tokio::main] async fn main() -> Result<(), Box> { let catalog = fabro_llm::default_catalog(); let built = fabro_llm::build_client( catalog, Arc::new(VaultCredentialSource::environment_only()), ClientOptions::standard(), ) .await?; for issue in &built.build_issues { eprintln!("provider {} is unavailable: {}", issue.provider, issue.cause); } let client = built.client; let request = Request::builder() .model("claude-sonnet-4.5") .user("Explain ownership in Rust in two sentences.") .build()?; let response = client.complete(request).await?; println!("{}", response.text()); println!("Tokens used: {}", response.usage.input + response.usage.billable_output()); Ok(()) } ``` ### Catalog `fabro_llm::default_catalog()` is the lithos built-in catalog with Fabro's policy layer applied. `fabro_llm::build_catalog(&overlay, &env_lookup)` adds an operator `[llm]` overlay on top, the same layering the server and CLI use. `fabro_config::load_llm_overlay(None)` reads that overlay from the active settings file. ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use fabro_config::load_llm_overlay; let overlay = load_llm_overlay(None)?; let catalog = fabro_llm::build_catalog(&overlay, &|name| std::env::var(name).ok())?; ``` The `fabro_llm::catalog` module reads Fabro policy from the catalog: `enabled_providers`, `models`, `model_on_provider`, `default_model`, `probe_model`, `small_default_for_ready`, and `agent_profile`. Disabled providers and models are invisible to every query. `fabro_llm::selection` chooses a provider and model before a request exists, the way run creation and validation do: a known selector resolves to its canonical offering, `provider/model` pins the provider, and an unknown selector on a passthrough provider passes through verbatim. ### Client `fabro_llm::build_client(catalog, credentials, options)` takes any lithos `CredentialProvider` and returns a `FabroClient`: the lithos `Client`, the providers that are ready, the providers whose credentials could not be used, and the providers lithos could not build an adapter for. Credentials are read from the provider on every attempt, so a refreshed OAuth token is picked up without rebuilding the client. `ClientOptions::standard()` turns on the lithos retry middleware (three attempts with short exponential backoff) and local attachment inlining. Add middleware with `with_middleware`, replace a provider's adapter with `with_adapter`, or set `http` to inject a configured HTTP client. `fabro_llm::build_offline_client(catalog, options)` builds a client whose only providers are custom adapters, which is how `fabro exec --server` routes every call through a Fabro server. Credential sources live in `fabro-auth`: `VaultCredentialSource` reads a Fabro vault with an optional process-environment fallback (`VaultCredentialSource::environment_only()` for SDK callers with no vault), and `SqlVaultCredentialSource` reads the server's secret store. lithos-llm decides which secret names a provider reads (`OPENAI_API_KEY`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, or `_API_KEY` for an operator-defined provider); Fabro's vault is keyed by those same names. #### Requests and responses `Request::builder()` is the lithos request builder. `model` takes a `provider/model` route, a model id or alias, or a provider id. `system`, `user`, and `message` add messages; `tool`, `tool_choice`, `response_format`, `max_output_tokens`, `temperature`, `reasoning_effort`, and `speed` set controls. `client.complete(request)` returns a `Response` whose `content` is a list of `ContentPart` values, with `text()` and `tool_calls()` helpers, plus `finish_reason`, `usage`, and `cost`. ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use fabro_llm::Request; use lithos_llm::types::{Message, Role}; let request = Request::builder() .model("openai/gpt-5.4") .system("You are a helpful assistant.") .message(Message::text(Role::User, "What is the capital of France?")) .temperature(0.0) .build()?; let response = client.complete(request).await?; println!("{}", response.text()); ``` There is no tool-execution loop in `fabro-llm`. The agent loop lives in `pebble-coding-agent`, which decides when to run a tool and feeds results back as `Role::Tool` messages. ### Streaming `client.stream(request)` returns a lithos `ResponseStream`, a `Stream` of `StreamEvent` values. Events are discriminated by `type` on the wire: `started`, `content_block_start`, `text_delta`, `reasoning_delta`, `tool_call_delta`, `content_block_end`, `usage`, `rate_limits`, and `ended`, which carries the complete `Response`. ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use fabro_llm::StreamEvent; use futures::StreamExt; let mut stream = client.stream(request).await?; while let Some(event) = stream.next().await { match event? { StreamEvent::TextDelta { text, .. } => print!("{text}"), StreamEvent::Ended { response } => { println!("\n[done: {:?}]", response.finish_reason); } _ => {} } } ``` A turn that ends with `FinishReason::Length` or `FinishReason::Incomplete` is not complete. Tool calls from such a turn arrive in `response.suppressed_tool_calls` and must not be executed. The coding agent treats both as a retryable failure of the turn. ### Structured output `Client::complete_object` (a lithos method) attaches a JSON Schema as the request's response format and parses the reply into a `StructuredCompletion` with the response and the parsed document: ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use fabro_llm::Request; use serde_json::json; let schema = json!({ "type": "object", "properties": { "name": { "type": "string" }, "age": { "type": "integer" } }, "required": ["name", "age"] }); let request = Request::builder() .model("claude-sonnet-4.5") .user("Generate a profile for a fictional character") .build()?; let completion = client.complete_object(request, "profile", schema).await?; println!("Name: {}", completion.object["name"]); ``` ### Reasoning `response.reasoning()` (a lithos method) folds a response's readable reasoning parts into a `ReasoningOutput` with a summary and a trace, whichever channel the provider used. Provider replay data such as signatures and encrypted reasoning never appears in it; `ContentPart::is_replay_material()` marks the parts a conversation keeps for the next request instead. ### Middleware Middleware is the lithos `Middleware` trait: `handle(&self, call: Call, next: Next)` sees the resolved route and request and returns an `Output` that is either a complete response or a stream. `ClientOptions::standard()` installs lithos's `InlineLocalFiles`, which rewrites local file paths in messages into inline media before dispatch. ### Error handling Every fallible operation returns `Result`, the lithos error. `error.kind()` is an `ErrorKind` such as `Authentication`, `RateLimit`, `Server`, `ContextLength`, `ContentFilter`, `Timeout`, `StreamDecode`, or `Cancelled`. `error.data()` is the `ErrorData` snapshot Fabro stores in run events; it reads like `Error`, prints its message, and implements `std::error::Error`. Both `Error` and `ErrorData` answer the policy questions directly; only the loop-detection signature is Fabro's: | Function | Description | | - | - | | `error.is_retryable()` | Safe to retry with the same provider, from lithos's retry classification | | `error.failover_eligible()` | Safe to try a different provider | | `error.is_auth_error()` | The credential was missing or rejected | | `error.is_cancelled()` | The caller cancelled the call | | `fabro_llm::failure_signature_hint(&data)` | A stable string for loop and restart detection | ### Retries The lithos `RetryMiddleware` installed by `ClientOptions::standard()` retries a request until its stream delivers visible output. After visible output the client never replays on its own; the coding agent decides whether to replay a turn using `RetryPolicy::next_delay`, the same decision the middleware uses. Insert a `fabro_llm::RetryListener` into a call's context extensions to be told about each retry the middleware performs. ### Cancellation Pass a `CallContext` with a cancellation token through `complete_with_context` or `stream_with_context`. Cancelling the token ends the call with `ErrorKind::Cancelled`. ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use fabro_llm::CallContext; let context = CallContext::new(); let cancel = context.cancellation().clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(30)).await; cancel.cancel(); }); let result = client.complete_with_context(request, context).await; ``` ### Probes `fabro_llm::probe::run_model_test(&client, "provider/model", mode, reasoning_effort, timeout)` sends the lithos model probe: one word in `Basic` mode, a two-step tool exchange in `Deep` mode. `probe_provider_with_api_key` validates an operator-supplied key against a provider's probe model before it is stored. ### Provider adapters A provider names one lithos adapter in `adapter` (`http`, the default, or `bedrock`) and lists the wire codecs its host speaks in `codecs` (`openai-chat`, the default, `openai-responses`, `anthropic-messages`, `gemini-generate`, or `bedrock-converse`). A new OpenAI-compatible endpoint needs a catalog entry, not code. To add a custom transport, implement the lithos `ProviderAdapter` trait and register it with `ClientOptions::with_adapter`. `fabro_llm::gateway::GatewayAdapter` is Fabro's own example: it posts each request to a Fabro server's completions endpoint, which returns lithos `Response` JSON and streams lithos `StreamEvent` JSON verbatim. ```rust theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} use std::sync::Arc; use fabro_llm::ClientOptions; use fabro_llm::gateway::GatewayAdapter; use lithos_llm::catalog::ProviderId; let adapter = Arc::new(GatewayAdapter::new(Box::new(my_transport))); let built = fabro_llm::build_offline_client( catalog, ClientOptions::default().with_adapter(ProviderId::new("anthropic"), adapter), )?; ``` # Server Operations Source: https://docs.fabro.sh/reference/server-operations Operate the Fabro server: starting, install wizard, auth, web UI, and pointing the CLI at it This page covers operating the Fabro server once it's running, whether locally on your laptop or self-hosted in a container. For where to run it, see [Deployment](/administration/deployment). ## Starting the server ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server start ``` This starts the server on a Unix socket at `~/.fabro/fabro.sock` by default. Use `--bind 127.0.0.1` for TCP. ### First run: web install wizard If `~/.fabro/settings.toml` does not yet exist, `fabro server start` enters **install mode**: it prints an install URL and a one-time install token, attempts to open the URL in your default browser, and serves a web wizard that walks you through configuring your server URL, shared object store, LLM provider, and GitHub integration. The LLM step can be completed with one or more provider keys, or explicitly skipped so you can finish server setup first and add model credentials later. A skipped LLM step writes no LLM vault credentials; LLM-dependent workflows keep failing with provider-not-configured errors until credentials are added. Optional integration secrets collected by install mode, including LLM keys and GitHub App secrets, are written to the server vault rather than `server.env`. When Fabro can construct a direct install URL, the token is embedded in the URL and also printed on its own line for copying. If you open the server root through a reverse proxy or another machine, paste the printed install token when prompted. The `Object store` step offers two wizard-managed modes: * `Local disk` for a host-local object-store root, detected by default and editable before continuing * `AWS S3` for one bucket with the fixed `artifacts/` prefix The wizard's manual-credential path stores only `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in `server.env`. It does not collect STS/session tokens or S3-compatible endpoint settings. If you need MinIO, Cloudflare R2, path-style options, or custom endpoints, finish install with local defaults and then edit `[server.artifacts]` in `settings.toml` manually. When you finish the wizard, the server writes `~/.fabro/settings.toml` and exits cleanly. Start it again to boot in configured mode: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro server start ``` Under a process supervisor with a restart policy (for example docker-compose `restart: unless-stopped`, systemd, or Railway's restart-on-exit) this second start happens automatically. For headless or scripted environments where no browser is available, run `fabro install` instead — it's the same wizard as a CLI prompt flow. Common flags: | Flag | Default | Description | | - | - | - | | `--bind` | `~/.fabro/fabro.sock` | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket | | `--model` | — | Override default LLM model | | `--environment` | — | Override default environment slug | | `--max-concurrent-runs` | `5` | Maximum concurrent run executions | See [Server Configuration](/administration/server-configuration) for the full `settings.toml` reference. ### Upgrading from SlateDB Fabro stores current run history and content-addressed blobs in SQLite. Upgrading from a release that stored these in SlateDB does not automatically import them, so those older runs are not available in the current application. The upgrade leaves existing SlateDB run history and blobs in their original local storage directory or object-store location. Retain that data if you may need to recover historical runs using a reader compatible with the older storage format. Artifact files use the separate local or S3 object store configured through `[server.artifacts]` in [Server Configuration](/administration/server-configuration). #### Legacy SlateDB settings When Fabro loads active server settings that still contain `[server.slatedb]`, it backs up the settings file and removes that section and its subtables. For `settings.toml`, the backup is `settings.toml.server-slatedb-migration.bak` beside the original file. Existing backups are preserved; further backups use numbered suffixes such as `settings.toml.server-slatedb-migration.1.bak`. A warning identifies the rewritten file and its backup. Other settings are preserved by this cleanup, and subsequent loads do nothing once the section is absent. This cleanup changes configuration only. The settings backup contains no run history or blobs. ### SQLite schema upgrades On startup, Fabro applies pending SQLite schema migrations before serving normal traffic. Before changing a database that has previously applied migrations, it writes a snapshot beside the database as `fabro.sqlite3.pre-migration.bak`. A fresh database or a restart with no pending migrations does not create a new snapshot. Each later schema upgrade replaces this snapshot; failure to create it stops the upgrade. The Petri transition drops the former SQL `run_events` table and its activation bookkeeping. Those events are not converted into Petri run history. Current runs use Petri records and Fabro platform records in SQLite. For a database rollback, stop the server and restore the `.pre-migration.bak` snapshot, then remove any `-wal` and `-shm` siblings before starting the corresponding older binary. The snapshot holds the database state immediately before the most recent schema upgrade. Restoring it loses database writes accepted after that snapshot. ## Submitting runs Register the workflow content, create a run from that version, then request execution. This example uses a clone-based default environment and an empty workspace target; add the authentication headers required by your server: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} version_id=$(curl --fail-with-body -sS http://localhost:3000/api/v1/workflow-versions \ -H 'Content-Type: application/json' \ -d '{"entrypoint":"workflow.fabro","files":{"workflow.fabro":"digraph Demo { start [shape=Mdiamond]; exit [shape=Msquare]; start -> exit; }"},"workflow_dependencies":{}}' \ | jq -r '.workflow_version_id') run_id=$(curl --fail-with-body -sS http://localhost:3000/api/v1/runs \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg id "$version_id" '{workflow_version_id:$id,target:{kind:"none"},args:{}}')" \ | jq -r '.id') curl --fail-with-body -X POST "http://localhost:3000/api/v1/runs/$run_id/start" ``` The server returns immediately with a run ID. After a start request, a background scheduler promotes `runnable` runs to `running` in FIFO order, up to the concurrency limit. Parent-generated [child runs](/execution/child-runs) can remain `pending` until a user approves them. ## Run lifecycle 1. **Submit** — `POST /api/v1/runs` creates the run with status `submitted`. 2. **Start request** — `POST /api/v1/runs/{id}/start` makes normal runs `runnable`; parent-generated [child runs](/execution/child-runs) may become `pending` with `approval_required`. 3. **Approve if needed** — Approving a pending child run makes it `runnable`; denying it fails with `approval_denied`. 4. **Schedule** — The scheduler picks up `runnable` runs up to `max_concurrent_runs`. 5. **Execute** — The engine walks the graph, streaming events to all subscribers. 6. **Complete** — The run transitions to `succeeded`, `failed`, or `dead`. ## Web UI The web UI connects to the API server and provides: * **Runs board** — Monitor all active runs organized by status * **Run detail** — Real-time stage progress, event stream, diffs, and usage stats * **Files Changed** — Browse changed files with a searchable tree, per-file status, aggregate diff stats, and split or stacked diffs * **Settings** — Inspect server configuration, enabled integrations, storage, auth, and capacity settings * **Start new run** — Submit workflows from the browser * **Human-in-the-loop** — Answer agent questions through the web interface * **Workflows** — Browse available workflows, view their graphs, and see run history * **Insights** — SQL-based analysis across runs via DuckDB Fabro web UI Runs board with Working, Pending, Verify, and Merge columns Fabro web UI run detail showing stages and workflow graph ## Event streaming The API streams run events via [Server-Sent Events (SSE)](/api-reference/runs/stream-run-events). Every stage start, LLM call, tool invocation, and edge selection is emitted as a structured JSON event. Any HTTP client that supports SSE can subscribe — the web UI is just one consumer. ## Human-in-the-loop Human-in-the-loop questions are served over HTTP. The engine blocks the current stage until an answer is submitted, then continues execution. See the [list questions](/api-reference/human-in-the-loop/list-run-questions) and [submit answer](/api-reference/human-in-the-loop/submit-run-answer) API reference pages. ## Authentication The server configures auth with `server.auth.methods`: * **`dev-token`** — Operators can call the API directly with `Authorization: Bearer fabro_dev_...`, and the web login page can accept the dev token too. * **`github`** — End users sign in through GitHub OAuth and receive a browser session cookie. Both methods can be enabled simultaneously: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [server.auth] methods = ["dev-token", "github"] [server.auth.github] allowed_usernames = ["alice", "bob"] ``` ## Demo mode Send the `X-Fabro-Demo: 1` header on any API request to get static mock data. To enable demo mode in the web UI, set the `fabro-demo=1` cookie in your browser devtools (Application → Cookies). This lets you explore the UI without API keys or real workflow execution. ## Pointing the CLI at a server The CLI can target a running Fabro server for commands that support a remote API. Configure `~/.fabro/settings.toml`: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.target] type = "http" url = "https://fabro.example.com/api/v1" ``` Or use the `--server` flag: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro model list --server https://fabro.example.com/api/v1 ``` For dev-token servers, save the token in the CLI auth store instead of exporting it for every command: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro auth login --server https://fabro.example.com/api/v1 --dev-token fabro_dev_... ``` `fabro model list` and `fabro model test` honor `[cli.target]` by default unless you explicitly pass `--storage-dir`. `fabro exec` remains a local agent session and only uses the server when you pass `--server`. See [User Configuration](/reference/user-configuration#cli-target-section) for the full connection options, including client certificates for proxy-terminated HTTPS endpoints. ## Next steps Choose where the server runs: laptop or self-hosted Docker container. Full settings.toml reference — authentication, reverse-proxy TLS, run defaults, and more. REST API for submitting runs, streaming events, and managing resources. The workflow engine and architecture. # Shell Completions Source: https://docs.fabro.sh/reference/shell-completions Set up tab completion for the fabro CLI in your shell The `fabro completion` command generates shell completion scripts for tab-completing commands, flags, and arguments. ## Bash Add to your `~/.bashrc`: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} eval "$(fabro completion bash)" ``` Or generate a file and source it: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro completion bash > ~/.local/share/bash-completion/completions/fabro ``` ## Zsh Add to your `~/.zshrc` (before `compinit`): ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} eval "$(fabro completion zsh)" ``` Or generate a file: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro completion zsh > "${fpath[1]}/_fabro" ``` You may need to run `compinit` or start a new shell session for changes to take effect. ## Fish ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro completion fish | source ``` Or persist to the completions directory: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro completion fish > ~/.config/fish/completions/fabro.fish ``` ## PowerShell Add to your PowerShell profile: ```powershell theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro completion powershell | Out-String | Invoke-Expression ``` ## Elvish ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} eval (fabro completion elvish | slurp) ``` ## Supported shells Run `fabro completion --help` to see all supported shells: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro completion --help ``` # Settings Configuration Source: https://docs.fabro.sh/reference/user-configuration Configure CLI and shared machine defaults with settings.toml Fabro loads machine defaults from `~/.fabro/settings.toml`. The file is optional. If it does not exist, Fabro falls back to built-in defaults. The CLI and server each read the sections relevant to their own process. On a remote deployment, the CLI machine and the server machine each have their own `settings.toml`. Fabro only reads `settings.toml`. Older `cli.toml`, `user.toml`, and `server.toml` filenames are no longer part of the supported config surface. ## File location The default path is `~/.fabro/settings.toml`. Use `fabro server start --config /path/to/settings.toml` if the server should read a different file. ## Schema version Every Fabro config file must declare its schema version with a top-level `_version` key: ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 ``` Files that omit `_version` are treated as version `1`. The legacy top-level `version` key is no longer accepted and raises a targeted rename hint. ## Who reads what `settings.toml` uses the same schema as `.fabro/project.toml` and `workflow.toml`, but each process only reads the fields it understands. The top-level schema is strictly namespaced — the only allowed domains are `[project]`, `[workflow]`, `[run]`, `[llm]`, `[cli]`, and `[server]`. | Scope | Examples | | - | - | | CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` | | Server-side run policy | `[run.model]`, `[run.environment]`, `[environments.]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` | | Shared LLM catalog | `[llm]`, a lithos-llm catalog overlay: `[llm.providers.]`, `[llm.providers..models.]`, and the agent harness under `metadata.agent` | | Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` | `[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert. `fabro run` and `fabro create` do not copy the CLI machine's `[run]` or `[environments]` tables into an intent request. They warn with only the file path and affected key names when those tables are present. Put workflow-owned behavior in `workflow.toml` and configure placement in environments managed by the target server. The server may still use its own active `settings.toml` independently. See [Server Configuration](/administration/server-configuration) for the server-owned sections. ## Precedence For CLI-created workflow runs, sparse CLI flags override immutable `workflow.toml` behavior. The CLI does not layer local project or machine run defaults into the request. A target server resolves its own server-side policy and environment catalog during admission. Owner-specific domains (`[cli.*]`, `[server.*]`) use a narrower trust boundary — only CLI flags, env overrides, `~/.fabro/settings.toml`, and built-in defaults apply. ## Full example ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [cli.target] type = "http" url = "https://fabro.example.com/api/v1" [cli.exec] prevent_idle_sleep = true [cli.exec.model] provider = "anthropic" name = "claude-opus-4-6" [cli.exec.agent] permissions = "read-write" [cli.output] format = "text" verbosity = "normal" [cli.updates] check = true [cli.logging] level = "info" [llm.providers.proxy] display_name = "Acme Gateway" base_url = "https://llm-gateway.example.com/v1" auth = { type = "bearer" } aliases = ["gateway"] default_model = "team-code-large" [llm.providers.proxy.default_headers] x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}" x-portkey-config = "@bedrock-prod" [llm.providers.proxy.metadata.agent] profile = "anthropic" [llm.providers.proxy.models."team-code-large"] display_name = "Team Code Large" aliases = ["team-code"] api_model = "provider-wire-model-name" limits = { context_tokens = 200000, max_output_tokens = 32000 } capabilities = { text = true, tools = true, reasoning = true, reasoning_effort = { low = true, medium = true, high = true } } protocol_options = { reasoning_effort_levels = true } pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000 } ``` All fields are optional. Include only the sections and keys you want to override. A single file can still include both CLI and server sections when you run both processes on one machine, but explicit remote targets do not read remote server state from the local machine. The `[run]` schemas below remain valid for a server's own active configuration, but they are not CLI-side defaults for `fabro run` or `fabro create`. Put automatic pull-request settings and other workflow-owned behavior in `workflow.toml`; there is no compatibility request field or CLI global default. ## `[cli.target]` Connection info for commands that target a remote Fabro server. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.target] type = "http" url = "https://fabro.example.com/api/v1" ``` | Key | Type / values | Default | Description | | - | - | - | - | | `type` | `"http"` \| `"unix"` | None | Explicit transport selection. | | `url` | string | None | Required for `type = "http"`; the API base URL. | | `path` | string | None | Required for `type = "unix"`; the absolute Unix socket path. | ## `[llm]` The `[llm]` table is a [lithos-llm](https://docs.rs/lithos-llm) catalog overlay. Fabro builds its model catalog from two layers: the lithos built-in providers and models, and this table. Later layers win; tables merge key by key and every other value replaces. Fabro does not interpret the table itself. lithos validates it when the catalog is built, and rejects unknown provider or model fields. Several built-in providers ship with `enabled = false`. Turn one on by setting `enabled = true` on its provider table. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [llm.providers.proxy] display_name = "Acme Gateway" base_url = "https://llm-gateway.example.com/v1" auth = { type = "bearer" } priority = 50 aliases = ["gateway"] default_model = "team-code-large" [llm.providers.proxy.default_headers] x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}" x-portkey-config = "@bedrock-prod" [llm.providers.proxy.metadata.agent] profile = "anthropic" [llm.providers.proxy.models."team-code-large"] display_name = "Team Code Large" aliases = ["team-code"] api_model = "provider-wire-model-name" limits = { context_tokens = 200000, max_output_tokens = 32000 } capabilities = { text = true, tools = true, reasoning = true, caching = true, reasoning_effort = { low = true, medium = true, high = true } } protocol_options = { reasoning_effort_levels = true } pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000, cached_input_usd_micros_per_million = 300000 } family = "team-code" small_default = true estimated_output_tps = 80 ``` A provider's API key is the secret lithos names for it: `OPENAI_API_KEY` for `openai`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` for `modal`, and `_API_KEY` (upper case, `-` and `.` as `_`) for a provider you define, so the gateway above reads `PROXY_API_KEY`. Store it in the server vault with `fabro secret set`, or export it for `fabro exec` and SDK use. ## `[llm.providers.]` Define or override an LLM provider. The keys are the lithos provider record. | Key | Type / values | Default | Description | | - | - | - | - | | `display_name` | string | required for new providers | Human-readable provider name. | | `adapter` | string | `"http"` | lithos adapter id: `http` or `bedrock`. Any other id names a custom adapter the application registered. | | `codecs` | `array` | `["openai-chat"]` | Wire codecs the host speaks, in preference order: `openai-chat`, `openai-responses`, `anthropic-messages`, `gemini-generate`, or `bedrock-converse`. A Chat Completions host needs no line. | | `base_url` | string | required for new providers | Provider API base URL. The `openai-chat` codec appends `/v1/chat/completions` unless the URL already ends in a version segment. | | `auth` | table | required for new providers | Auth scheme: `{ type = "bearer" }`, `{ type = "header", name = "x-api-key" }`, `{ type = "headers" }`, `{ type = "none" }`, or `{ type = "aws" }`. | | `enabled` | boolean | `true` | Set `false` to hide a provider from Fabro. `bedrock`, `bedrock-openai`, `litellm`, `modal`, and `ollama` ship disabled. | | `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection. | | `aliases` | `array` | `[]` | Additional provider names accepted by model routing and fallback config. | | `default_model` | string | None | The provider's default model id. | | `allow_passthrough` | boolean | `false` | Whether `provider/model` selectors may name models the catalog does not list. | | `api_key_url` | string | None | Where an operator obtains an API key. Shown by `fabro provider login` and the install flow. | | `stands_in_for` | string | None | Another provider this one answers for when that provider has no credentials. `openai-codex` stands in for `openai`. | | `default_headers` | table | `{}` | Headers attached to every request. A value may be literal text or a `{{ secrets.NAME }}` token resolved against the vault. | ## `[llm.providers..metadata.agent]` Which coding harness the provider's models expect. Pebble reads the same namespace. Every key is optional; a model row overrides the provider. | Key | Type / values | Default | Description | | - | - | - | - | | `profile` | `"anthropic"` \| `"claude-5"` \| `"openai"` \| `"gemini"` \| `"kimi"` \| `"gpt56"` \| `"gpt6"` | derived from `adapter` | Agent profile for models on this provider. | | `reasoning_by_default` | boolean | reasoning models with effort levels: `true` | Whether requests reason when no `reasoning_effort` is supplied. | ## `[llm.providers..models.]` Define or override one provider's offering of a model. The table key is the model id Fabro users reference. An offering's identity is the pair `(provider, model id)`, so different providers may use the same id and aliases. `api_model` is the string sent to the provider and defaults to the id. | Key | Type / values | Default | Description | | - | - | - | - | | `display_name` | string | required for new models | Human-readable model name. | | `aliases` | `array` | `[]` | Additional selectors. Aliases may repeat across providers. | | `api_model` | string | model id | Wire model identifier sent to this provider. | | `limits` | `{ context_tokens, max_output_tokens }` | None | Token limits. | | `capabilities` | table | unknown | Per-capability `true`, `false`, or `"unknown"`: `text`, `images`, `audio`, `documents`, `tools`, `reasoning`, `caching`, `cache_routing`, `sampling`, plus `tool_choice = { required, named }`, `response_format = { json_object, json_schema }`, `reasoning_effort = { minimal, low, medium, high, xhigh, max }`, and `speed = { fast, balanced, economical }`. | | `protocol_options` | table | `{}` | Encoding flags: `reasoning_effort_levels`, `cache_breakpoints`, `system_turns`. | | `pricing` | table | None | USD micros per million tokens: `input_usd_micros_per_million`, `output_usd_micros_per_million`, `cached_input_usd_micros_per_million`, `cache_write_usd_micros_per_million`, plus optional `long_context` and `speed` tiers. | | `family` | string | model id | Family label for display and grouping. | | `training_cutoff` | string | None | Training data cutoff, as the provider states it. | | `knowledge_cutoff` | string | None | Public knowledge cutoff label, as a person would write it. | | `estimated_output_tps` | number | None | Estimated output tokens per second. | | `small_default` | boolean | `false` | Preferred for small utility calls such as generated run titles. | | `probe` | boolean | `false` | Preferred for provider connectivity probes. | ## `[llm.providers..models..metadata.agent]` The same keys as the provider-level `metadata.agent` table, applied to one model. `profile` here is how a Kimi or GPT-5.6 model keeps its own harness on a gateway whose other models use the provider default. ## `[cli.updates]` `[cli.updates]` — upgrade check toggle ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.updates] check = true ``` | Key | Type / values | Default | Description | | - | - | - | - | | `check` | boolean | true | Check for new Fabro releases during supported CLI commands. | ## `[cli.output]` `[cli.output]` — generic CLI output defaults ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.output] format = "text" verbosity = "verbose" ``` | Key | Type / values | Default | Description | | - | - | - | - | | `format` | "text" \| "json" | "text" | Output format for commands that support machine-readable output. | | `verbosity` | "quiet" \| "normal" \| "verbose" | "normal" | Default output verbosity. | ## `[cli.exec]` `[cli.exec]` — `fabro exec` defaults ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.exec] prevent_idle_sleep = true ``` | Key | Type / values | Default | Description | | - | - | - | - | | `prevent_idle_sleep` | boolean | false | Prevent idle sleep on macOS while an exec run is in flight. | ## `[cli.exec.model]` ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.exec.model] provider = "anthropic" name = "claude-opus-4-6" ``` | Key | Type / values | Default | Description | | - | - | - | - | | `name` | string | None | Model name for `fabro exec`. | | `provider` | string | None | LLM provider for `fabro exec`. | ## `[cli.exec.agent]` ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.exec.agent] permissions = "read-write" ``` | Key | Type / values | Default | Description | | - | - | - | - | | `mcps` | table | None | Agent-scoped MCP entries for `fabro exec`. | | `permissions` | "read-only" \| "read-write" \| "full" | "read-write" | Tool permission level for `fabro exec`. | ## `[run.model]` `[run.model]` — provider-neutral default model selection ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.model] provider = "anthropic" name = "claude-sonnet-4-5" [run.model.fallbacks] "claude-sonnet-4-5" = ["openrouter:kimi-k3", "gpt-terra"] ``` | Key | Type / values | Default | Description | | - | - | - | - | | `fallbacks` | `table>` | Model-keyed fallback chains. Each value is an ordered list of bare
providers, bare model IDs or aliases, or provider-qualified
`provider:selector` values. A qualified selector may be a model ID,
alias, or provider API ID. Legacy `provider/model` values remain
accepted. Each list supports the `...` splice marker at layering time.
Fabro selects one chain from the original requested model. A fallback
target never activates another model's chain.

Model keys stay unresolved in this sparse layer because `fabro
validate` is offline and has no server model catalog. | | | `name` | string | None | Model name for workflow runs. | | `provider` | string | None | Provider name for workflow model selection. | ## `[cli.logging]` `[cli.logging]` — process-owned logging configuration for the CLI ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [cli.logging] level = "info" ``` | Key | Type / values | Default | Description | | - | - | - | - | | `level` | "error" \| "warn" \| "info" \| "debug" \| "trace" | "info" | Default CLI log level. | ## `[run.git.author]` ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.git.author] name = "fabro-bot" email = "fabro-bot@company.com" ``` | Key | Type / values | Default | Description | | - | - | - | - | | `email` | string | resolved from the run's GitHub credential | Git author and committer email for every commit the run creates. When
unset, the run uses the credential's noreply address, else
`noreply@fabro.sh`. | | `name` | string | resolved from the run's GitHub credential | Git author and committer name for every commit the run creates. When
unset, the run uses its GitHub App bot or PAT user, else `Fabro`. | ## `[run.pull_request]` `[run.pull_request]` — provider-neutral PR behavior ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.pull_request] enabled = true ``` | Key | Type / values | Default | Description | | - | - | - | - | | `auto_merge` | boolean | false | Enable GitHub auto-merge for created pull requests. Implies `draft =
false`. | | `draft` | boolean | true | Open created pull requests as drafts. | | `enabled` | boolean | false | Automatically create a PR after successful runs. | | `merge_strategy` | "merge" \| "squash" \| "rebase" | "squash" | Merge method to configure for the pull request. | ## `[run.agent]` `[run.agent]` — agent knobs only (Fabro tools and MCPs) ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent] fabro_tools = true ``` | Key | Type / values | Default | Description | | - | - | - | - | | `fabro_tools` | boolean | false | Allow workflow agents to use Fabro run-management tools. | | `mcps` | table | None | Agent-scoped MCP server entries, keyed by name. | ## `[run.agent.mcps.]` Configure MCP servers for workflow agents. For `fabro exec`-only MCPs, use `[cli.exec.agent.mcps.]` with the same shape. ```toml title="settings.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [run.agent.mcps.filesystem] type = "stdio" command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"] startup_timeout = "15s" tool_timeout = "90s" ``` | Key | Type / values | Default | Description | | - | - | - | - | | `type` | `"stdio"` \| `"http"` \| `"sandbox"` | None | MCP transport type. | | `command` | `array` | None | Command and arguments for `stdio` or `sandbox` transports. | | `script` | string | None | Shell script alternative to `command` for process-launching transports. A `stdio` script runs on the host through `sh -c`; a `sandbox` script is evaluated inside the sandbox by non-login Bash. | | `url` | string | None | Remote MCP URL for `http` transport. | | `port` | integer | None | Sandbox port for `sandbox` transport. | | `env` | table | `{}` | Additional environment variables for process-launching transports. | | `headers` | table | `{}` | HTTP headers for `http` transport. | | `startup_timeout` | duration | `"10s"` | Max duration for startup and MCP handshake. | | `tool_timeout` | duration | `"60s"` | Max duration for a single MCP tool call. | See [MCP](/agents/mcp) for transport-specific examples. # Verifying Releases Source: https://docs.fabro.sh/reference/verifying-releases Verify Fabro binaries and images were built by our release workflow Fabro publishes [SLSA Build Provenance](https://slsa.dev/) attestations for every release tarball and the multi-arch Docker image. Attestations are signed via [Sigstore](https://www.sigstore.dev/) and recorded in the public transparency log, so you can verify that an artifact was built by our GitHub Actions workflow from a specific commit in [`fabro-sh/fabro`](https://github.com/fabro-sh/fabro) before you run it. Provenance proves **where and how** an artifact was built. It does not vouch for the correctness or security of the source code itself — only that what you downloaded was built by our workflow from the commit it claims. ## Prerequisites Install the [GitHub CLI](https://cli.github.com/) and authenticate: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gh auth login ``` ## Verify a binary tarball Download the tarball for your platform from the [releases page](https://github.com/fabro-sh/fabro/releases), then: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gh attestation verify fabro-aarch64-apple-darwin.tar.gz --repo fabro-sh/fabro ``` A successful verification prints `Loaded digest …` followed by `✓ Verification succeeded!` and details of the matched attestation. ## Verify the Docker image The attestation is attached to the image in GHCR, so no separate download is needed: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gh attestation verify oci://ghcr.io/fabro-sh/fabro: --repo fabro-sh/fabro ``` Replace `` with the release version (e.g. `0.207.0`) or use `latest` / `nightly`. ## Homebrew installs The `fabro-sh/tap` Homebrew formula verifies a SHA-256 checksum against each tarball Homebrew downloads, so there is no separate attestation step to run at install time. If you want provenance verification for a Homebrew-installed release, download the matching tarball from the [releases page](https://github.com/fabro-sh/fabro/releases) and run `gh attestation verify` against it. # Branch & Loop Source: https://docs.fabro.sh/tutorials/branch-loop Conditionals, test validation loops, and max_visits This tutorial builds an implement-test-fix loop — the agent writes code, runs tests, and if they fail, fixes the code and tries again. No human intervention needed. ## The workflow Branch-Loop workflow: Start → Plan → Implement → Validate → Tests passing? → Pass to Exit or Fix back to Implement ```dot title="branch-loop.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph BranchLoop { graph [goal="Create a Python script that passes its test suite"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] plan [label="Plan", prompt="Plan a small Python script (fizzbuzz.py) and a test file (test_fizzbuzz.py) using pytest. Describe what you will create.", shape=tab, reasoning_effort="low"] implement [label="Implement", prompt="Create fizzbuzz.py and test_fizzbuzz.py as planned. Write the files to disk."] validate [label="Validate", shape=parallelogram, script="python3 -m pytest test_fizzbuzz.py -v 2>&1 || true"] gate [shape=diamond, label="Tests passing?"] start -> plan -> implement -> validate -> gate gate -> exit [label="Pass", condition="outcome=succeeded"] gate -> implement [label="Fix"] } ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/05-branch-loop.fabro ``` ## Command nodes The `validate` node has `shape=parallelogram`, making it a **command node**. It runs a shell script and captures the output: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} validate [label="Validate", shape=parallelogram, script="python3 -m pytest test_fizzbuzz.py -v 2>&1 || true"] ``` The `|| true` ensures the command always exits successfully — this way the node itself doesn't fail even when tests fail. The test output is captured as `command.output` in the [run context](/execution/context) for downstream nodes to use. ## Conditional branching The `gate` node has `shape=diamond`, making it a **conditional node**. It evaluates outgoing edge conditions against the current run context: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate [shape=diamond, label="Tests passing?"] gate -> exit [label="Pass", condition="outcome=succeeded"] gate -> implement [label="Fix"] ``` * If the previous stage (`validate`) succeeded → take the "Pass" edge to `exit` * Otherwise → take the unconditional "Fix" edge back to `implement` The `condition="outcome=succeeded"` checks the status of the most recently completed stage. An edge without a `condition` acts as the default fallback. ### Condition expressions Conditions support more than just equality checks: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} // Numeric comparisons gate -> fast [condition="context.score > 80"] // Substring matching gate -> alert [condition="context.log contains error"] // Boolean logic gate -> deploy [condition="outcome=succeeded && context.tests_passed=true"] // Negation gate -> retry [condition="!outcome=succeeded"] ``` See [Transitions](/workflows/transitions) for the full condition grammar. ## The fix loop When tests fail, execution loops back to `implement`. The agent receives context about what happened — the test output and failure details — so it can fix the issues: ``` start → plan → implement → validate → gate → [Fix] → implement → validate → gate → [Pass] → exit ``` Each time `implement` runs, it sees the previous test output in its preamble, guiding the fix. ## Preventing infinite loops This workflow has no explicit loop limit, but in production you should add one. Use `max_visits` to cap how many times a node can execute: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [label="Implement", max_visits=3, prompt="..."] ``` After 3 visits, Fabro terminates the run rather than looping forever. You can also set a graph-level limit: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} graph [max_node_visits="20"] ``` ## Fixed-count loops Sometimes you want a node to execute exactly N times before moving on — for example, running an improvement pass 5 times. Use `internal.node_visit_count` in an edge condition: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph FixedLoop { graph [goal="Run an improvement loop 5 times"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] improve [label="Improve", prompt="Pick an area and improve it."] gate [shape=diamond, label="5 Done?"] start -> improve -> gate gate -> exit [condition="context.internal.node_visit_count >= 5"] gate -> improve } ``` The engine tracks how many times each node has been visited. When `gate` has been visited 5 times, the condition matches and execution advances to `exit`. The unconditional edge back to `improve` serves as the default fallback for earlier visits. ## Node type summary This workflow uses four node types: | Node | Shape | What it does | | - | - | - | | `plan` | `tab` | Single LLM call, no tools | | `implement` | `box` (default) | Agent with tool access | | `validate` | `parallelogram` | Runs a shell script | | `gate` | `diamond` | Routes based on conditions | ## What you've learned * **Command nodes** (`shape=parallelogram`) run shell scripts and capture output * **Conditional nodes** (`shape=diamond`) route execution based on edge conditions * **Loops** are just edges that point backward — the agent receives prior context on each visit * **`max_visits`** prevents infinite loops * **`internal.node_visit_count`** in edge conditions enables fixed-count loops * Unconditional edges act as default fallbacks ## Next Fan out to concurrent branches and merge the results. # Ensemble Source: https://docs.fabro.sh/tutorials/ensemble Multi-provider fan-out and shared-checkout result synthesis This tutorial combines parallel execution with multi-model routing to get independent opinions from four different LLM providers, then synthesizes the results. This is the ensemble pattern — useful when you want diverse perspectives, consensus-based decisions, or protection against any single model's blind spots. ## The workflow Ensemble workflow: Start → Fan Out → Opus, Gemini, Codex, Mercury → Merge → Synthesize → Exit ```dot title="ensemble.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Ensemble { graph [ goal="Get independent opinions from multiple providers, then synthesize", model_stylesheet=" #opus { model: claude-opus-4-6; } #gemini { model: gemini-3.1-pro-preview;} #codex { model: gpt-5.4; } #mercury { model: mercury-2; provider: inception; } #synth { model: claude-opus-4-6; reasoning_effort: high; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] fork [label="Fan Out", shape=component] opus [label="Opus", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] gemini [label="Gemini", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] codex [label="Codex", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] mercury [label="Mercury", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] merge [label="Synthesize", shape=tripleoctagon, prompt="Compare every branch result: identify consensus, highlight disagreements, and synthesize the strongest ideas into one recommendation. Note where models agreed and diverged."] synth [label="Write Recommendation", prompt="Write the synthesized analysis as a single coherent recommendation.", shape=tab] start -> fork fork -> opus fork -> gemini fork -> codex fork -> mercury opus -> merge gemini -> merge codex -> merge mercury -> merge merge -> synth -> exit } ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/11-ensemble.fabro ``` This workflow requires API keys for all four providers (`ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `OPENAI_API_KEY`, `INCEPTION_API_KEY`). If a provider key is missing, that branch will fail — but the remaining branches still complete. ## How it works The workflow has three phases: ### 1. Fan-out to four providers The `fork` node spawns four parallel branches, each assigned to a different provider via the stylesheet: ``` #opus { model: claude-opus-4-6; } #gemini { model: gemini-3.1-pro-preview;} #codex { model: gpt-5.4; } #mercury { model: mercury-2; provider: inception; } ``` Each branch receives the same prompt but runs on a completely different model. The branches execute concurrently and have no knowledge of each other's responses. ### 2. Merge results The `merge` node collects all four responses. It waits for every branch — even if some fail. A missing API key or provider outage doesn't cancel the entire workflow. ### 3. Synthesize The `synth` node receives all four perspectives in its preamble and produces a unified recommendation. It uses `reasoning_effort: high` because comparing and synthesizing multiple viewpoints is a harder task than generating any single one. ## Combining patterns This workflow combines two patterns from earlier tutorials: * **Parallel execution** from [Parallel Review](/tutorials/parallel-review) — fan-out/fan-in with join policies * **Model routing** from [Multi-Model Routing](/tutorials/multi-model) — stylesheet selectors assigning different providers to each node The key difference from the parallel review tutorial is that here each branch uses a *different provider*, not just a different prompt. This gives you genuinely independent perspectives — each model has different training data, different reasoning patterns, and different blind spots. ## When to use ensembles The ensemble pattern is most valuable when: * **Correctness matters more than speed** — e.g., security audits, architectural decisions, spec reviews * **You want to detect model-specific blind spots** — if three models agree and one disagrees, the disagreement is worth investigating * **You need confidence in a judgment call** — consensus across models is stronger than any single model's opinion The tradeoff is cost and latency — you're making 4x the LLM calls. Use single-model workflows for routine tasks and ensembles for high-stakes decisions. ## What you've learned * **Ensemble workflows** fan out the same task to multiple providers * **ID selectors** (`#opus`, `#gemini`) assign each branch to a specific model * A **synthesis node** compares perspectives and produces a unified result * Combine parallel execution and model routing for diverse, independent analysis ## Next Delegate to reusable child workflows with the supervisor pattern. # Hello World Source: https://docs.fabro.sh/tutorials/hello-world Your first workflow: prompt nodes, tool use, and sub-agents This tutorial walks through three minimal workflows that introduce the building blocks of Fabro: a one-shot prompt, an agent with tool access, and a sub-agent delegation pattern. ## Prerequisites Complete the [Quick Start](/getting-started/quick-start) so you have a working `fabro` binary and at least one LLM API key configured. ## 1. One-shot prompt The simplest possible workflow has one node that sends a prompt to an LLM and exits. Hello World workflow: Start → Compose → Exit ```dot title="hello.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Hello { graph [goal="Write a haiku about software workflows"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] compose [label="Compose", prompt="Write a haiku (5-7-5 syllable) about software workflows. Output only the haiku, nothing else.", shape=tab, reasoning_effort="low"] start -> compose -> exit } ``` Run it: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/01-hello.fabro ``` ### What's happening * `shape=tab` makes this a **prompt node** — a single LLM call with no tool access. Good for generation, summarization, and classification. * `reasoning_effort="low"` tells the model to think less. This is a simple task that doesn't need deep reasoning. * `graph [goal="..."]` describes the workflow's purpose. Fabro uses it in preambles and agent context. Every workflow needs exactly one `start` node (`shape=Mdiamond`) and one `exit` node (`shape=Msquare`). ## 2. Agent with tools An **agent node** (the default `box` shape) runs an LLM in a loop with access to tools — bash, file reading, file editing, grep, and glob. The agent calls tools autonomously until it decides the task is complete. Tool Use workflow: Start → Explore → Exit ```dot title="tool-use.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph ToolUse { graph [goal="Explore the current directory using shell tools"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] explore [label="Explore", prompt="Use bash to list the files in the current directory, then read the first 5 lines of any README or CLAUDE.md file you find. Summarize what this project is about in 2-3 sentences."] start -> explore -> exit } ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/02-tool-use.fabro ``` ### What's happening * No `shape` attribute means the default `box` — an **agent node**. * The agent has access to [built-in tools](/agents/tools): `shell`, `read_file`, `write_file`, `edit_file`, `grep`, `glob`, `web_search`, and `web_fetch`. * The agent decides which tools to call and when to stop. Fabro handles the tool loop automatically. ### Prompt vs. agent nodes | | Prompt node (`tab`) | Agent node (`box`) | | - | - | - | | LLM calls | Single call | Multi-turn loop | | Tool access | None | Full toolset | | Use case | Analysis, generation | Tasks requiring file I/O and commands | ## 3. Sub-agents An agent can spawn **sub-agents** to delegate work. Sub-agents run in their own session and return results to the parent. Sub-agent workflow: Start → Research → Exit ```dot title="sub-agent.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph SubAgent { graph [goal="Research and summarize using a sub-agent"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] research [label="Research", prompt="You have a sub-agent available via the spawn_agent tool. Spawn a sub-agent to list the files in the current directory and read the first 10 lines of any README or CLAUDE.md. Then, using the sub-agent's findings, write a 2-sentence summary of the project."] start -> research -> exit } ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/03-subagent.fabro ``` ### What's happening * The parent agent uses `spawn_agent` to create a child session, then `wait` to collect the result. * Sub-agents have their own tool access and conversation history — they don't see the parent's context. * This pattern is useful for parallelizing research, isolating risky operations, or keeping the parent's context window lean. See [Sub-agents](/agents/subagents) for the full tool reference. ## What you've learned * **Prompt nodes** (`shape=tab`) make a single LLM call — no tools * **Agent nodes** (default `box`) run a multi-turn tool loop * **Sub-agents** let an agent delegate to independent child sessions * Every workflow needs a `start` node, an `exit` node, and a `goal` ## Next Add human gates and revision loops to a multi-step workflow. # Multi-Model Routing Source: https://docs.fabro.sh/tutorials/multi-model Model stylesheets, CSS selectors, and per-node reasoning effort This tutorial assigns different models to different tasks in a single workflow — a cheap, fast model for the spec, a capable model for coding, and a different model for review. The routing is controlled by a CSS-like stylesheet. ## The workflow Multi-Model workflow: Start → Write Spec (Haiku) → Implement (Sonnet) → Write Tests (Sonnet) → Code Review (Sonnet) → Exit ```dot title="multi-model.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph MultiModel { graph [ goal="Build and review a utility function using multiple models", model_stylesheet=" * { model: claude-haiku-4-5;reasoning_effort: low; } .coding { model: claude-sonnet-4-5;reasoning_effort: high; } #review { model: claude-sonnet-4-5;reasoning_effort: high; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] spec [label="Write Spec", prompt="Write a brief spec for a TypeScript string utility module with 3 functions: slugify, truncate, and capitalize. Output the spec only.", shape=tab] implement [label="Implement", prompt="Implement the TypeScript string utility module from the spec. Write it to string-utils.ts.", class="coding"] test [label="Write Tests", prompt="Write tests for the string utility module using Bun's test runner. Write to string-utils.test.ts.", class="coding"] review [label="Code Review", prompt="Review the implementation and tests. Check for edge cases, type safety, and correctness. Provide a brief verdict.", shape=tab] start -> spec -> implement -> test -> review -> exit } ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/08-multi-model.fabro ``` ## Model stylesheets The `model_stylesheet` graph attribute contains CSS-like rules that assign models to nodes: ``` * { model: claude-haiku-4-5;reasoning_effort: low; } .coding { model: claude-sonnet-4-5;reasoning_effort: high; } #review { model: claude-sonnet-4-5;reasoning_effort: high; } ``` ### Selectors | Selector | Syntax | Matches | Specificity | | - | - | - | - | | Universal | `*` | All nodes | 0 | | Shape | `box`, `tab`, etc. | Nodes with that shape | 1 | | Class | `.classname` | Nodes with `class="classname"` | 2 | | ID | `#nodeid` | A specific node by ID | 3 | Higher specificity wins. If two rules have the same specificity, the last one in the stylesheet wins. ### How this workflow routes | Node | Matches | Model | Why | | - | - | - | - | | `spec` | `*` (universal) | Haiku | Simple generation task — fast and cheap | | `implement` | `.coding` (class) | Sonnet | Coding requires a capable model | | `test` | `.coding` (class) | Sonnet | Test writing also needs coding capability | | `review` | `#review` (ID) | Sonnet | Review needs careful analysis | ### Assigning classes Set the `class` attribute on a node to target it with class selectors: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [label="Implement", class="coding"] ``` Separate multiple classes with spaces: `class="coding critical"`. ## Properties Stylesheets support four properties: | Property | Description | | - | - | | `model` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`) | | `provider` | Provider name (optional — auto-inferred from the model catalog when omitted) | | `reasoning_effort` | `low`, `medium`, or `high` | | `backend` | `api` (default), `cli`, or `acp` | ## Why route models? Not every task needs a frontier model: * **Spec writing, classification, summarization** — use a fast, cheap model (Haiku, Flash Lite) * **Code implementation, complex reasoning** — use a capable model (Sonnet, Opus, GPT-5.4) * **Cross-critique** — use a *different provider* so the reviewer brings fresh eyes Model routing lets you optimize cost and latency without changing the workflow structure. Swap `claude-haiku-4-5` to `gemini-3-flash-preview` in the stylesheet and the workflow behaves the same — just with a different model underneath. ## Explicit overrides A model set directly on a node attribute always beats the stylesheet: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [label="Implement", class="coding", model="claude-opus-4-6"] ``` This node uses Opus regardless of what `.coding` says. See [Model Stylesheets](/workflows/stylesheets) for the full reference and [Models](/core-concepts/models) for available model IDs. ## What you've learned * **Model stylesheets** use CSS-like rules to assign models to nodes * **Selectors** match by universal (`*`), shape, class (`.name`), or ID (`#name`) * **Specificity** determines which rule wins when multiple match * Route cheap models to simple tasks and capable models to hard ones ## Next Fan out to multiple providers and synthesize their independent opinions. # Parallel Review Source: https://docs.fabro.sh/tutorials/parallel-review Shared-checkout fan-out, fan-in, and result synthesis This tutorial runs three code review perspectives in parallel — security, architecture, and quality — then merges the results into a single report. ## The workflow Parallel Review workflow: Start → Fan Out → Security Audit, Architecture Review, Code Quality → Merge → Final Report → Exit ```dot title="parallel.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Parallel { graph [goal="Perform a multi-perspective code review"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] fork [label="Fork Analysis", shape=component] security [label="Security Audit", prompt="Examine the codebase for security concerns: hardcoded secrets, injection risks, unsafe dependencies. List findings as bullet points.", shape=tab, reasoning_effort="low"] architecture [label="Architecture Review", prompt="Assess the codebase architecture: separation of concerns, dependency structure, modularity. List findings as bullet points.", shape=tab, reasoning_effort="low"] quality [label="Code Quality", prompt="Check code quality: naming conventions, dead code, test coverage gaps, error handling. List findings as bullet points.", shape=tab, reasoning_effort="low"] merge [label="Synthesize Findings", shape=tripleoctagon, prompt="Synthesize every branch result into a prioritized summary report with the top 5 action items."] report [label="Write Final Report", prompt="Write the synthesized review as a clear final report.", shape=tab] start -> fork fork -> security fork -> architecture fork -> quality security -> merge architecture -> merge quality -> merge merge -> report -> exit } ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/06-parallel.fabro ``` ## Fan-out with the fork node The `fork` node has `shape=component`, making it a **parallel fan-out node**. Every outgoing edge becomes a concurrent branch: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fork [label="Fork Analysis", shape=component] fork -> security fork -> architecture fork -> quality ``` All three branches start concurrently and operate in the same sandbox checkout and working directory. This review is safe because the branches are prompt nodes with no workspace tools. Parallel branches are not isolated from one another. If branches edit files, they can observe, race with, or overwrite each other's changes. Fabro does not lock files, detect overlapping writes, or warn about races. Keep branches read-only or give each branch responsibility for disjoint paths. The parallel node always waits for every branch to finish before continuing. ## Fan-in with the merge node The `merge` node has `shape=tripleoctagon`, making it a **merge (fan-in) node**. It receives every branch's status and context updates in `parallel.results` and synthesizes them with its prompt: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} merge [ label="Synthesize Findings", shape=tripleoctagon, prompt="Synthesize every branch result into a prioritized summary report with the top 5 action items." ] security -> merge architecture -> merge quality -> merge ``` `parallel.results` is runtime context, not a `parallel_results.json` file in the checkout. Fan-in never selects a branch's files or changes workspace state; every branch has already worked in the same checkout. The downstream `report` node writes the synthesis produced by fan-in as the final report. ## Concurrency control By default, Fabro runs up to 4 parallel branches simultaneously. Control this with `max_parallel`: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fork [shape=component, max_parallel=2] ``` This is useful when branches are resource-intensive (e.g., each running a full agent session with tool calls) and you want to limit concurrency. ## Review a runtime candidate list Static branches work when the review perspectives are known while writing the graph. A security scan often discovers its review candidates at runtime. Write that array to a flat context key, then use `for_each` to run one reviewer per candidate: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} discover [ shape=tab, output_schema="routing", prompt="Identify security review candidates. Return only JSON with \ context_updates.candidates as an array of objects. Give every object a \ name, path, and reason." ] review_batch [ shape=component, for_each="context.candidates", max_parallel=4 ] reviewer [ label="Candidate Reviewer", prompt="Inspect this candidate for exploitable security problems. Report \ evidence, severity, and a concrete remediation." ] aggregate [ shape=tripleoctagon, prompt="Synthesize all candidate reviews. Call out candidates whose branch failed." ] discover -> review_batch review_batch -> reviewer reviewer -> aggregate ``` The `routing` output schema merges `context_updates.candidates` into the flat `candidates` context key. `review_batch` accepts that inline array or its automatically offloaded managed artifact reference. It clones `reviewer` for each item, appends the item as pretty JSON inside a fresh `` fence with a matching closing tag, and keeps `parallel.results` in candidate order. Each result has `id="reviewer"`, a zero-based `index`, and an `item_label` chosen from the item's `name`, then `label`, then index. An empty candidate array succeeds and proceeds directly to `aggregate`. Mixed success and failure also proceeds; only an all-failed batch fails the parallel stage. Retries and executor-enforced timeouts apply independently to each candidate. A retry keeps the same result index and branch identity, and releases its `max_parallel` slot while waiting for backoff. If a run stops partway through the batch, resuming it reruns every item because per-item checkpoints are not created. The randomized fence prevents candidate text from closing its own data block, but it does not sandbox a tool-enabled reviewer. Candidate data can still try to influence the model. Limit the target agent's tools and permissions to the minimum needed for the review. ## What you've learned * **Fan-out nodes** (`shape=component`) spawn concurrent branches and wait for all of them * `for_each` runs one agent or prompt template for every item in a runtime array * Parallel branches share one checkout, so workflows must prevent or tolerate file races * **Fan-in nodes** (`shape=tripleoctagon`) can synthesize `parallel.results` with a prompt * Fan-in never selects or restores workspace state, and no results file is created ## Next Assign different models to different workflow nodes using stylesheets. # Plan & Implement Source: https://docs.fabro.sh/tutorials/plan-implement Human gates, revision loops, and prompt file references This tutorial builds a plan-approve-implement workflow where a human reviews the plan before the agent writes code. If the plan isn't right, the human sends it back for revision — creating a loop. ## The workflow Plan-Implement workflow: Start → Plan → Approve Plan → Implement → Simplify → Exit, with Revise loop back to Plan ```dot title="plan-implement.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph PlanImplement { graph [goal="Plan, approve, implement, and simplify a change"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] plan [label="Plan", prompt="Analyze the goal and codebase. Write a clear, step-by-step implementation plan to a Markdown file called plan.md. Include what files will change and why.", reasoning_effort="high"] approve [shape=hexagon, label="Approve Plan"] implement [label="Implement", prompt="Read plan.md and implement every step. Make all the code changes described in the plan."] simplify [label="Simplify", prompt="@prompts/simplify.md"] start -> plan -> approve approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] implement -> simplify -> exit } ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/10-plan-implement.fabro ``` ## Human gates The `approve` node has `shape=hexagon`, which makes it a **human gate** — the workflow pauses and waits for a person to choose a path. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve Plan"] approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] ``` The outgoing edge labels define the options. In the CLI, you'll see: ``` ? Approve Plan [1] A - [A] Approve [2] R - [R] Revise Select: ``` The `[A]` and `[R]` prefixes are keyboard accelerators — type the letter to select. ## The revision loop If you choose **Revise**, execution goes back to the `plan` node. The agent runs again with context about what happened — it knows its previous plan was rejected and can improve it. This cycle repeats until you approve. ``` start → plan → approve → [Revise] → plan → approve → [Approve] → implement → simplify → exit ``` Loops are natural in Fabro — just point an edge back to an earlier node. For safety, you can set `max_visits` on a node to prevent infinite loops: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} plan [label="Plan", max_visits=5, ...] ``` ## Reasoning effort The `plan` node sets `reasoning_effort="high"`. This tells the model to think harder — useful for planning tasks that require careful analysis. The default is `high`, but you can set it to `low` or `medium` for simpler tasks to save cost and time. ## Prompt file references The `simplify` node uses `@prompts/simplify.md` instead of an inline prompt string: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} simplify [label="Simplify", prompt="@prompts/simplify.md"] ``` The `@` prefix tells Fabro to load the prompt from a Markdown file, resolved relative to the Graphviz file's location. This keeps Graphviz files concise and lets you version prompts as standalone files. See [Prompts](/agents/prompts) for details. ## Context flow between nodes Each node receives a **preamble** summarizing what happened in prior stages. When the `implement` node runs, it knows that a plan was written and approved. The preamble includes: * The workflow goal * A summary of completed stages with their outcomes * Files touched by prior stages * Run context values The agent reads `plan.md` (as instructed by its prompt), but the preamble gives it additional context about the overall workflow state. See [Context](/execution/context) for the full reference. ## What you've learned * **Human gates** (`shape=hexagon`) pause for human input with edge labels as options * **Revision loops** are just edges that point back to earlier nodes * **Prompt file references** (`@path/to/file.md`) keep Graphviz files clean * **`reasoning_effort`** controls how hard the model thinks * Nodes receive preambles summarizing prior stages ## Next Add conditional branching and automated test validation loops. # Sub-Workflows Source: https://docs.fabro.sh/tutorials/sub-workflow Reusable child workflows, context flow, and the supervisor pattern This tutorial uses a **sub-workflow node** to delegate part of a parent workflow to a separate, reusable child workflow. The parent plans a feature, hands off implementation to a child workflow that runs its own implement-test loop, then reviews the result. ## Prerequisites Complete the [Branch & Loop](/tutorials/branch-loop) tutorial — the child workflow in this tutorial reuses that pattern. ## The child workflow First, create a standalone implement-and-test workflow. This is a normal workflow that can run on its own or be invoked as a sub-workflow by a parent. Child workflow: Start → Implement → Validate → Tests passing? → Pass to Exit or Fix back to Implement ```dot title="implement-and-test.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph ImplementAndTest { graph [goal="Implement the plan and make tests pass"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] implement [label="Implement", prompt="Read plan.md and implement every step. Write the code and tests to disk."] validate [label="Validate", shape=parallelogram, script="python3 -m pytest -v 2>&1 || true"] gate [shape=diamond, label="Tests passing?"] start -> implement -> validate -> gate gate -> exit [label="Pass", condition="outcome=succeeded"] gate -> implement [label="Fix", max_visits=5] } ``` This is the same implement-test-fix loop from the [Branch & Loop](/tutorials/branch-loop) tutorial, packaged as its own file. ## The parent workflow Now create a parent workflow that delegates to the child: Sub-Workflow: Start → Plan (agent) → Implement & Test (house) → Review (agent) → Exit ```dot title="sub-workflow.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph SubWorkflow { graph [goal="Create a Python module (tempconv.py) that converts between Celsius, Fahrenheit, and Kelvin, with pytest tests"] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] plan [label="Plan", prompt="Analyze the goal. List the functions needed, their signatures, and edge cases. Write the plan to plan.md."] impl [label="Implement & Test", shape=house, stack.child_workflow="implement-and-test.fabro", manager.max_cycles=50] review [label="Review", prompt="Read every file the child workflow created. Run the tests yourself with 'python3 -m pytest -v'. Verify the implementation matches plan.md and all tests pass. Write a short verdict to review.md."] start -> plan -> impl -> review -> exit } ``` ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run docs/internal/demo/12-sub-workflow.fabro ``` ## The house node The `impl` node has `shape=house`, which makes it a **sub-workflow node**. Instead of running an LLM or a script, it launches a separate engine to execute the child workflow file: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} impl [label="Implement & Test", shape=house, stack.child_workflow="implement-and-test.fabro", manager.max_cycles=50] ``` The child workflow runs through its own start → implement → validate → gate → exit sequence. When it finishes, execution returns to the parent and continues to the `review` node. ### Sub-workflow attributes | Attribute | Description | | - | - | | `stack.child_workflow` | Path to the child workflow file (resolved relative to the parent). | | `stack.child_dot_source` | Inline child Graphviz source (alternative to `child_workflow`) | | `manager.max_cycles` | Safety limit on poll cycles before the child is cancelled (default: 1000) | | `manager.poll_interval` | How often to check for completion or stop conditions (default: `45s`) | | `manager.stop_condition` | Condition expression that, when true, cancels the child early | Use `stack.child_workflow` when you want to reuse the child workflow across multiple parents. Use `stack.child_dot_source` for one-off child workflows that are specific to the parent. ## What's shared and what's isolated The child shares the parent's **run ID and event stream** — its stage events appear in the same run, so `fabro events` and `fabro inspect` see parent and child interleaved. The child gets its own **checkpoints, artifacts, and run directory**, so its working state can't pollute the parent. Context flows bidirectionally: 1. **Parent → child:** The child receives a clone of the parent's context. In this example, the `plan` node writes `plan.md` to disk and the child's `implement` node reads it. 2. **Child → parent:** When the child finishes, any context values it added or changed are merged back into the parent. The `review` node sees the results of the child's work. Only the *diff* is merged — values the child didn't touch remain unchanged in the parent. ## Stop conditions For long-running child workflows, you can set a stop condition that cancels the child early based on the parent's context: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} impl [shape=house, stack.child_workflow="implement-and-test.fabro", manager.stop_condition="context.deploy_ready=true", manager.max_cycles=100] ``` The parent polls at `manager.poll_interval` (default 45 seconds). On each poll, it evaluates the stop condition against the current context. If the condition is true, the child is cancelled and the parent continues. This is useful when an external process (another branch, a webhook, a human gate) signals that the child's work is no longer needed. ## When to use sub-workflows Sub-workflows are most valuable when: * **Reusability** — the same child workflow is used by multiple parents (e.g., a standard test-and-fix loop, a deploy pipeline, a review checklist) * **Encapsulation** — the child has its own checkpoints and artifacts, so its working state can't pollute the parent's * **Supervisor patterns** — the parent needs to monitor or cancel a complex child process based on external conditions For simpler cases, just add more nodes to a single workflow. Sub-workflows add a layer of indirection — use them when the benefits of reuse or encapsulation justify it. Use [child runs](/execution/child-runs) instead when the delegated work should be a separate durable run that can be listed, approved, cancelled, retried, inspected, and supervised independently. ## What you've learned * **Sub-workflow nodes** (`shape=house`) run a child workflow inside a parent * **`stack.child_workflow`** references an external workflow file for reuse * **Context flows** from parent to child and back via diff merging * **`manager.max_cycles`** prevents runaway child workflows * **`manager.stop_condition`** cancels the child when an external signal arrives ## Further reading Complete reference for all node types. Full syntax reference including sub-workflow attributes. How context flows between nodes and across workflow boundaries. The implement-test-fix pattern used in the child workflow. # Decisions Source: https://docs.fabro.sh/workflows/human-in-the-loop Adding human review and intervention to workflows Not every decision should be made by an LLM. Fabro provides several mechanisms for humans to participate in workflows — approving plans, choosing between options, providing free-form input, and steering agents while they run. ## Human gates A human gate is a node with shape `hexagon` that pauses the workflow and presents the user with a choice. The options are derived from the outgoing edge labels: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve Plan"] approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] approve -> skip [label="[S] Skip"] ``` When execution reaches the gate, the user sees the previous stage's output followed by the node's label ("Approve Plan") and the available options. In the CLI, this appears as an interactive menu. In the web UI, blocked runs show an interview dock on the run page where the user can answer the pending question. `fabro approve` and `fabro deny` grant or deny permission to **start a run** waiting for execution approval. To answer a human gate during execution, run `fabro attach ` for an interactive prompt or use the run page's interview dock. API clients can list pending questions with `GET /api/v1/runs/{id}/questions` and answer a specific question with `POST /api/v1/runs/{id}/questions/{qid}/answer`. ### Keyboard accelerators The prefixes `[A]`, `[R]`, `[S]` in edge labels serve as keyboard accelerators. Fabro supports three formats: | Format | Example | | - | - | | `[K] Label` | `[A] Approve` | | `K) Label` | `A) Approve` | | `K - Label` | `A - Approve` | When matching the user's selection to an edge, Fabro strips the accelerator prefix so typing `A` matches `[A] Approve`. ### Freeform input In addition to fixed choices, a human gate can accept freeform text input. Add an edge with `freeform=true`: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review [shape=hexagon, label="Review Changes"] review -> implement [label="[A] Approve"] review -> plan [label="[R] Revise"] review -> custom [freeform=true] ``` If the user types something that doesn't match any fixed option, their input routes to the freeform edge. The text is available to downstream nodes as `human.gate.text` in the run context. When a gate has only a freeform edge and no fixed choices, the user goes directly to a text input prompt — skipping the multiple-choice menu entirely. This is useful for conversational loops like REPL workflows where the user provides open-ended instructions each iteration. ### Question types Human gates infer their interview type from their edges: fixed choices become `multiple_choice`, and a gate with only a freeform edge becomes `freeform`. To force another interviewer presentation, set `question_type` on the node: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} confirm [shape=hexagon, label="Continue?", question_type="confirmation"] confirm -> next [label="[Y] Yes"] confirm -> exit [label="[N] No"] ``` Supported values are `yes_no`, `confirmation`, `multiple_choice`, `multi_select`, and `freeform`. ### Review targets A human gate can present one external document as the primary review link. Set `review_target=true` on the gate: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review [ shape=hexagon, review_target=true ] review -> sync [label="[S] Review complete; sync the current Markdown"] review -> address [label="[A] Ask the agent to address human feedback"] review -> review [label="[C] Continue reviewing"] ``` Before the workflow reaches the gate, an agent, prompt, or command node must set the flat `review_target` context key. A routing response can do this directly: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "outcome": "succeeded", "context_updates": { "review_target": { "label": "Quarry review exercise", "url": "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef", "kind": "document" } } } ``` Fabro then presents this question: > Review the [Quarry review exercise](https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef) document, then choose the next action. The link uses the target URL from context. The example uses a placeholder secret, not a live Quarry document. Fabro generates this question text from the target. A `label` on the gate is not used while `review_target=true`. The target object has three required fields: | Field | Meaning | | - | - | | `label` | The link text. It must contain 1 to 200 characters and no control characters. | | `url` | An absolute HTTP or HTTPS URL. It must contain a host, must not contain URL credentials, and must be at most 2048 characters. | | `kind` | The resource type. The supported value is `document`. | Fabro validates the target before it starts the interview. A missing or invalid target fails the gate deterministically. A gate without `review_target=true` ignores this context key and keeps its normal label. The context value remains available after the human answers. A feedback loop can return to the same gate without recreating the target. A later stage can replace the target by writing a new value to the same context key. Fabro does not fetch the URL. Web and Slack clients open it as an external link. Treat bearer-capability links as secrets and only provide them to people who can access the run. ### Default choice on timeout If a human gate has a timeout configured, you can specify a default choice using the `human.default_choice` attribute: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve?", human.default_choice="approve"] ``` If the timeout elapses without a response, the workflow continues to the default target rather than failing. ### Unanswered gates fail closed Fabro does not treat missing input as approval. A human gate advances only when one of these happens: * A person explicitly answers the question * The gate times out and `human.default_choice` is set * The run uses `--auto-approve` If stdin is closed, a prompt is canceled, a web session disconnects, or no answer is otherwise captured, the gate is treated as unanswered. Fabro will not fall through to an unconditional approval edge. If you want to handle that case explicitly, add a failure route such as `condition="outcome=failed"` or a `retry_target`. ### Auto-approve For testing or fully automated runs, pass `--auto-approve` to skip all human gates: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run workflow.fabro --auto-approve ``` Auto-approve selects `Yes` for yes/no gates and the first option for multiple-choice gates. ## Where to place human gates Human gates are most valuable at high-leverage decision points: * **Before implementation** — Approve a plan before the agent writes code * **After review** — Confirm that a code review's findings are worth fixing * **At branch points** — Choose a strategy when multiple approaches are viable * **Before external actions** — Approve before deploying, merging, or sending notifications A workflow can have multiple human gates. Place them where the cost of a wrong decision is high relative to the cost of a brief pause. ## Context set by human gates When a user makes a selection, the human gate sets several context values for downstream use: | Context key | Value | | - | - | | `human.gate.selected` | The accelerator key (e.g. `"A"`) or `"freeform"` | | `human.gate.label` | The full label of the selected edge | | `human.gate.text` | The user's freeform text (if applicable) | | `human.gate..question` | The question text for a specific human gate node | | `human.gate..answer` | The answer text for a specific human gate node | | `human.gate..label` | The selected label for a specific human gate node, when applicable | These can be read in edge conditions or referenced by downstream agents. # Imports Source: https://docs.fabro.sh/workflows/imports Reusing one workflow as a subgraph inside another A workflow can pull in another `.fabro` file as a reusable subgraph. Use imports to factor a common pattern — a review loop, a validation pipeline, a deploy sequence — into its own file and splice it into many parent workflows. Imports are resolved at parse time: the imported graph is merged into the parent's graph, with node IDs prefixed to avoid collisions. There is no runtime "sub-workflow" boundary; once merged, an imported node behaves like any other node in the run. If you need runtime delegation to separate durable runs, use [child runs](/execution/child-runs) instead. ## Basic example Place a node in the parent graph with an `import` attribute pointing at the file to splice in: ```dot title="deploy.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Deploy { start [shape=Mdiamond] exit [shape=Msquare] validate [import="./validate.fabro"] deploy [label="Deploy", prompt="Deploy the change."] start -> validate -> deploy -> exit } ``` The imported file is a normal workflow graph with its own start and exit: ```dot title="validate.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Validate { start [shape=Mdiamond] exit [shape=Msquare] lint [label="Lint", prompt="Run clippy and report issues."] test [label="Test", prompt="Run the test suite."] start -> lint -> test -> exit } ``` After expansion, the effective graph contains `start → validate.lint → validate.test → deploy → exit`. The `validate` placeholder is removed and its incoming/outgoing edges are rewired to the imported subgraph's entry and exit. ## Node ID prefixing Imported nodes are renamed with the placeholder ID as a prefix, joined by a dot: | Imported node | Becomes | | - | - | | `lint` | `validate.lint` | | `test` | `validate.test` | Edges inside the imported graph are rewritten to match. This means the same file can be imported multiple times in one workflow under different placeholder names without ID collisions. The placeholder's `start` and `exit` sentinels are discarded — only the body is spliced in. ## Contract for imported files An imported file must define a clean entry and exit so Fabro knows where to rewire edges: * Exactly one start node (`shape=Mdiamond`, or ID `start`/`Start`) with exactly one outgoing edge. * Exactly one exit node (`shape=Msquare`, or ID `exit`/`Exit`/`end`/`End`) with exactly one incoming edge. * The boundary edges (start → entry, predecessor → exit) must carry no semantic attributes (no `condition`, `label`, `weight`, `fidelity`, `thread_id`, `loop_restart`, or `freeform`). If the contract is violated, the placeholder is **poisoned**: its `import` attribute is replaced with `import_error`, and `fabro validate` reports the failure as a graph error. ## Placeholder attributes A small set of attributes on the placeholder node propagate as **defaults** to every imported node. The imported node's own value wins when both are set. | Attribute | | - | | `model` | | `provider` | | `reasoning_effort` | | `speed` | | `backend` | | `acp.command` | | `acp.config` | | `fidelity` | | `max_retries` | | `thread_id` | ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} validate [import="./validate.fabro", model="haiku", reasoning_effort="low"] ``` Every node from `validate.fabro` inherits `model="haiku"` and `reasoning_effort="low"` unless it sets its own value. Any attribute outside this list (other than `class`, see below) causes a poisoned placeholder. ## Class propagation Classes on the placeholder are unioned into every imported node's class list, in addition to a class derived from the placeholder ID itself (lowercased, non-alphanumeric stripped). This lets [stylesheets](/workflows/stylesheets) target imported subgraphs as a group. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} validate [import="./validate.fabro", class="fast shared"] ``` Each imported node ends up with the classes it declared, plus `fast`, `shared`, and `validate`. ## Retry targets `retry_target` and `fallback_retry_target` inside an imported file are rewritten to match the prefixed IDs: ```dot title="validate.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} lint [prompt="...", retry_target="test"] test [prompt="..."] ``` After import under the placeholder `validate`, `validate.lint`'s retry target becomes `validate.test` automatically. ## Templating Imported files are parsed as static DOT before template expansion. The import path and graph structure must be literal, but templates remain supported inside node `prompt` attributes: ```dot title="shared/lint.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Lint { start [shape=Mdiamond] exit [shape=Msquare] run [label="Lint", prompt="Run {{ inputs.linter }} and fix any reported issues."] start -> run -> exit } ``` Imported prompts receive the same `{{ goal }}`, `{{ inputs.* }}`, and `{{ vars.* }}` context as prompts in the root graph. The server-managed variable values are the snapshot captured when the run is created. Do not put templates in `import` paths, node IDs, edge definitions, other structural references, or attributes besides `prompt` — they are literal text. See [Variables](/workflows/variables#expansion-timing) for the rendering pipeline. ## Nested imports An imported file can itself contain `import=` nodes. Relative paths resolve from the *importing* file's directory, so a shared library can reference its own siblings without knowing where it's loaded from: ```dot title="shared/review.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review_loop [import="./review-loop.fabro"] ``` Cycles are detected and reported as a poisoned placeholder rather than recursing. ## Empty imports as bypass If the imported graph contains only `start` and `exit` (no body), the placeholder is removed and each incoming edge is cross-wired to each outgoing edge. This is useful for stubbing out an optional step: ```dot title="noop.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Noop { start [shape=Mdiamond] exit [shape=Msquare] start -> exit } ``` Empty-import bypass is rejected if any of the placeholder's incoming or outgoing edges carry semantic attributes — those edges have nowhere to land. ## Error handling When an import fails to resolve or violates the contract, Fabro does not abort parsing. Instead it leaves the placeholder node in place with an `import_error` attribute describing the failure. The `import_error` lint rule then surfaces this as a validation error from `fabro validate` and from run startup. Common failure messages: * `file not found: ` — the path does not resolve under the parent file's directory or the project fallback. * `circular import detected: ` — an import cycle was detected during expansion. * `imported workflow must have exactly one start node, found ` — the contract above is violated. * `import placeholder '' has unsupported attribute ''` — the placeholder uses an attribute that does not propagate. * `empty import '' cannot bypass semantic edges` — a body-less import sits between edges that carry conditions or labels. # Nodes & Stages Source: https://docs.fabro.sh/workflows/stages-and-nodes All node types and how they become stages at runtime ## Nodes vs. stages A **node** is a step defined in the Graphviz file at author time. A **stage** is the runtime execution of a node. In a simple linear workflow, each node runs once and produces one stage. But when a workflow loops — for example, an implement-test-fix cycle — the same node can produce multiple stages within a single run. This distinction matters for observability and debugging: the workflow graph shows nodes, but the run timeline shows stages. Each stage records its own inputs, outputs, duration, and token usage. ## Node types An explicit `type` attribute selects a node's execution behavior. Otherwise, its Graphviz `shape` selects the behavior. When both are omitted, a node with `script` is a command node and every other node defaults to an agent. ### Start **Shape:** `Mdiamond` The entry point of the workflow. Every workflow must have exactly one start node. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} start [shape=Mdiamond, label="Start"] ``` ### Exit **Shape:** `Msquare` The terminal node. When execution reaches exit, the workflow completes. Every workflow must have exactly one exit node. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} exit [shape=Msquare, label="Exit"] ``` ### Agent **Shape:** `box` (default when `shape`, `type`, and `script` are omitted) Runs an LLM with access to tools — bash, file editing, sub-agents — in an agentic loop. The agent works autonomously, calling tools as needed, until it decides the task is complete. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [label="Implement", prompt="Read plan.md and implement every step."] ``` Key attributes: | Attribute | Description | | - | - | | `prompt` | The task instructions for the agent | | `reasoning_effort` | `low`, `medium`, or `high` (default: `high`) | | `max_tokens` | Maximum tokens for LLM responses | | `fidelity` | How much prior context is passed to this node (see [Context](/execution/context#fidelity-controlling-agent-context)) | | `thread_id` | Groups nodes into a shared conversation thread (advanced — see below) | | `timeout` | Execution timeout (e.g. `"900s"`). Time spent waiting for an answer to an agent question does not count. | **Fidelity levels:** | Value | Behavior | | - | - | | `compact` | Structured summary of prior stages (default) | | `full` | Complete context from all prior stages — no summarization | | `summary:high` | Detailed summary including outputs and key details | | `summary:medium` | Moderate summary with outcomes and notable findings | | `summary:low` | Brief summary with just outcomes per stage | | `truncate` | Minimal — only the goal and run ID | Fidelity can also be set at the graph level (`default_fidelity`) or on individual edges to control the transition between stages. See [Context](/execution/context#fidelity-controlling-agent-context) for the full reference on fidelity precedence, preamble construction, and thread integration. **Thread ID:** Setting `thread_id` on multiple nodes (e.g. `thread_id="impl"`) groups them into a shared conversation thread, preserving context continuity across nodes as if they were part of the same session. This is an advanced feature typically used within subgraph clusters: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} subgraph cluster_impl { node [fidelity="full", thread_id="impl"] plan [label="Plan"] implement [label="Implement"] review [label="Review"] } ``` ### Prompt **Shape:** `tab` Makes a single LLM call with no tool use. Useful for analysis, summarization, generation, and lightweight reasoning where tools aren't needed. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} spec [label="Write Spec", shape=tab, prompt="Write a brief spec for a string utility module."] ``` Prompt nodes accept the same attributes as agent nodes (`prompt`, `reasoning_effort`, `max_tokens`, etc.) but never invoke tools. ### Command **Shape:** `parallelogram` (optional when `shape` and `type` are omitted — inferred from `script`) Runs a shell script inside the configured sandbox and captures its output. The output is available to downstream nodes as context. This ensures command nodes execute in the same environment as agent nodes. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} test [label="Run Tests", script="cargo test 2>&1 || true"] merge_results [ script="python3 scripts/merge.py", stdin_source="context.parallel.results" ] ``` When a node has no explicit `shape` or `type`, the presence of `script` makes it a command node. Writing `shape=parallelogram` explicitly is still valid and does the same thing. | Attribute | Description | | - | - | | `script` | The shell command to execute (required). Substitutes `{{ goal }}`, `{{ inputs.NAME }}`, and `{{ vars.NAME }}` — see [command node scripts](/workflows/variables#command-node-scripts) | | `language` | `"shell"` (default) or `"python"` | | `stdin_source` | Flat runtime context key to pass to the command's standard input. `context.NAME` first checks that exact key, then falls back to `NAME`. | For `stdin_source`, strings are passed unchanged. Other JSON values use compact JSON. Fabro does not add a newline. A missing source, or a value larger than 30 MiB, fails before the command starts. ### Human **Shape:** `hexagon` Pauses the workflow and waits for a person to choose a path. The outgoing edge labels define the available options: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve Plan"] approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] ``` In the web UI, human gates appear as interactive prompts. From the CLI, they appear as a menu. ### Wait **Shape:** `insulator` Pauses the workflow for a configured duration before proceeding. Useful for rate limiting between API calls or waiting for external processes to complete. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} cooldown [label="Wait 30s", shape=insulator, duration="30s"] ``` | Attribute | Description | | - | - | | `duration` | How long to pause (required). Supports `ms`, `s`, and `m` suffixes (e.g. `"500ms"`, `"30s"`, `"2m"`) | ### Conditional **Shape:** `diamond` Routes execution to different edges based on conditions evaluated against the current run context: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate [shape=diamond, label="Tests passing?"] gate -> exit [label="Pass", condition="outcome=succeeded"] gate -> implement [label="Fix"] ``` Conditions support `=`, `!=`, `&&`, and context variable lookups (e.g. `context.tests_passed=true`). An edge with no condition acts as the default fallback. ### Parallel (fan-out) **Shape:** `component` Fans out to execute multiple branches concurrently. Every branch runs in the same sandbox checkout and working directory, and the parallel node waits for every branch to finish. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fork [label="Fan Out", shape=component] fork -> security fork -> architecture fork -> quality ``` | Attribute | Description | | - | - | | `max_parallel` | Maximum concurrent branches (default: 4) | | `for_each` | Flat context key containing a runtime JSON array. Requires one outgoing agent or prompt template target. | To run one template node for a runtime array, add `for_each`: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review_batch [shape=component, for_each="context.candidates", max_parallel=8] reviewer [prompt="Review this candidate for security issues."] aggregate [shape=tripleoctagon, prompt="Synthesize every candidate review."] review_batch -> reviewer -> aggregate ``` Fabro accepts an inline array or a managed JSON artifact reference. It runs `reviewer` once per item, appends the item to the prompt as fenced data, and keeps the results in input order. The source lookup is flat: `context.candidates` checks that exact key and then `candidates`; it does not traverse nested objects. Each item can contribute up to 64 KiB of serialized JSON to its branch prompt. Fabro stores larger items as content-addressed blobs and replaces the inline item with its size, a readable file path, and a 300-character preview. The preview remains inside the untrusted-data fence, and the branch agent can read the file for the full item. The template target must be an agent or prompt node, and nested `for_each` is rejected. An empty source array succeeds with `parallel.results=[]` and skips straight to `aggregate`. Missing, invalid, non-array, or over-long sources fail the parallel stage before any branches start; the limit is 1000 items. Because the checkout is shared, file changes from one branch are immediately visible to the others. Concurrent writes can race or overwrite each other. Fabro does not isolate branch files, lock paths, detect conflicts, or warn about overlapping writes. Design branches to be read-only or assign each branch disjoint files and directories when deterministic workspace changes matter. For each branch's first node, fidelity resolves from the fork-to-branch edge, then the branch node; otherwise it inherits the fork preamble unchanged. Fabro renders branch-specific preambles before fan-out from the fork snapshot. Branch-level `full` degrades to `summary:high` because concurrent branches cannot share sessions, and `thread_id` on a branch node or fork-to-branch edge is inert. ### Merge (fan-in) **Shape:** `tripleoctagon` Converges parallel branches after all of them finish. Branch status and context updates are collected in the runtime context at `parallel.results`; Fabro does not create a `parallel_results.json` file in the checkout. ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} merge [ label="Synthesize Results", shape=tripleoctagon, prompt="Synthesize every branch result into one report." ] security -> merge architecture -> merge quality -> merge merge -> report ``` A fan-in node with a `prompt` synthesizes the collected results. It never chooses, restores, or merges a branch's workspace state: all branches have already operated on the same checkout. Without a prompt, fan-in is only a convergence barrier. ## Common node attributes These attributes can be set on any node type: | Attribute | Description | | - | - | | `label` | Display name shown in the graph visualization | | `class` | CSS-like class for [model stylesheet](/workflows/stylesheets) targeting. Separate multiple classes with spaces. | | `max_visits` | Max times this node can execute in a run. Overrides the graph-level `max_node_visits` for this node. | | `goal_gate` | When `true`, the workflow fails if this node doesn't succeed | | `max_retries` | Override default retry count for this node | | `retry_policy` | Named retry preset (see table below) | **Retry policies:** | Preset | Attempts | Backoff | Description | | - | - | - | - | | `none` | 1 | — | No retries, fail immediately | | `standard` | 5 | 5s initial, 2x exponential | Good default for transient failures | | `aggressive` | 5 | 500ms initial, 2x exponential | Longer initial delay for rate limits | | `linear` | 3 | 500ms fixed | Constant delay between attempts | | `patient` | 3 | 2s initial, 3x exponential | Slow ramp for unreliable services | If neither `retry_policy` nor `max_retries` is set, nodes default to 3 retries with standard backoff. # Model Stylesheets Source: https://docs.fabro.sh/workflows/stylesheets Assign LLM models to workflow nodes using CSS-like rules Model stylesheets let you assign LLM models, providers, and settings to workflow nodes using a CSS-like syntax. Instead of hardcoding a model on every node, you write a set of rules that target nodes by ID, class, shape, or a universal wildcard — and Fabro applies them by specificity. ## Defining a stylesheet Stylesheets are set in the `model_stylesheet` graph attribute: ```dot title="example.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [ goal="Build and review a utility function", model_stylesheet=" * { model: claude-haiku-4-5;} .coding { model: claude-sonnet-4-5; reasoning_effort: high; } #review { model: gemini-3.1-pro-preview;} " ] start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] spec [label="Write Spec"] implement [label="Implement", class="coding"] test [label="Write Tests", class="coding"] review [label="Code Review"] start -> spec -> implement -> test -> review -> exit } ``` In this example: * **spec** gets Haiku (matches `*`) * **implement** and **test** get Sonnet with high reasoning (match `.coding`) * **review** gets Gemini Pro (matches `#review`) ## Template stylesheets The root graph's `model_stylesheet` is a [MiniJinja template](/workflows/variables). It can read typed run inputs and server-managed variables through `inputs` and `vars`: ```dot title="variable-effort.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Review { graph [ model_stylesheet=" * { reasoning_effort: low; } {% if inputs.effort == 'deep' %} .variable-effort { reasoning_effort: high; } {% elif inputs.effort == 'balanced' %} .variable-effort { reasoning_effort: medium; } {% endif %} " ] triage [prompt="Triage the change"] review [prompt="Review the change", class="variable-effort"] } ``` Stylesheet templates support expressions, conditionals, loops, filters, macros, `{% set %}`, and normal local values such as `loop`. They do not expose `goal`, `env`, or `secrets`. Fabro renders a stylesheet once. If an input or variable contains `{{ ... }}` or `{% ... %}`, that text stays literal. Fabro does not render it again. Template output is not escaped as stylesheet syntax. Map user-facing choices to fixed declarations instead of inserting unrestricted text directly: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} model_stylesheet=" {% set efforts = {'quick': 'low', 'thorough': 'high'} %} .review { reasoning_effort: {{ efforts[inputs.review_mode] }}; } " ``` Use single quotes inside MiniJinja expressions when possible. A double quote must follow normal DOT string escaping because the surrounding graph attribute uses double quotes. MiniJinja braces need no extra escaping inside the quoted DOT attribute. Static template includes are supported and resolve relative to the workflow template root: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} graph [model_stylesheet="{% include 'styles/models.partial' %}"] ``` Include paths must be literal. Dynamic or root-escaping include paths fail validation. `model_stylesheet` does not support the `@file` shorthand. Fabro uses this order: 1. Parse the DOT source. 2. Expand workflow imports and supported file references. 3. Render the root `model_stylesheet` with `{ inputs, vars }`. 4. Parse and apply the rendered stylesheet. 5. Resolve model and provider selectors. 6. Validate the transformed graph. A `model_stylesheet` on an imported graph is ignored and produces an `imported_model_stylesheet_ignored` warning. Put the stylesheet on the root graph. A root stylesheet can target imported nodes by their generated IDs, classes, or shapes. If an input or variable is unavailable, `fabro validate` reports `attractor.unbound_input` as a warning and leaves the stylesheet unrendered, so its syntax and model checks wait for the values. Run-style commands refuse the workflow with `unsupported.template.unbound_input` before they create or start a run. ## Selectors Each rule starts with a selector that determines which nodes it applies to: | Selector | Syntax | Matches | Specificity | | - | - | - | - | | Universal | `*` | All nodes | 0 | | Shape | `box`, `tab`, `hexagon`, etc. | Nodes with that Graphviz shape | 1 | | Class | `.classname` | Nodes with `class="classname"` | 2 | | ID | `#nodeid` | The node with that specific ID | 3 | ### Assigning classes Set the `class` attribute on a node to target it with class selectors. Separate multiple classes with spaces: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [label="Implement", class="coding critical"] ``` This node matches both `.coding` and `.critical` rules. ## Properties Stylesheets support five properties: | Property | Description | Example | | - | - | - | | `model` | Model ID or alias | `claude-sonnet-4-5`, `opus`, `gemini-pro` | | `provider` | Provider name (optional — auto-inferred from the model catalog when omitted) | `anthropic`, `openai`, `gemini` | | `reasoning_effort` | Reasoning effort level | `low`, `medium`, `high` | | `speed` | Output speed mode. `fast` enables Anthropic's fast mode for up to 2.5x faster output at higher cost. | `fast` | | `backend` | Agent execution backend — `api` (default) runs Fabro's own tool loop, `cli` delegates to a legacy external CLI tool, and `acp` runs an Agent Client Protocol stdio agent in the active sandbox. See [Backends](/core-concepts/agents#backends). | `api`, `cli`, `acp` | See [Models](/core-concepts/models) for the full list of model IDs and aliases. ## Specificity and cascading When multiple rules match the same node, the rule with the **highest specificity** wins. This follows the same principle as CSS: ``` * (0) < shape (1) < .class (2) < #id (3) ``` For example: ``` * { model: claude-haiku-4-5; } .coding { model: claude-sonnet-4-5; } #review { model: gpt-5.4; } ``` A node with `id="review"` and `class="coding"` gets `gpt-5.4` because `#id` (specificity 3) beats `.class` (specificity 2). If two rules have the same specificity, the **last one** in the stylesheet wins. ## Explicit attributes override stylesheets A model set directly on a node attribute always takes precedence over stylesheets, regardless of specificity: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} implement [label="Implement", class="coding", model="claude-opus-4-6"] ``` Even if `.coding` sets `model: claude-sonnet-4-5`, this node uses Opus because the explicit attribute wins. ## Syntax reference The stylesheet syntax is a simplified subset of CSS: ``` selector { property: value; property: value; } ``` * Selectors: `*`, `shape`, `.class`, `#id` * Properties and values are separated by `:` * Declarations are separated by `;` * Whitespace is flexible — newlines and indentation are ignored * CSS block comments (`/* ... */`) can appear between tokens and are ignored * Comments close at the first `*/` and do not nest; an unterminated comment is a syntax error * `//` starts a comment in the surrounding workflow file, but not inside a stylesheet ### Full example ``` /* Use a fast model by default. */ * { model: claude-haiku-4-5;reasoning_effort: low; } box { reasoning_effort: high; } tab { reasoning_effort: low; } .coding { model: claude-sonnet-4-5;reasoning_effort: high; } .review { model: gemini-3.1-pro-preview;} #final_check { model: claude-opus-4-6;reasoning_effort: high; } ``` This stylesheet: * Defaults everything to Haiku with low reasoning * Overrides all agent nodes (`box` shape) to high reasoning * Keeps prompt nodes (`tab` shape) at low reasoning * Routes `.coding` nodes to Sonnet * Routes `.review` nodes to Gemini for independent critique * Routes the `final_check` node to Opus for maximum quality # Transitions Source: https://docs.fabro.sh/workflows/transitions How Fabro decides which node to execute next After each node finishes, Fabro must decide which edge to follow to the next node. This decision is deterministic by default — given the same outcome and context, Fabro always picks the same edge. Nodes can opt into [random selection](#random-selection) for weighted-random tiebreaking instead. Understanding the transition logic helps you design workflows that route reliably. ## How transitions work When a node completes, it produces an **outcome** with a [stage outcome](/execution/outcomes) (`succeeded`, `failed`, `partially_succeeded`, or `skipped`) and optional signals like a preferred label or suggested next node. A node's retry policy runs before routing starts. Fabro then selects the next step in this order: 1. **Direct jump** — An outcome's `jump_to_node` value bypasses edge selection. 2. **Condition match** — Edges with a `condition` attribute are evaluated first. If one or more conditions match, the edge with the highest `weight` wins (lexical tiebreak on target node ID). 3. **Preferred label** — If the node's outcome includes a preferred label (for example, from a human gate selection), the edge whose `label` matches is chosen. 4. **Suggested next** — If the node suggests a specific next node ID, the edge pointing to that node is chosen. 5. **Failure policy** — For a failed outcome with no explicit route, the effective `on_failure` policy (node-level `on_failure` first, then graph-level) decides what happens next. `exit` skips the unconditional fallback. `succeed` promotes the outcome to `succeeded` and routes it as a success. `route` continues to the unconditional fallback. 6. **Unconditional fallback** — Edges without conditions are considered last, again using `weight` then lexical tiebreak. 7. **Retry target** — For a failed outcome with no selected edge, Fabro checks node-level and graph-level `retry_target` and `fallback_retry_target` values. If no edge or retry target supplies a next node, the workflow ends. A failed node produces a failed run outcome. ## Failed-node routing policy The `on_failure` attribute controls what happens to a failed node when no explicit recovery route matches: | Policy | Effective outcome | Fallback routing | | - | - | - | | `route` (default) | stays `failed` | takes the unconditional edge | | `exit` | stays `failed` | skips the unconditional edge; the run ends unless a retry target applies | | `succeed` | becomes `succeeded` | uses normal success routing | Set it at the graph level to apply the policy to every node, or on a node to control that node alone. A node-level `on_failure` overrides the graph level. A node without the attribute inherits the graph policy. This lets a linear workflow stop at the first failed work node: ```dot title="stop-on-failure.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Build { graph [on_failure="exit"] start [shape=Mdiamond] exit [shape=Msquare] plan [prompt="Plan the work"] implement [prompt="Implement the plan"] verify [prompt="Verify the implementation"] start -> plan -> implement -> verify -> exit } ``` Node-level overrides work in both directions. A strict graph can mark one best-effort node as `route` so its failure continues down the unconditional edge, and a default graph can mark one critical node as `exit`: ```dot title="mixed-policies.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Build { graph [on_failure="exit"] start [shape=Mdiamond] exit [shape=Msquare] implement [prompt="Implement the change"] lint [prompt="Run optional lint cleanup" on_failure="route"] verify [prompt="Verify the implementation"] start -> implement -> lint -> verify -> exit } ``` Use `succeed` for a best-effort node whose failure must not block the workflow. Its failure becomes a `succeeded` outcome, so the node's normal success routing applies: ```dot title="best-effort-node.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Review { graph [on_failure="exit"] start [shape=Mdiamond] exit [shape=Msquare] required_check [script="./required-check"] optional_scan [script="./optional-scan" on_failure="succeed"] start -> required_check -> optional_scan -> exit } ``` Under `succeed`, Fabro first checks explicit routes against the original `failed` outcome. If a `condition="outcome=failed"` edge, a matching preferred label, a matching suggested next node, or a handler jump applies, the outcome stays `failed` and that route is taken. Otherwise Fabro rewrites the outcome to `succeeded` before it records the node, so goal gates, the run context, events, and routing all see the promoted outcome. Edge selection then runs again: `condition="outcome=succeeded"` edges and unconditional edges apply. The original failure details stay on the `stage.completed` event and in the checkpoint, and the outcome's notes record which scope promoted it. A promoted outcome is not `failed`, so retry targets do not apply to it. Both `exit` and `succeed` apply only to the `failed` outcome. They do not change routing for `succeeded`, `partially_succeeded`, or `skipped` outcomes. Conditioned edges, matching preferred labels, and matching suggested node IDs are explicit recovery routes. They take priority under every policy. An unmatched preferred label or suggested node ID does not make an unconditional edge explicit. Retry targets also remain available under `exit`. Fabro checks them after it skips the unconditional fallback. Use graph-level `max_node_visits` or node-level `max_visits` to bound workflows whose retry targets return to a failing path. A failed human gate never falls through to an unconditional edge as a failure, regardless of policy. Node-level `on_failure="route"` does not change that; route an interrupted gate explicitly with `condition="outcome=failed"`. Under `succeed`, an interrupted gate with no explicit route is promoted like any other node and then follows its success routing. When `exit` stops routing, Fabro checkpoints the failed node without a next node and ends the run as failed. It does not execute the graph's exit node or emit an edge selection for an edge it did not take. An explicit recovery route can still reach the exit node normally. For a parallel node, `exit` and `succeed` see the final outcome returned by the parallel handler. `exit` can stop routing for a failed parallel outcome; `succeed` promotes it. Neither adds branch-level fail-fast behavior, and a `partially_succeeded` parallel outcome continues normally. Inside the fan-out, a branch node whose effective policy is `succeed` counts as `succeeded` in the parent's aggregate when it fails. Branches have no edge routing, so there is no explicit route to prefer. `auto_status=true` is the deprecated spelling of node-level `on_failure="succeed"`. Fabro still accepts it as an alias and validation warns with the replacement. See [Node Outcomes](/execution/outcomes#succeed-on-failure). ## Edge attributes | Attribute | Description | | - | - | | `label` | Display text on the edge; also used for human gate option matching | | `condition` | Boolean expression that must evaluate to true for this edge (see below) | | `weight` | Numeric priority for tiebreaking (higher wins, default: 0) | ## Conditions Edge conditions are boolean expressions evaluated against the stage outcome and run context. Conditions go in the `condition` attribute on an edge: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate -> exit [label="Pass", condition="outcome=succeeded"] gate -> implement [label="Fix", condition="outcome=failed"] ``` ### Available keys | Key | Resolves to | | - | - | | `outcome` | The stage outcome: `succeeded`, `failed`, `partially_succeeded`, or `skipped`. See [Node Outcomes](/execution/outcomes). | | `preferred_label` | The label selected by a human gate | | `context.KEY` | A value from the run context (e.g. `context.tests_passed`) | | `KEY` | Shorthand for context lookup (without the `context.` prefix) | ### Operators | Operator | Example | Description | | - | - | - | | `=` | `outcome=succeeded` | Equality | | `!=` | `outcome!=failed` | Inequality | | `>` | `context.score > 80` | Greater than (numeric) | | `<` | `context.count < 5` | Less than (numeric) | | `>=` | `context.score >= 80` | Greater than or equal (numeric) | | `<=` | `context.count <= 10` | Less than or equal (numeric) | | `contains` | `context.message contains error` | Substring match, or array membership | | `matches` | `context.version matches ^v\d+` | Regular expression match | A bare key with no operator is a **truthiness check** — it passes if the value is non-empty, not `"false"`, and not `"0"`: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate -> next [condition="my_flag"] ``` ### Combining conditions Use `&&` (AND), `||` (OR), and `!` (NOT) to build compound expressions. `&&` binds tighter than `||`: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} // Both must be true gate -> deploy [condition="outcome=succeeded && context.tests_passed=true"] // Either can be true gate -> proceed [condition="outcome=succeeded || outcome=partially_succeeded"] // Negation gate -> retry [condition="!outcome=succeeded"] // Mixed precedence: (a AND b) OR c gate -> next [condition="outcome=succeeded && context.ready=true || context.override"] ``` ## Agent transitions Agent and prompt nodes can influence which edge is taken by including a JSON object in their response with routing directives. Fabro scans the LLM output for the last JSON object containing any of these fields: ```json theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} { "preferred_next_label": "fix", "suggested_next_ids": ["implement", "review"], "context_updates": { "tests_passed": true } } ``` | Field | Effect | | - | - | | `preferred_next_label` | Matched against edge labels (same as human gate selection) | | `suggested_next_ids` | Ordered list of preferred target node IDs | | `context_updates` | Key-value pairs merged into the run context for downstream conditions | Fabro automatically scans LLM output for these JSON objects — no special configuration is needed. However, you do need to instruct the LLM to emit the JSON in your prompt. For example: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} review [ label="Review", shape=tab, prompt="Review the implementation for correctness and \ code quality. If changes are needed, respond with: \ {\"preferred_next_label\": \"fix\"}. If everything \ looks good, respond with: \ {\"preferred_next_label\": \"approve\"}." ] review -> fix [label="Fix"] review -> approve [label="Approve"] ``` The LLM's natural language response can contain other text — Fabro finds the last JSON object with a recognized routing field and extracts the directives from it. ## Human gate transitions Human gates use edge labels to present options to the user. The selected label becomes the `preferred_label` in the outcome, and Fabro matches it to the corresponding edge: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} approve [shape=hexagon, label="Approve Plan"] approve -> implement [label="[A] Approve"] approve -> plan [label="[R] Revise"] approve -> skip [label="[S] Skip"] ``` The `[A]`, `[R]`, `[S]` prefixes are keyboard accelerators — Fabro strips them when matching, so the user can type just the letter. ## Unconditional edges An edge without a `condition` attribute is the normal fallback. When a node has a single outgoing edge, it doesn't need a condition: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} start -> plan -> implement -> exit ``` When mixing conditional and unconditional edges, conditional matches take priority. An unconditional edge acts as the default fallback: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} gate -> fast_path [condition="outcome=succeeded"] gate -> slow_path ``` For a failed outcome, `on_failure="exit"` skips this fallback after explicit routes are checked, and `on_failure="succeed"` promotes the outcome to `succeeded` before taking it. The default `on_failure="route"` keeps the behavior shown above. ## Weight tiebreaking When multiple edges match (e.g. two unconditional edges), `weight` determines the winner. Higher weight wins: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} node -> preferred [weight=10] node -> fallback [weight=1] ``` If weights are equal, the edge with the lexicographically first target node ID is chosen. This makes the behavior fully deterministic. ## Random selection By default, tiebreaking between candidate edges is deterministic (highest weight, then lexical node ID). Setting `selection="random"` on a node switches to weighted-random tiebreaking for its outgoing edges: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} picker [label="Pick path", selection="random"] picker -> path_a [weight=3] picker -> path_b [weight=1] ``` In this example, `path_a` is chosen \~75% of the time and `path_b` \~25%. Edges with weight ≤ 0 are treated as weight 1. The cascade priority (conditions → preferred label → suggested next → unconditional) is unchanged — randomness only affects the pick-one-from-candidates step within each tier. `selection="random"` cannot be combined with conditional edges on the same node. Validation rejects this combination because condition evaluation order would conflict with random selection. Use unconditional edges with weights instead. # Variables Source: https://docs.fabro.sh/workflows/variables Using templates in workflows Fabro renders full MiniJinja templates in three workflow attributes: the graph `goal`, the root graph's `model_stylesheet`, and node `prompt`s. A command node's `script` gets narrower treatment — [simple value substitution](#command-node-scripts), not templating. Every other attribute is literal text. ## Template context Goal templates can reference inputs and server-managed variables. Prompt templates receive those values plus the rendered workflow goal: | Expression | Resolves to | | - | - | | `{{ goal }}` | The rendered workflow goal (prompts only; a goal cannot reference itself) | | `{{ inputs.name }}` | A value from `[run.inputs]`, optionally overridden by CLI input flags | | `{{ vars.NAME }}` | A server-managed variable snapshotted when the run is created | The root `model_stylesheet` receives only `inputs` and `vars`. It does not receive `goal`. See [Model Stylesheets](/workflows/stylesheets#template-stylesheets) for examples and output safety guidance. Secrets are **not** available in goal, prompt, or model stylesheet templates. Use `{{ secrets.NAME }}` only in the configuration fields that support run-boundary interpolation. ## Run config inputs Define typed inputs in `[run.inputs]`: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [workflow] graph = "check.fabro" [run] goal = "Run repository checks" [run.inputs] repo_name = "fabro" repo_url = "https://github.com/fabro-sh/fabro" language = "rust" ``` These values are available in the graph `goal`, root `model_stylesheet`, and node `prompt` attributes: ```dot title="check.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Check { graph [goal="Run tests for {{ inputs.repo_name }}"] start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] test [label="Test", prompt="Clone {{ inputs.repo_url }} and run the {{ inputs.language }} test suite."] start -> test -> exit } ``` Other attributes — `label`, `model`, `provider`, `condition`, and all edge attributes — do not render templates. If one of them contains `{{ … }}` or `{% … %}`, the syntax is treated as literal text and Fabro records a `detemplated_attribute` warning suggesting you move the dynamic value into a `prompt`, `goal`, or `model_stylesheet`. Override individual inputs at run time with repeatable `-I` / `--input` flags: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro run .fabro/workflows/check/workflow.toml -I repo_name=fabro-2 --input language=rust ``` CLI input values use TOML scalar parsing when possible. Quoted strings, booleans, integers, and floats keep their typed values; unquoted bare text falls back to a string. Empty values such as `foo=` are accepted as empty strings. Arrays, inline tables, and datetimes are rejected. ## Command node scripts A [command node](/workflows/stages-and-nodes#command) `script` substitutes `{{ goal }}`, `{{ inputs.NAME }}`, and `{{ vars.NAME }}`: ```dot title="check.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Check { test [shape=parallelogram, script="cargo test -p {{ inputs.crate }} --profile {{ vars.PROFILE }}"] pr [shape=parallelogram, script="gh pr create --title {{ goal }}"] } ``` This is value substitution, not templating. Only those three forms are recognized; every other brace sequence reaches the shell untouched, so `jq` filters, `awk` programs, Go templates, and brace expansion all keep working: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} report [shape=parallelogram, script="kubectl get pod -o go-template='{{ .status.phase }}' | jq '{phase: .}'"] ``` There is no `{% if %}`, no filters, and no loops, and `{{ goal }}` has no dotted form — `{{ goal.title }}` stays literal. Put branching in a [conditional node](/workflows/stages-and-nodes#conditional) or in the shell itself. In a shell script, Fabro quotes each substituted value as one shell argument. Put the token where one shell word is valid. Do not add quotes around the token, and do not use a token to inject multiple flags or shell syntax: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} build [shape=parallelogram, script="docker build -t {{ inputs.image }} ."] ``` For `language="python"`, Fabro inserts each value as a quoted string literal. Put the token where a Python expression is valid: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} report [shape=parallelogram, language="python", script="print({{ goal }})"] ``` Substituted text is never scanned again, so a goal or input containing `{{ ... }}` reaches the command as literal characters rather than being interpolated a second time. ### `env` and `secrets` are not substituted `{{ env.NAME }}` and `{{ secrets.NAME }}` are rejected in a `script` with a validation error. Read environment variables with `$NAME` in a shell script: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} deploy [shape=parallelogram, script="deploy --token $DEPLOY_TOKEN"] ``` In a Python script, read them with `os.environ["NAME"]`. To make a secret available that way, put it in the [environment's env map](/execution/run-configuration#run-environment-and-environments-slug), where `{{ secrets.* }}` does resolve — at run start, into the sandbox environment rather than into the script text: ```toml title="run.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} [environments.ci.env] DEPLOY_TOKEN = "{{ secrets.DEPLOY_TOKEN }}" ``` This keeps secret values out of the `command.started` event, which records the script verbatim. ### When values resolve Inputs and variables are substituted when the run is created, at the same time as goals and prompts — the persisted workflow already contains the final script. An unbound input or variable is a warning from `fabro validate` and an error at run creation, so a run never executes a partially substituted command. ## Server-managed run config variables Use server-managed variables for non-sensitive values that should be shared across runs, such as deployment environments, default branches, regions, or image tags: ```bash theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} fabro variable set DEPLOY_ENV staging --description "Deployment target" ``` Run configuration strings, graph goals, root model stylesheets, and node prompts can reference these values with `{{ vars.NAME }}`: ```toml title="workflow.toml" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} _version = 1 [run] goal = "Deploy {{ vars.DEPLOY_ENV }}" ``` ```dot title="deploy.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Deploy { graph [goal="Deploy to {{ vars.DEPLOY_ENV }}"] deploy [prompt="Deploy the requested release to {{ vars.DEPLOY_ENV }}."] } ``` Variables are intentionally readable: `fabro variable list` and `fabro variable get DEPLOY_ENV` show stored values. Do not store tokens, API keys, or credentials as variables; use `fabro secret set` for sensitive values. Server-managed variables are stored in the shared SQLite database. When upgrading from file-backed storage, Fabro imports `/variables.json` once; existing SQLite rows win on name conflicts. After a successful import, the source file is renamed to `variables.json.imported-.bak`. The server snapshots the variable store when it creates the run. Changing a variable later does not rewrite that run's rendered goal, imported prompts, or `@file` prompt and goal contents. ## `goal` Agent and prompt nodes also receive the workflow goal at runtime: ```dot title="example.fabro" theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} digraph Example { graph [goal="Implement the login feature"] plan [label="Plan", prompt="Create a plan for: {{ goal }}"] } ``` That prompt becomes `Create a plan for: Implement the login feature`. A graph goal cannot contain `{{ goal }}` because that would reference itself: the goal is not bound while it renders, so `fabro validate` reports `attractor.unbound_input` and run-style commands refuse the workflow with `unsupported.template.unbound_input`. Put the reusable text in an input or server-managed variable instead. ## Expansion timing Fabro keeps workflow structure static and renders workflow templates once: 1. Fabro parses the root DOT and imported `.fabro` files without rendering them. 2. Literal `import`, `@file`, graph-goal file, and child-workflow references are resolved. 3. The graph `goal` is rendered with the `{ inputs, vars }` context. 4. Node `prompt` attributes are rendered with the `{ goal, inputs, vars }` context. 5. The root `model_stylesheet` is rendered with the `{ inputs, vars }` context, then parsed and applied. 6. Node `script` attributes have their `{{ goal }}`, `{{ inputs.* }}`, and `{{ vars.* }}` values substituted. Templates are not supported in graph syntax, node IDs, edge structure, `import` paths, `@file` paths, child workflow paths, other file references, or any attribute besides `prompt`, `goal`, and the root `model_stylesheet` — and `script`, which takes value substitution rather than templates. Command `stdin_source` values are literal context keys. Fabro resolves them at stage execution time, after upstream nodes have updated the workflow context. Fabro renders the graph `goal` first and stores the rendered value back onto the graph. Prompts that use `{{ goal }}` receive that rendered value. ## Undefined variables A workflow variable that nothing binds is a diagnostic from the workflow compile. `fabro validate` reports it as a warning (`attractor.unbound_input`) and leaves the text unrendered, so you can validate workflow structure before all inputs are known. Offline validation does not read a server's variable store, so `{{ vars.* }}` references also warn there. Run-style commands such as `fabro run`, `fabro create`, and preflight use the server snapshot and refuse any still-unbound reference with `unsupported.template.unbound_input` before proceeding. ## Template includes Prompt, goal, and root model stylesheet templates support static MiniJinja loader dependencies such as `{% include "partial.md" %}`. Includes are resolved relative to the template file being rendered and can be nested. Fabro discovers those static dependencies while building the run manifest so sandbox providers receive every required template file. Dynamic loader expressions such as `{% include inputs.partial %}` are rejected; use a literal include path and choose content with normal template conditionals instead. ## Escaping To emit literal template syntax, use MiniJinja escaping: ```dot theme={"languages":{"custom":["/languages/dot.json","/languages/fabro.json"]}} test [prompt="{% raw %}{{ goal }}{% endraw %}"] ``` You can also emit literal braces with expressions such as `{{ '{{' }}` when needed. ## Input merging TOML `[run.inputs]` tables intentionally replace the inherited map wholesale rather than merging by key. Whichever TOML layer has the highest precedence and sets `[run.inputs]` wins its entire map. CLI input flags are different: they are sparse per-key overrides applied after config resolution, so unrelated inherited inputs remain available. If a key is repeated on the CLI, the last value wins. | Source | Priority | | - | - | | CLI flags (`-I key=value` / `--input key=value`, repeated; per-key merge) | Highest | | `workflow.toml` `[run.inputs]` | | | `.fabro/project.toml` `[run.inputs]` | | | `~/.fabro/settings.toml` `[run.inputs]` | Lowest |