Feature Agent — User Guide
Everything a user needs to run Feature Agent (FA): enroll a project, submit a feature request, configure the sandbox environment, drive the review/PR flow, and stay informed.
FA turns a plain-English feature request into a reviewable pull request — implemented autonomously by an AI coding agent in an isolated sandbox, with tests, docs, a feature branch, and a draft PR you merge.
Related docs:
SETUP.md(installing FA + Claude Code) ·OPERATIONS.md(running FA in production: systemd, Cloudflare, recovery). This guide is the feature reference — what FA does and how you use it.Keeping this current: every user-facing feature must update this guide as part of shipping (North Star §7-8). If something here doesn't match the running server, that's a bug — file it.
Contents
- Core Concepts & Getting Started
- Submitting & Managing Features
- Configuring a Project (the Environment Manifest)
- Spec-Kit, Review, Pull Requests & Agent Permissions
- Notifications, Webhooks & Observability
- Inbound Triggers (GitHub, GitLab, Linear, Sentry & Slack)
- Fleet Overview — Cross-Project Triage Dashboard
- Active Runs — Live Pulse of Every Running Feature
- Active Runs Drill-Down — Portfolio-Wide Live Run Activity
- Dashboard Stat Cards — What Each Count Means
- Possibly-Stuck Drift Panel — Silent-State Divergence Made Visible
- Run Provenance — Audit Trail in Every PR
- Fleet Fan-out — Submit One Spec Across N Projects
- Campaign Grouping & Batch Rollup
- Builder — Draft a Spec from Plain English
- Audit Log Export & SIEM Integration
- Mobile Dashboard — Phone & Tablet Access
Examples use
http://localhost:3100(the default). Replace with your FA host (e.g.https://fa.redshoehomes.ca). Behind Cloudflare Access you authenticate at the edge first, then present the API key shown below.
Core Concepts & Getting Started
What Feature Agent Is
Feature Agent (FA) is a self-hosted platform that turns a plain-English feature request into a reviewable pull request — implemented autonomously by Claude Code.
The mental model is three steps:
- Enroll a project — register a Git repository with FA (its URL, default branch, and how much autonomy you want to grant).
- Submit a feature request — a title and a description of what you want built.
- The agent implements it — FA clones your repo into an isolated sandbox, creates a feature branch, runs Claude Code to write the code, runs your tests, writes docs, commits, pushes the branch, and opens a draft pull request for you to review and merge.
You stay in control: depending on the project's autonomy mode, the agent may pause to ask clarifying questions or wait for a human to approve the work before any code is written. FA never merges for you — it opens a draft PR and (once you merge it on GitHub) detects the merge automatically.
Key Entities
| Entity | What it is |
|---|---|
| Project | An enrolled Git repository plus its configuration: default branch, autonomy mode, environment manifest (test command, setup, data, runtime image), notification channels, and its own API key. |
| Feature | A single work request against a project — a title + description, with an optional priority. As it is processed it accumulates a branch_name, pr_url, test results, docs, and cost/token metrics. |
| User | A person with a login key. Two roles: admin (full control — manage projects, users, and any feature) and approver (can review and approve features, but only for the projects they are linked to). |
| Clarification | A question the agent (or the spec-kit pipeline) raises when a request is ambiguous. The feature pauses in clarification_needed until the question is answered, then resumes. |
Autonomy Modes
A project's autonomy_mode decides how much a human is in the loop. It sets the initial status a new feature lands in:
| Mode | New feature starts in | Behavior | Use when |
|---|---|---|---|
full_auto | queued | No review, no analysis gate — the feature goes straight into the implementation queue. | You trust the agent fully (e.g. a sandbox/experimental repo) and want maximum throughput. |
auto_safe | analyzing | The agent first reviews the request; if it is unclear it moves to clarification_needed and asks questions, otherwise it proceeds to queued. The gate is best-effort: if the analysis output is unreadable twice, the feature proceeds to queued anyway (it does not stall) and is flagged with an analysis_indeterminate indicator on the dashboard and in the run-events ledger. | You want a light safety net — the agent should ask before guessing, but no human sign-off is required. |
po_approval | awaiting_approval | The feature waits for a human (admin or a linked approver) to approve it before any analysis or implementation happens. A cost estimate is attached at submission when enough history exists. | Every change needs a human "go" first — production repos, cost control, or compliance. Requires a po_email on the project. |
The Feature Lifecycle
Every feature moves through a well-defined set of statuses. Here is the full state machine:
┌──────────── po_approval ────────────┐
submit ─▶ pending ─▶ awaiting_approval ─(approve)─▶ analyzing
│ │
│ auto_safe: starts at ─────────────▶ │
│ ▼
│ ┌─── clarification_needed ───┐
│ │ (answer question) │
│ ▼ │
full_auto: starts at ───────────────────▶ queued ◀────────────────────┘
│
▼
in_progress
│
▼
implemented ──(you merge PR)──▶ merged [terminal]
│ ▲ ↘(PR closed unmerged, auto)▶ wont_merge [terminal]
(/revise)│ │(done)
▼ │
revising
│ ▲
(reviewer=spec_conformance, auto)
▼ │
reviewing
any active state ──(/cancel)──▶ cancelled ──(/retry)──▶ re-queued
any state ────────────────────▶ failed ────(/retry)──▶ re-queued
credit limit hit ─────────────▶ awaiting_credits ──(credits return)──▶ resumes
oauth expiry ────────────────▶ awaiting_auth ────(creds refreshed)──▶ resumesStatus by status:
| Status | Meaning | Typically moves to |
|---|---|---|
pending | Just submitted, not yet routed by the engine. | awaiting_approval, analyzing, or queued per autonomy mode |
awaiting_approval | Waiting for a human to approve (po_approval only). | analyzing on approval |
analyzing | The agent is reading the request to decide if it's actionable or needs clarification. | clarification_needed or queued |
clarification_needed | The agent has open questions for you. Answer them to continue. | back to queued/analysis once answered |
queued | Ready to build, waiting for an implementation slot. | in_progress |
in_progress | The agent is actively writing code in the sandbox. | implemented, failed, awaiting_credits, or awaiting_auth |
implemented | Draft PR is open and awaiting your merge decision. Transient waypoint — not a lifetime tally (see below). | merged (auto), wont_merge (auto or manual), or revising |
in_progress | The agent is actively writing code in the sandbox. | implemented, failed, or awaiting_credits |
implemented | Draft PR is open and awaiting your merge decision. Transient waypoint — not a lifetime tally (see below). | merged (auto), wont_merge (auto or manual), reviewing (auto, if reviewer enabled), or revising |
reviewing | The spec-conformance reviewer is running: a fresh agent is comparing the diff against the spec. Transient — always returns to implemented. | back to implemented (verdict posted as a VCS review) |
revising | The agent is addressing PR review comments on the same branch/PR (via /revise). | back to implemented |
merged | Terminal. Your PR was merged; FA detected it by polling GitHub. | — |
wont_merge | Terminal. PR was closed without merging (auto-detected) or you explicitly decided not to ship it. | — |
failed | The agent could not complete the run. | re-queued via /retry |
cancelled | You aborted the run. Editable and re-runnable. | re-queued via /retry |
awaiting_credits | Paused because the Claude credit limit was reached; the workspace is preserved. | resumes automatically when credits return |
awaiting_auth | Paused because the OAuth session expired mid-run; workspace preserved, retry_count NOT bumped. FA auto-resumes when the host refreshes credentials. | resumes automatically on credential refresh; run claude /login only if the refresh token is dead |
Terminal states are merged and wont_merge — a feature never leaves these. failed and cancelled are re-runnable (POST /api/features/:id/retry). Cancelling a revising feature reverts it to implemented and leaves the PR intact.
implemented is a transient waypoint, not a lifetime tally. FA polls GitHub every few minutes for each implemented feature's PR. When the PR is merged, the feature automatically transitions to merged. When the PR is closed without merging (e.g. you decided the approach was wrong and closed the PR on GitHub), FA automatically transitions the feature to wont_merge — no manual step needed. The implementation_log records --- PR closed unmerged; auto-reconciled to wont_merge --- so you can distinguish auto-reconciliation from a manual won't-merge. The "Implemented" count in the dashboard therefore reflects only genuinely open, unreviewed PRs, not a historical accumulation.
Signing In
FA supports two ways for humans to access the dashboard:
Google Sign-In (recommended for human users)
When the operator has configured Google OAuth (see Configuring Google OAuth), a "Sign in with Google" button appears on the login page (/login.html). Click it to sign in with your Google account.
Important — invite-only: FA does NOT allow open self-signup. Your Google account email must match a pre-provisioned FA user account. If you see an error like "No account for your@email.com — invite-only", ask your FA administrator to create a user account for your email via POST /api/users. Once the account exists, you can sign in with Google.
After a successful Google sign-in, FA:
- Links your Google identity to your FA user account (recorded in the
identitiestable). - Issues a signed session cookie that keeps you logged in for 7 days.
- Redirects you to the dashboard, which works via the session.
CSRF protection: all state-changing requests (POST/PATCH/DELETE) made via the session cookie require an X-CSRF-Token header. The dashboard handles this automatically — you don't need to do anything. API clients using Bearer keys are exempt.
SSO / OIDC (enterprise identity)
When the operator has configured a generic OIDC identity provider (see SSO / OIDC — Enterprise Identity), a "Sign in with SSO" button appears on the login page. Click it to sign in with your organization's identity provider (Okta, Azure AD / Entra, Auth0, Keycloak, or any OpenID Connect–compliant issuer).
Identity linking: On your first SSO login, FA looks up your account by the IdP-verified email address (email_verified=true). Your OIDC identity (provider='oidc', provider_subject=<sub>) is then linked to your FA user account. On subsequent logins, FA finds you directly via the linked identity — so if your email changes at the IdP, you are still recognized.
Default (invite-only): If OIDC_ALLOW_SIGNUP=false (the default), your email must match a pre-created FA account. If you see "No account for your@email.com", ask your FA administrator to create your account via POST /api/users. Once the account exists, you can sign in with SSO.
Auto-provisioning: If OIDC_ALLOW_SIGNUP=true, FA creates a new account on your first login with the least-privilege approver role. An admin can promote you to admin afterwards.
After a successful SSO login, FA:
- Verifies your ID token signature, issuer, audience, expiry, nonce, and email verification status.
- Links or updates your OIDC identity in the
identitiestable. - Issues a signed session cookie valid for 7 days.
- Redirects you to the dashboard.
Email + Password (local accounts)
When the operator has configured SESSION_SECRET, an email/password form appears on the login page. Enter your email address and password, then click Sign In with Email.
Invite-only: Your account must be pre-created by an administrator before you can log in. Accounts with no password set are rejected until a password is set via the reset flow below.
After a successful login, FA:
- Records a
localidentity entry (same as Google does with agoogleentry). - Issues a signed session cookie that keeps you logged in for 7 days.
- Redirects you to the dashboard.
Brute-force protection: after 5 consecutive failed login attempts for an email+IP pair, the account is temporarily locked out for 15 minutes.
Setting or Resetting Your Password
If your account has no password yet (new invite) or you've forgotten your password:
- On the login page, click "Set / forgot password?" below the email/password form.
- Enter your email address and click Send Reset Link.
- Check your email for a link from Feature Agent (valid 24 hours, single-use).
- Click the link. The login page will show a "Set New Password" form.
- Enter and confirm your new password (minimum 8 characters), then click Set Password.
- You can now sign in with your new password.
If you don't receive an email, ask your administrator — SMTP may not be configured on this instance. They can work around it by generating a reset link manually.
Security properties of reset tokens:
- Signed with
SESSION_SECRET(HMAC-SHA256) — cannot be forged without the server secret. - Expire after 24 hours.
- Single-use: setting a new password invalidates all prior tokens (fingerprint-based).
reset-requestalways returns 200, whether or not the email exists — no user enumeration.
API Key (for automation and fallback)
The existing API key path continues to work unchanged. On the login page, enter your user API key (fa_...) in the "API Key" field and click "Sign In with API Key". The key is validated and a session cookie is issued. The dashboard then operates via the session.
API clients, CI pipelines, and the self-build loop continue to use Authorization: Bearer <key> directly — no change required.
Logout
Click "Sign Out" in the dashboard header to clear the session cookie. On the login page you will be returned to the sign-in screen.
Authentication & API Keys
FA has three kinds of credentials. All are passed as Authorization: Bearer <key>.
| Credential | Format | Who holds it | Grants |
|---|---|---|---|
| Project API key | fa_ + 64 hex chars | The enrolled project / its automation | Submitting and viewing that project's own features, and self-configuring its environment (/api/project). Scoped to a single project. |
| User API key | fa_ + 64 hex chars | An admin or approver user | Admin users: full access. Approver users: review/approve features on their linked projects (/api/users/me, /api/users/me/features, approvals). |
| Legacy admin key | value of ADMIN_API_KEY env var | The operator | Admin-level access, equivalent to an admin user key. Optional. |
Which credential each API family needs:
/api/projects(create, list, update, delete projects) — admin (legacy admin key or an admin user key)./api/features(submit/list/view/cancel/retry/revise a project's own features) — the project API key for that project./api/features/admin/...(act on any project's features) — admin./api/features/:id/approve— admin or approver (an approver must be linked to that project)./api/project(self-config) — the project API key (identifies the project; no:idneeded)./api/users(manage users) — admin./api/users/meand/api/users/me/features— any user key.
Note on dev mode: when FA is bound to loopback only and no
ADMIN_API_KEYis set, admin endpoints are open for local development. As soon as the server is exposed (non-loopback bind,FA_EXPOSED=true, orNODE_ENV=production) admin routes fail closed and require a valid credential.
The project key is shown exactly once, in the response to POST /api/projects. Every later read redacts it (api_key: null), so store it when you create the project.
Secret field masking: notification_channels (bot tokens, webhook URLs, passwords), env (all values), and services (credential-bearing fields) are masked as "***REDACTED***" in all API and dashboard responses after creation. Keys and non-secret fields (channel type, chat_id, env key names, service name/image) are always visible. To change a secret: submit the new value. To leave it unchanged: submit the masked value — FA detects the sentinel and preserves the stored secret.
The Project Self-Config Endpoint (/api/project)
A project can read and update its own environment manifest using its own API key — no admin key required, and no :id in the path (the key identifies the project). This lets a project own the declarative environment its runs are built and validated in, while the operator keeps control of the privileged settings.
GET /api/project— returns the authenticated project's current configuration (with the secretapi_keystripped).PATCH /api/project— update only the self-configurable fields below. Any other field is rejected with403(not silently ignored), and the response lists both theforbidden_fieldsyou tried and theallowed_fieldsyou may set.
Self-configurable fields (a project may set these with its own key):
runtime_image, data_dirs, services, env, setup_command, preflight_command, test_command, test_gate, verify_command, agent_max_turns, max_budget_tokens, max_budget_seconds, code_discipline, permission_policy
Admin-only fields (changeable only via PATCH /api/projects/:id with an admin key): everything else — including name, repo_url, default_branch, autonomy_mode, po_email, allowed_tools, notification_channels, callback_url, protected, auth_mode, engine, and auto_create_pr. In short: a project can shape how its environment is built and tested, but not its privilege boundary (approval mode, tool access, credential mounts).
Example — a project raising its own test command and turn budget:
curl -X PATCH http://localhost:3100/api/project \
-H "Authorization: Bearer fa_<project_key>" \
-H "Content-Type: application/json" \
-d '{
"test_command": ".venv/bin/python -m pytest -q",
"agent_max_turns": 150
}'Trying to set an admin-only field returns 403:
{
"error": "These fields are admin-only and cannot be set with a project key. Use ADMIN_API_KEY on PATCH /api/projects/:id.",
"forbidden_fields": ["autonomy_mode"],
"allowed_fields": ["runtime_image", "data_dirs", "..."]
}Quick Start: From Zero to a Draft PR
This walks the minimal end-to-end path. Assume FA is running at http://localhost:3100.
1. Enroll a project (admin). Only name, repo_url, and autonomy_mode are required. In po_approval mode a po_email is also required. The response is the only place you'll see the project's api_key — save it.
curl -X POST http://localhost:3100/api/projects \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "my-app",
"repo_url": "https://github.com/me/my-app.git",
"default_branch": "main",
"autonomy_mode": "auto_safe"
}'Response (201), abbreviated:
{
"id": "3f2b...",
"name": "my-app",
"autonomy_mode": "auto_safe",
"api_key": "fa_1a2b3c...<64 hex>",
"spec_kit_status": "disabled"
}Export the key for the next calls:
export PROJECT_KEY="fa_1a2b3c...<64 hex>"2. Submit a feature request (project key). Only title and description are required; priority is one of low | medium | high | critical (defaults to medium).
curl -X POST http://localhost:3100/api/features \
-H "Authorization: Bearer $PROJECT_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Add a /health endpoint",
"description": "Add a GET /health route that returns {\"status\":\"ok\"} and a test for it.",
"priority": "high"
}'Response (201) includes the new feature's id and its initial status (here analyzing, because the project is auto_safe).
3. Watch it flow. Poll the feature (project key):
# All features for this project (optionally filter by status)
curl -H "Authorization: Bearer $PROJECT_KEY" \
"http://localhost:3100/api/features?status=implemented"
# One feature, with its clarifications
curl -H "Authorization: Bearer $PROJECT_KEY" \
http://localhost:3100/api/features/a91c...If it asks a question (status: clarification_needed), answer the clarification (its id is in the feature-detail response's clarifications array):
curl -X POST \
http://localhost:3100/api/features/a91c.../clarifications/<clarificationId>/answer \
-H "Authorization: Bearer $PROJECT_KEY" \
-H "Content-Type: application/json" \
-d '{ "answer": "Return HTTP 200 with a JSON body; no auth required." }'If the project is po_approval, the feature sits in awaiting_approval. An admin or a linked approver approves it:
curl -X POST http://localhost:3100/api/features/a91c.../approve \
-H "Authorization: Bearer $APPROVER_OR_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "approved_by": "you@example.com" }'4. Get your PR. Once the feature reaches implemented, its branch_name is pushed and its pr_url points at the draft pull request. Review it on GitHub; when you merge it there, FA polls GitHub and moves the feature to merged on its own. If PR review comments come back, POST /api/features/:id/revise has the agent address them on the same branch.
That's the whole loop: enroll → submit → (clarify/approve) → implemented → merge.
Submitting & Managing Features
This section covers the full lifecycle of a feature request: how to submit one, attach files to it, answer the agent's clarifying questions, approve it (when your project requires it), drive it through lifecycle actions, and monitor its progress and cost.
All feature endpoints live under /api/features. Unless noted otherwise, you authenticate with your project API key (fa_ prefix), which scopes every call to your own project. Endpoints with an /admin/... variant accept an admin or approver key instead and can act on any project's features. Where both exist, they behave identically except for auth and scope — this doc shows the project-key form.
Submitting a Feature
POST /api/features (project key)
Only title and description are required. Everything else is optional and inherits a project- or server-level default when omitted. On success you get 201 with the created feature object (including its generated id). The initial status depends on your project's autonomy mode (see above).
Submittable fields
| Field | Type | What it does / when to use |
|---|---|---|
title | string | Required. Short name for the feature. Also seeds the branch slug (feature/<slug>-<id>). |
description | string | Required. The actual request. The more concrete, the fewer clarifications the agent needs. Attach artifacts (below) to pin down anything visual or data-shaped. |
priority | low | medium | high | critical | Advisory priority label. |
submitter_email | string | Where "your feature is done" notifications go (if the project routes to submitter). |
submitter_contact | object | Non-email contact for notifications: { "telegram_chat_id": "...", "whatsapp": "..." }. |
use_spec_kit | boolean | Opt into the spec-kit pipeline instead of the standard flow. Silently coerced to false unless your project's spec-kit status is enabled. Not compatible with spec_content. |
base_branch | string | Branch the agent clones/branches off (and the PR targets) instead of the project's default_branch. Use to stack a feature on top of another branch. |
spec_path | string | Where the agent writes the spec/doc, e.g. specs/028-foo/spec.md. Default is docs/features/<slug>.md. Use when your reviewer expects the spec at a canonical path. |
spec_content | string | Verbatim spec handoff. This exact text is written byte-identical at spec_path — no regeneration. Use when an upstream tool produced the authoritative spec and a reviewer will byte-compare it. Forces the standard flow (use_spec_kit is ignored). |
callback_url | string | Per-feature webhook URL for this feature's status changes, overriding the project's callback_url. Signed with your project key either way. |
runtime_image | string | Container image override for this run. Inherits the project image, then the server default. Use when this feature needs a different toolchain. |
network_policy | string | Docker network override for this run. Null/unset inherits the project network_policy, then FA_RUNTIME_NETWORK (default bridge). ⚠ none breaks engine egress — both api and oauth auth need outbound network to reach Anthropic. Useful values: bridge or an operator-created network name. |
verify_command | string | Shell command FA runs to produce feature-level evidence (a backtest, eval, smoke test). Its output is committed and surfaced in the PR. Overrides the project's verify_command. |
verify_gate | warn | block | Overrides the project-level verify_gate for this feature. warn = record results, still ship (default). block = non-zero exit fails the run: no commit, no PR. null inherits the project setting. |
auth_mode | api | oauth | How the sandboxed agent authenticates inside the container. Defaults to the server's configured mode. |
code_discipline | off | lite | full | Injects a YAGNI / minimal-code discipline block to curb over-engineering. Overrides the project default; omit (or null) to inherit. |
reviewer | off | spec_conformance | Opt-in spec-conformance reviewer. spec_conformance posts a real VCS review (APPROVE / REQUEST_CHANGES) after implementation, comparing the diff against the spec. Overrides the project default; omit to inherit. |
reviewer_model | string | Model for the reviewer agent (e.g. claude-opus-4-8). Blank = inherit project or implementer model (self-review). Overrides the project default. |
reviewer_engine | string | Engine id for the reviewer agent. Blank = inherit project or implementer engine. Overrides the project default. |
permission_policy | allow_all | deny_all | ask | governed | Governs mid-run permission requests (see §4). Overrides the project default. |
engine | string | Engine id override for this run. Must be one of the server's known engines, or 400. Inherits the project engine / default. |
engine_routing_policy | object | null | Per-feature declarative role→engine routing policy (e.g. {"reviewer":"gemini-profile"}). Overrides the project policy; null inherits. See Engine Routing Policy. |
max_budget_tokens | number | null | Hard-stop token cap for the run; must be > 0 when set. Invalid → 400. |
max_budget_seconds | number | null | Hard-stop wall-clock cap (seconds) for the run. Works under both auth modes. Invalid → 400. |
Validation: missing
title/description→400; a badengine,max_budget_tokens, ormax_budget_seconds→400with a message naming the problem.
curl -X POST http://localhost:3100/api/features \
-H "Authorization: Bearer fa_yourprojectkey" \
-H "Content-Type: application/json" \
-d '{
"title": "Add CSV export to reports page",
"description": "Add a Download CSV button to /reports that exports the current filtered view. Match the column order shown in the mockup artifact.",
"priority": "high",
"base_branch": "develop",
"code_discipline": "lite",
"max_budget_seconds": 1800
}'Admin variant: POST /api/features/admin (admin key) additionally requires a project_id so you can submit on behalf of any project.
Attaching Artifacts
Artifacts are opaque files (a mockup, a data sample, an error log, a design PDF) that travel with the feature and are copied read-only into the agent's workspace at .fa-artifacts/<filename> during the standard implement flow. The agent is told they exist and where — your description tells it what to do with them ("build the UI to match mockup.png", "parse sample.csv"). FA never interprets or executes them.
- Upload —
POST /api/features/:id/artifacts(project key or admin/user auth):multipart/form-data, files in thefilesfield. Only before implementation begins (statuspending/awaiting_approval/analyzing/clarification_needed/queued; else409). Limits: ≤10 files, ≤10 MB/file, ≤25 MB total (over →413). Filenames sanitized to a safe basename; collisions auto-suffixed. The admin dashboard uses admin auth for uploads automatically — no project key needed.bash# Using a project key (project-scoped): curl -X POST http://localhost:3100/api/features/<id>/artifacts \ -H "Authorization: Bearer fa_yourprojectkey" \ -F "files=@./mockup.png" -F "files=@./sample.csv" # Using an admin/user key (cross-project access): curl -X POST http://localhost:3100/api/features/<id>/artifacts \ -H "Authorization: Bearer fa_youradminkey" \ -F "files=@./mockup.png" - List —
GET /api/features/:id/artifacts(project or admin): metadata only (id,filename,size,mime,created_at). - Download —
GET /api/features/:id/artifacts/:artifactId(project or admin): streams the file (Content-Disposition: attachment,nosniff). Missing on disk →410. - Delete —
DELETE /api/features/:id/artifacts/:artifactId(project or admin): removes file + record, any status. Does not remove a copy already provisioned into a started run (that snapshot is immutable — delete + re-upload to replace).
Artifacts are tenant-scoped by project/feature; a project key can only touch its own features (cross-project →
404, bytes never leak). Admin/user keys may upload to any feature.
Answering Clarifications
When the agent needs more information it moves the feature to clarification_needed and records questions. Fetch them via feature detail (GET /api/features/:id → clarifications array; each has id, question, answer, status, source). Answer each:
POST /api/features/:id/clarifications/:clarificationId/answer (project key)
curl -X POST \
http://localhost:3100/api/features/<id>/clarifications/<clarification-id>/answer \
-H "Authorization: Bearer fa_yourprojectkey" \
-H "Content-Type: application/json" \
-d '{ "answer": "Use ISO-8601 dates and include a header row." }'answer required (400 otherwise); answering an already-answered one → 400. Once every clarification is answered, the feature automatically returns to analyzing and continues. Admin/approver variant: POST /api/features/admin/:id/clarifications/:clarificationId/answer.
Approving a Feature (po_approval projects)
In po_approval mode a feature sits in awaiting_approval until approved. FA attaches a pre-run cost estimate (estimated_cost_usd + min/max/samples) when enough history exists, so the approver sees expected spend.
POST /api/features/:id/approve (admin key, or an approver linked to the project):
curl -X POST http://localhost:3100/api/features/<id>/approve \
-H "Authorization: Bearer fa_youruserkey" \
-H "Content-Type: application/json" \
-d '{ "approved_by": "jane@example.com" }'approved_by required unless you authenticate as a user (then your email is used). Feature must be awaiting_approval (400 otherwise); an unlinked approver → 403. On approval it moves to analyzing.
Separation of duties (governance): in po_approval mode, the person who submitted a feature cannot be the one who approves it. If approved_by matches the feature's submitter_email (case-insensitive, trimmed), FA returns 403 with code: "separation_of_duties" and the feature stays in awaiting_approval. This ensures every approval in the audit trail represents a genuine second pair of eyes.
- The check is skipped when no
submitter_emailwas recorded at submission time (identity unknown — the self-build loop submits without one and is never blocked). - The check is purely identity-based on the existing
submitter_email/approved_byfields — no schema change required. - Per-project configuration (opt-out for single-operator shops) and strict user-identity enforcement are planned follow-up increments.
Lifecycle Actions
Each requires a compatible status. All have an /admin/:id/... variant for admin/approver keys.
| Action | Endpoint (project key) | Requires status | What it does |
|---|---|---|---|
| Retry | POST /:id/retry | failed or cancelled | Re-queues for a fresh run. Bumps retry_count (on failed). Edit the feature first (via PATCH) to change the request before retrying. |
| Rerun | POST /:id/rerun | failed, cancelled, implemented, or wont_merge (not merged) | Re-runs in place with current config, no re-post — same branch/PR. Wipes the preserved workspace so it re-clones the latest base. |
| Cancel | POST /:id/cancel | analyzing, queued, clarification_needed, in_progress, awaiting_credits, revising | Aborts an in-flight run (SIGTERM if live → 202 {cancelling:true}). Cancels to cancelled — except a revising feature reverts to implemented (PR intact). |
| Won't-merge | POST /:id/wont-merge | any non-terminal | Marks wont_merge (terminal). Optional {reason} recorded. Fires a notification. |
| Create PR | POST /:id/create-pr | implemented, has branch_name, no pr_url | Recovery: opens a draft PR when auto-creation failed (e.g. expired token). Already has a PR → 409; GitHub failure → 502. Server reads GITHUB_TOKEN at startup — restart after rotating. |
| Revise | POST /:id/revise | implemented, has pr_url + branch_name | Addresses PR review comments: flips to revising, fetches the PR feedback, applies changes, runs tests, pushes the same branch. Returns to implemented. No comments / error → reverts to implemented, PR intact. |
| Notify | POST /:id/notify | any | Force-sends a status notification. Optional {message} overrides the default text. |
Edit a feature's fields with PATCH /api/features/:id (title, description, priority, status, submitter info) — commonly before a retry.
Monitoring a Feature
GET /api/features/:id(project key) — full feature +clarifications. Watch:status,branch_name,pr_url,doc_path,test_results,verification_results,duration_ms,total_cost_usd,total_input_tokens,total_output_tokens,tokens_used(live burn-down), theestimated_cost_*fields,retry_count,recovery_count,pr_review_comment_count.GET /api/features/:id/logs(admin/approver) —{ feature_id, log }, the full agent log.GET /api/features/:id/events(project key) — the durable run-event timeline ({ events: [...] }).GET /api/features/:id/replay(project key) — the lifecycle reconstructed from the ledger (404if no events yet).
Each has an /admin/:id/... counterpart. Admins can also use GET /api/features/admin/all; spend rollups via GET /api/features/cost-summary and governance line-items via GET /api/features/report?format=csv (see §5).
Configuring a Project (the Environment Manifest)
When you enroll a project, FA needs to know how to build a sandbox for it, how to prove a change is good, and how much it's allowed to spend. You express all of that declaratively through a set of project fields FA calls the environment manifest. FA never learns what your commands do — it just runs what you declare. A pytest gate, a backtest, an eval, a smoke test — all identical to FA.
Two ways to configure
| Route | Auth | Scope | What you can set |
|---|---|---|---|
PATCH /api/projects/:id | Admin key | Any project field | Everything, including governance/security fields |
PATCH /api/project | The project's own API key | This project only (no :id) | Only the environment-manifest fields (below) |
The self-service route accepts only: runtime_image, data_dirs, services, env, setup_command, preflight_command, test_command, test_gate, verify_command, agent_max_turns, max_budget_tokens, max_budget_seconds, code_discipline, permission_policy, reviewer, reviewer_model, reviewer_engine. Any other field → 403. Governance/security fields (autonomy_mode, allowed_tools, allowed_origins, auth_mode, engine, protected, callback_url, name, api_key) stay admin-only.
# Self-service: a project tightens its own test gate with its own key
curl -X PATCH http://localhost:3100/api/project \
-H "Authorization: Bearer fa_<project_key>" \
-H "Content-Type: application/json" \
-d '{ "test_command": ".venv/bin/python -m pytest -q", "test_gate": "block" }'Read config back with GET /api/project (project key) or GET /api/projects/:id (admin key). The api_key is never echoed after creation.
Sandbox / runtime
Apply when FA runs under RUNTIME=docker (under local the agent runs on the host and runtime_image/services are ignored).
| Field | What it does | Example | When |
|---|---|---|---|
runtime_image | Container image the agent, setup, and tests run in — supplies the language toolchain (FA always adds git + the Claude CLI). Unset → default Node image (fa-runtime:latest). Missing/unpullable → run fails up front. | "fa-runtime-python" | Any non-Node project. |
network_policy | Docker network the agent container attaches to. Null/unset → inherits FA_RUNTIME_NETWORK (default bridge = full outbound internet). Set to the name of an operator-provisioned network to pin all this project's runs to it. A per-feature network_policy overrides the project value. See resolution order below. | "org-egress-filtered" | Pin runs to an egress-controlled network; tighten isolation without touching the global default. |
data_dirs | Read-only data mounted into every workspace. String path (dest = basename) or {src,dest}. Every source must exist on the host or the run fails immediately. Excluded from commits. | [{"src":"/data/fixtures","dest":"fixtures"}] | Frozen datasets, caches, weights. |
services | Ephemeral deps (Postgres, etc.) started alongside the agent. name (network alias), image, env, ready, expose (env injected into the agent container). | see example | A suite needing a real DB/cache/broker. |
env | Env vars injected into the agent container. | {"MARKET_DATA_OFFLINE":"1"} | Flags, offline switches. |
setup_command | Runs after clone + data provisioning to install deps (pip install -r, npm ci, …). Non-zero exit fails the run. Toolchain belongs in runtime_image, not here. | "pip install -r requirements.txt" | Almost always. |
network_policy resolution order and caveats
Resolution (highest wins): feature network_policy → project network_policy → FA_RUNTIME_NETWORK env var (default bridge).
When a run declares services, the agent container attaches to the private services network so it can reach declared deps — network_policy has no effect in that case (the services network is always authoritative for inter-container comms).
⚠ WARNING —
nonebreaks engine egress. Bothapiandoauthauth modes require the agent to reach Anthropic over the network to authenticate. Settingnetwork_policy: "none"cuts all egress and will cause every run to fail. Only usenoneif you have an engine/setup that genuinely needs zero egress and handles authentication differently. Useful values arebridge(default, full outbound) or the name of an operator-created, optionally egress-filtered Docker network (see OPERATIONS.md).
A services entry: expose is how the agent finds the service (DATABASE_URL injected; hostname = the service name):
{ "name": "db", "image": "postgres:16",
"env": { "POSTGRES_PASSWORD": "postgres", "POSTGRES_DB": "app" },
"ready": { "type": "pg", "port": 5432, "timeoutMs": 30000 },
"expose": { "DATABASE_URL": "postgresql://postgres:postgres@db:5432/app" } }The test gate (proving a change is good)
FA runs commands at three points: before the agent (env sane?), as the gate (tests pass?), and as evidence (feature works?).
| Field | What it does | Example | When |
|---|---|---|---|
preflight_command | Runs on the untouched clone, before the agent. Red baseline = broken env (not the agent) → abort for ~$0, zero agent spend. Unset → skipped. | ".venv/bin/python -c 'import app'" | Catch broken env cheaply. |
test_command | The test gate. Unset → npm test fallback (but see test_gate default below). | ".venv/bin/python -m pytest -q" | Every project with tests. |
test_gate | On a failing test_command: warn (record, still ship), block (fail the run: no implemented, no PR), iterate (re-invoke agent+tests up to FA_MAX_TEST_ITERATIONS, default 2; green ships, ceiling fails like block). Default is iterate when a test_command is declared; warn when none is. A project that tells FA how to test itself gets iterate-until-green automatically; a project that never declared a test_command stays on warn (FA can't meaningfully iterate without a runnable test command). An explicit test_gate on the project always overrides the default (test_gate and test_command are project-level settings only). | "block" | block/iterate when red must never ship. Set warn to opt out of the iterate default. |
verify_command | Feature-level evidence (backtest/eval/smoke); output committed as verification.md + surfaced in the PR. Unset → none. | ".venv/bin/python scripts/backtest.py" | When "tests pass" isn't enough. |
verify_gate | On a failing verify_command: warn (default — record results, still ship) | block (non-zero exit fails the run: no implemented, no commit, no PR — workspace preserved). A feature-level verify_gate overrides the project setting. Unknown values coerce to warn (fail-safe). | "block" | Pair with verify_command to turn advisory evidence into an enforced gate. |
Budgets & limits
| Field | What it does | Notes |
|---|---|---|
max_budget_tokens | Hard-stop token cap per run; > 0 when set. Unset → FA_MAX_BUDGET_TOKENS (uncapped if unset). | 0/negative → 400. |
max_budget_seconds | Hard-stop wall-clock cap per run (seconds). Works under both auth modes. | Guards a runaway/hung run. |
agent_max_turns | Per-project turn budget override (AGENT_MAX_TURNS). | Raise for large features. |
Behavior
| Field | Values | What it does | Self-configurable? |
|---|---|---|---|
code_discipline | off(default)|lite|full | YAGNI decision ladder in the prompt; full adds a self-review delete pass. Never weakens correctness/tests/security. | Yes |
reviewer | off(default)|spec_conformance | Spec-conformance reviewer: posts a real VCS review comparing the diff against the spec. A feature-level reviewer field overrides this default. | Yes |
reviewer_model | string|null | Model for the reviewer agent (e.g. claude-opus-4-8). Null = same as implementer (self-review). A distinct model narrows the self-review gap. | Yes |
reviewer_engine | string|null | Engine id for the reviewer agent. Null = same engine as the implementer. Must match a configured engine profile or claude-code. | Yes |
permission_policy | allow_all(default)|deny_all|ask|governed | Mid-run permission handling (see §4). | Yes |
auth_mode | api|oauth | In-container agent auth (default FA_AGENT_AUTH). | Admin only |
engine | claude-code(default)|acp|declared-profile-id | Agent backend. Unknown id → 400. Discover via GET /api/engines. Non-Claude engines use their own auth — Anthropic creds are never forwarded. | Admin only |
auto_create_pr | boolean | Auto-open a draft PR on implemented. | Admin only |
allowed_tools | string[] | Restrict the agent's tool set. | Admin only |
allowed_origins | string[] | CORS origins for this project. | Admin only |
protected | boolean | A protected project can't be deleted (DELETE → 403). | Admin only |
The .fa/environment.yml in-repo manifest (optional)
Commit a manifest at .fa/environment.yml; keys map onto project fields. Whole-manifest-wins: when present, its fields take priority over the project record. Keys: image, data, services, setup, env, test, verify, mcp, model_endpoint, gates (no preflight/budget/behavior fields).
image: fa-runtime-python
setup: pip install -r requirements.txt
test: pytest -q
verify: python scripts/smoke.py
data: [ /data/fixtures ]
services:
- name: db
image: postgres:16
env:
MARKET_DATA_OFFLINE: "1"MCP tool passthrough (mcp:)
Give the agent loop access to project-specific MCP tool servers by declaring them in the manifest. FA writes whatever the project declares into Claude Code's native MCP config — it never interprets what any tool does.
mcp:
servers:
# stdio server (command + optional args/env)
playwright:
command: npx
args: ["@playwright/mcp@latest"]
env:
DISPLAY: ":1"
# SSE server (url-based)
myserver:
url: https://mcp.example.com/sseFA passes --mcp-config .fa-mcp.json --strict-mcp-config to the CLI so only the declared servers load (no ambient/global MCP leakage). The config file is written into the workspace at run time and excluded from git (never committed). Declared servers are loaded uniformly across all three run types: standard implement, spec-kit pipeline, and PR revision (/revise).
Safety invariants:
- MCP servers run inside the existing sandbox only — FA writes a config file, it does NOT start any server on the host.
- Any secret an MCP server needs flows through the manifest's
envkey (project-scoped, no new global credential). --strict-mcp-configprevents any host-global MCP servers from leaking into the agent run.- Governed browsing (spec 028): when
engine=acpandpermission_policy=governed, MCP tool calls are routed through the ACP permission gate exactly like built-in tools — non-mutating kinds (read/search/think) are auto-allowed and mutating/network kinds (execute/edit/fetch/…) pause for approver review. See the "Governed browser tooling" section below for the full recipe.
Server definition shape:
- stdio (command-based):
command(string, required) + optionalargs(array of strings) + optionalenv(object of string values) - SSE (URL-based):
url(string, required) + optionalenv - Each server entry must have either
commandorurl; a missing or malformedmcpfield throwsManifestValidationError(fail-fast, same as other fields)
Model endpoint control (model_endpoint:) — data-perimeter posture
Route the agent's model calls to an org-controlled endpoint (AWS Bedrock, Google Vertex AI, or a private gateway) by declaring model_endpoint: in the manifest. When present, FA maps it to the env vars Claude Code already reads and injects them into the sandboxed run — the ANTHROPIC_API_KEY (or oauth credential) you supply separately continues to satisfy authentication; the declared field only controls where calls go.
# AWS Bedrock (IAM creds ride the secret env channel — not this file)
model_endpoint:
provider: bedrock
region: us-east-1 # optional; sets AWS_REGION
retention: zero # optional; recorded in provenance ('zero' | 'default')
# Google Vertex AI
model_endpoint:
provider: vertex
project_id: my-gcp-project # optional; sets ANTHROPIC_VERTEX_PROJECT_ID
region: us-central1 # optional; sets CLOUD_ML_REGION
# Private / org gateway
model_endpoint:
provider: proxy
base_url: https://gateway.internal/v1 # sets ANTHROPIC_BASE_URL
# Explicit default (no override — same as omitting the field)
model_endpoint:
provider: anthropicShape (strictly validated — unknown keys throw ManifestValidationError):
| Field | Type | Description |
|---|---|---|
provider | anthropic | bedrock | vertex | proxy | Required. Which endpoint family to use. |
base_url | string | URL override (ANTHROPIC_BASE_URL for proxy; ANTHROPIC_BEDROCK_BASE_URL for bedrock). |
region | string | AWS region (AWS_REGION) for bedrock; GCP region (CLOUD_ML_REGION) for vertex. |
project_id | string | GCP project (ANTHROPIC_VERTEX_PROJECT_ID) for vertex. |
retention | zero | default | Declared retention posture; recorded in the provenance artifact, not enforced by FA. |
What FA resolves per provider:
| Provider | Env vars set |
|---|---|
bedrock | CLAUDE_CODE_USE_BEDROCK=1 + optional AWS_REGION, ANTHROPIC_BEDROCK_BASE_URL |
vertex | CLAUDE_CODE_USE_VERTEX=1 + optional ANTHROPIC_VERTEX_PROJECT_ID, CLOUD_ML_REGION |
proxy | ANTHROPIC_BASE_URL=<base_url> |
anthropic | (none — uses default Anthropic API) |
Credential safety: only non-secret routing posture goes in the manifest. The actual credential (AWS keys, gateway token, GCP service account) must be supplied separately via the secret env channel (e.g. docker run -e AWS_ACCESS_KEY_ID=...) — never in the committed manifest or the provenance artifact.
Provenance: when model_endpoint: is declared, the provider, endpoint host, region, and retention posture are committed into the feature branch at .fa/provenance/<id>.md as a machine-generated audit trail (SECRETS REDACTED). See the Run Provenance section.
Declared gate commands (gates:) — composable merge governance
Declare an ordered list of shell commands FA runs inside the sandbox after implementation; each gate's exit code determines whether the merge proceeds.
gates:
- name: lint
command: npm run lint
# block defaults to true — a non-zero exit fails the run; no commit, no PR
- name: custom-policy-check
command: python scripts/check_policy.py
block: true # explicit: same as default — non-zero blocks
- name: advisory-scan
command: ./scan.sh # your command; FA never interprets what it does
block: false # advisory — non-zero recorded as 'warn', does not blockHow it works:
FA runs each declared gate command in order inside the run's sandbox Runtime (the same isolated environment the agent used). FA reads only the exit code and captures the output — it never interprets what the command does. A linter, a SAST/DAST scanner, a third-party AI-review CLI, or a custom compliance script are all identical to FA.
| Gate outcome | block value | Effect |
|---|---|---|
| Exit 0 | either | Gate passes; run continues. |
| Non-zero exit | true (default) | Blocking gate failed — feature transitions to failed; no commit, no push, no PR. Workspace preserved for inspection. |
| Non-zero exit | false | Advisory — result recorded as warn; run continues and PR is opened. |
Gate shape (strictly validated — malformed entries throw ManifestValidationError):
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Non-empty label used in logs, run-events, and the provenance artifact. |
command | string | yes | Non-empty shell command FA runs via bash -lc inside the sandbox. |
block | boolean | no | true (default) = blocking; false = advisory. |
Observability: every gate result is recorded as a gate_result run-event in the existing run-events ledger (visible in the dashboard's run-events timeline) and committed into the provenance artifact (.fa/provenance/<id>.md) so results are auditable without a new dashboard panel.
Tenant-scoped: gates come from the per-project committed manifest — no global registry. Gates inherit the run's environment (credentials, service env) and run inside the same sandbox that confined the agent.
No gates declared → strict no-op: a project with an absent or empty gates: list behaves byte-identically to today. No performance cost, no behavior change.
Dry-run before you pay: the provision-check
Verify your manifest stands up without implementing anything — clone → mount data → start services → run setup → run verify, reporting green/red per step (requires RUNTIME=docker). Short-circuits on first failure, always tears down.
# Kick off (project or admin key) → 202 while running
curl -X POST http://localhost:3100/api/projects/<id>/env/check -H "Authorization: Bearer fa_<key>"
# Poll report: 202 running, 200 done (JSON), 404 never run
curl http://localhost:3100/api/projects/<id>/env/check -H "Authorization: Bearer fa_<key>"
# Live log
curl http://localhost:3100/api/projects/<id>/env/check/logs -H "Authorization: Bearer fa_<key>"Scaffold a starter manifest
Ask FA to inspect the repo and generate a first-draft manifest (shallow-clone, reads file names + package.json JSON only, runs no repo code, emits a commented .fa/environment.yml; every guess marked [LOW CONFIDENCE]; data/verify left as placeholders).
curl -X POST http://localhost:3100/api/projects/<id>/env/scaffold -H "Authorization: Bearer fa_<key>"
curl http://localhost:3100/api/projects/<id>/env/scaffold -H "Authorization: Bearer fa_<key>" # { manifest, yaml, detections }Worked example: Python + Postgres + frozen data + a real pytest gate
The hermetic-backtest pattern — a real Postgres for tests, a frozen dataset mounted read-only, offline data, a blocking pytest gate, a cheap preflight, and a backtest as evidence:
curl -X POST http://localhost:3100/api/projects \
-H "Authorization: Bearer <admin_key>" -H "Content-Type: application/json" \
-d '{
"name": "trader-service",
"repo_url": "https://github.com/acme/trader.git",
"autonomy_mode": "po_approval", "po_email": "lead@acme.com",
"runtime_image": "fa-runtime-python",
"data_dirs": [ { "src": "/data/signal_cache", "dest": "signal_cache" } ],
"services": [ { "name": "db", "image": "postgres:16",
"env": { "POSTGRES_PASSWORD": "postgres", "POSTGRES_DB": "trader" },
"ready": { "type": "pg", "port": 5432, "timeoutMs": 30000 },
"expose": { "DATABASE_URL": "postgresql://postgres:postgres@db:5432/trader" } } ],
"env": { "MARKET_DATA_OFFLINE": "1" },
"setup_command": "pip install -r requirements.txt && alembic upgrade head",
"preflight_command": ".venv/bin/python -c \"import trader\"",
"test_command": ".venv/bin/python -m pytest -q",
"test_gate": "block",
"verify_command": ".venv/bin/python scripts/run_backtest.py --hermetic",
"max_budget_tokens": 3000000, "max_budget_seconds": 2400,
"agent_max_turns": 150, "code_discipline": "lite"
}'Then run the provision-check once. If it goes green through mount data → start db → run setup → run verify, your environment is proven — start submitting features with confidence.
Spec-Kit, Review, Pull Requests & Agent Permissions
FA implements features autonomously, but hands you the controls that matter: how a feature is specified, how its PR is reviewed, and what the agent may do mid-run.
Spec-Kit: spec-driven development
By default FA goes straight from title+description to implementation. Spec-Kit (GitHub Spec Kit) inserts a disciplined pipeline in front of the code: the agent writes a spec, resolves ambiguities with you, plans, and only then implements. Use it for large/ambiguous features or when you want an authoritative spec on the branch for reviewers.
The pipeline (per feature): /speckit.specify → /speckit.clarify (stops and asks you if unsure → clarification_needed; resumes where it paused) → /speckit.plan → /speckit.tasks → /speckit.analyze → /speckit.implement. Generated files land under .specify/specs/<slug>/.
Enrolling a project (statuses: disabled → enrolling → awaiting_merge → enabled, plus checking/failed):
# Bootstrap a fresh repo (opens a draft PR that installs spec-kit + a constitution)
curl -X POST http://localhost:3100/api/projects/PROJECT_ID/spec-kit/enable
# ... review + MERGE that PR on GitHub, then:
curl -X POST http://localhost:3100/api/projects/PROJECT_ID/spec-kit/mark-enabled
# Watch enrollment live
curl http://localhost:3100/api/projects/PROJECT_ID/spec-kit/logsThe bootstrap PR includes a constitution.md (test-first, small reviewable changes, follow conventions, type-safe, no silent failures) — the guardrail for every future feature. Edit it before merging if you like.
Adopt an already-configured repo (has .specify/ + .specify/memory/constitution.md): POST /api/projects/PROJECT_ID/spec-kit/check — promotes straight to enabled (no PR) if both files exist. Runs only from disabled/failed. Reset with POST .../spec-kit/disable.
Opt a feature in: set use_spec_kit: true on submit. If the project isn't enabled, it's silently coerced to false (no error, standard flow). Also ignored when you supply verbatim spec_content.
The Pull Request / Merge Request flow
When a feature reaches implemented, FA opens a draft PR or MR — a human always merges. FA implements and proposes, it does not merge for you.
FA supports three VCS providers out of the box and selects the right one automatically from the project's repo_url host:
| Repo host | Provider | Draft created | Token required |
|---|---|---|---|
github.com | GitHub | Draft PR | GITHUB_TOKEN |
gitlab.com | GitLab | Draft MR (title prefixed Draft:) | GITLAB_TOKEN |
GITLAB_HOST env value | GitLab (self-hosted) | Draft MR | GITLAB_TOKEN |
bitbucket.org | Bitbucket Cloud | PR (title prefixed Draft:) | BITBUCKET_TOKEN |
| anything else | GitHub (default) | Draft PR | GITHUB_TOKEN |
Bitbucket Cloud note: Bitbucket Cloud's REST API does not expose a native draft PR flag. FA creates a normal (open) PR and prefixes the title with
Draft:to signal intent — matching the GitLab approach. Merge or close it as usual; FA detects the merge via polling. Bitbucket Server / Data Center (the on-premises v1.0 API) is not supported.
The pr_url field holds whichever URL the provider returns (GitHub PR, GitLab MR, or Bitbucket PR), and every status-flow action, merge-detection poll, review-comment fetch, and revision works the same regardless of provider.
- Auto-merge detection: FA polls open PRs/MRs (throttled ~10 min, needs
GITHUB_TOKENorGITLAB_TOKEN); merging transitions the feature tomerged(terminal). - Won't-merge:
POST /:id/wont-mergemarkswont_merge(terminal); optional{reason}. - Recover a missing PR/MR:
POST /:id/create-pr(implemented, hasbranch_name, nopr_url) — surfaces the real API failure as502. Server reads tokens at startup; restart after rotating. - Review-comments indicator: the dashboard shows a
!on PRs/MRs with unaddressed review comments.
PR/MR revision: acting on review comments
Leave your feedback as normal GitHub PR review comments or GitLab MR notes (a review summary, inline line comments, or conversation comments), then:
curl -X POST http://localhost:3100/api/features/FEATURE_ID/revise \
-H "Authorization: Bearer fa_YOUR_PROJECT_KEY"The feature → revising; FA fetches the PR/MR feedback (summaries incl. CHANGES_REQUESTED, inline comments with file+line, conversation comments/notes), re-clones, applies changes, runs tests, and pushes to the same branch (updates the existing PR/MR, no new one) → back to implemented. Requires implemented + pr_url + branch_name. No comments / error → reverts to implemented, PR/MR intact.
The spec-conformance reviewer
FA reviews its own PR/MR against the spec before you look. Opt-in, off by default. Enable by setting reviewer: spec_conformance at the project or feature level.
What happens: once a feature reaches implemented (PR open) with reviewer: spec_conformance, FA automatically:
- Transitions the feature to
reviewing— visible in the dashboard with a blue badge. - Spawns a fresh, independent agent run that sees only three things: the authoritative spec (verbatim
spec_contentif present → generated doc atdoc_path→ the feature description), the full PR/MR diff, and the project constitution. The reviewer sees no implementer reasoning, no prior conversation. - Runs three lenses in a single independent pass:
- PRIMARY (spec-conformance): "did the diff satisfy every requirement in the spec?" This is the main gate.
- SECONDARY (constitution): constitution as a secondary guardrail — clear violations only, not style.
- TERTIARY (advisory action-risk): a generic, project-agnostic read over the diff for risk signals (see below). Advisory only — does not affect APPROVE/REQUEST_CHANGES.
- Posts a real VCS review: GitHub
APPROVEorREQUEST_CHANGES(naming the unmet requirement(s) and, where possible, the specific file and line in the diff). The review body also includes the advisory risk assessment section. - Records the verdict in the run-events ledger (visible in the feature's Run Events timeline in the dashboard). The verdict payload includes
decision,unmet_count,round,engine_id,reviewer_model,risk_level, andrisk_signalsfor auditability. - On APPROVE: records a
reviewer_finalevent and returns the feature toimplemented. You still merge. FA proposes; humans decide. - On REQUEST_CHANGES: drives the bounded auto-revise loop (see below).
Bounded auto-revise loop: FA doesn't just post a REQUEST_CHANGES and stop — it gates the merge AND drives correction automatically.
- On
REQUEST_CHANGES, FA transitions the feature torevising. The existing revise flow (the implementer) fetches the reviewer's posted comments from the PR, applies changes, pushes to the same branch (updating the existing PR), and returns the feature toimplemented. - FA then re-reviews the updated diff on the next poll tick. Each round's verdict and round number are recorded in the run-events timeline.
- The loop is bounded by
REVIEWER_MAX_ROUNDS(default 2). After 2REQUEST_CHANGESrounds with the deviation still unresolved, FA escalates to a human: it leaves the feature atimplemented, leaves the PR intact with the standingREQUEST_CHANGESreview, and appends a clear escalation note to the implementation log. - No-progress detection: if a revise round produces no improvement (the unmet-requirement count does not shrink relative to the prior round), FA escalates immediately — before burning the remaining rounds. This catches the case where the revise flow finds no review comments and returns unchanged.
- REVIEWER_MAX_ROUNDS=0 disables the auto-revise loop entirely (backward-compat): FA posts the verdict and returns to
implementedexactly as before.
Dashboard visibility: loop progress is observable through existing surfaces — no new panel needed:
- The
reviewingandrevisingstatus badges show live state during each round. - The Run Events timeline shows each
reviewer_verdictevent (per round, withroundandunmet_count) and the terminalreviewer_finalevent (withoutcome: approved | escalatedandreason). - Feature list badge: shows the latest reviewer verdict:
- Green
✅ spec: approved— the diff satisfied all requirements. - Amber
⚠️ changes requested — N unmet— the reviewer found N unmet requirement(s); details are in the GitHub review. - Yellow
ⓘ verdict unparseable— the reviewer ran but the output could not be parsed; check the Run Events timeline. - No badge — the feature has not yet been reviewed (reviewer off or run not yet triggered).
- Green
- Feature detail panel: a "Spec verdict" row appears below the Pull Request row, showing the decision, unmet count, reviewed-at timestamp, which model and engine produced the verdict (e.g. "by claude-opus-4-8 on openhands"), and a direct link to the PR review. A separate "Risk (advisory)" row shows the risk level badge (🟢 low / 🟡 medium / 🔴 high) and any detected signals.
- Escalation: visible as the feature resting at
implementedwith a standingREQUEST_CHANGESreview + escalation note in the implementation log +reviewer_final(escalated)event in the timeline.
Advisory risk assessment (action-risk lens):
The reviewer's third lens scans the diff for generic, project-agnostic risk signals — the same signals that matter for any codebase:
| Signal category | Examples |
|---|---|
| Auth / credentials / permissions | Touches auth checks, credential handling, or permission gates |
| Weakened safety checks | Removes tests, assertions, invariant guards |
| Broadened execution surface | Adds process exec (spawn/eval), network calls, or filesystem writes |
| Schema / data changes | DB migrations, destructive data operations |
| Secret-shaped literals | API keys, tokens, or passwords hardcoded in source |
| Large-blast-radius deletions | Significant removal of logic, APIs, or user-facing behaviour |
Risk level is low (no signals), medium (one or two mild signals, limited blast radius), or high (strong signals, broad blast radius, or any secret-shaped literal).
This assessment is advisory. A high-risk APPROVE is still an APPROVE. The risk level does not by itself block a merge or trigger a REQUEST_CHANGES in this increment — it informs the human reviewer. The risk section is visible in the PR review body (posted to the VCS) and in the "Risk (advisory)" row in the feature detail panel on the dashboard.
Idempotent: once a reviewer_final event is recorded for a feature (on APPROVE, or after escalation), FA never re-reviews that PR. Between auto-revise rounds, only non-terminal reviewer_verdict events exist, so the feature IS re-selected for re-review after each revise.
Error-safe: if the reviewer cannot post (e.g. the GitHub token lacks review permission, or the PR is from a fork), FA logs the reason and leaves the feature implemented with the PR intact. No crash, no lost feature.
Spec source priority chain:
spec_content(verbatim — byte-identical to what was submitted)doc_path(the generated spec committed on the branch)description(low-confidence fallback when no spec file exists)
Independent reviewer model and engine (FR-013 / FR-014)
By default, the reviewer runs on the same engine and model as the implementer. For an approval to be a genuinely independent evaluation, configure a different model and/or engine:
| Field | Where | Description |
|---|---|---|
reviewer_model | project or feature | The Claude model (or other engine's model) to use for the reviewer agent. Blank = implementer model. Example: claude-opus-4-8. |
reviewer_engine | project or feature | The engine backend id for the reviewer. Blank = implementer engine. Must match a configured FA_ENGINE_PROFILES entry or claude-code. |
Precedence: feature reviewer_model/reviewer_engine → project default → implementer's engine/model. When BOTH are unset, the reviewer behaves exactly as before (backward-compatible).
Provenance: every reviewer_verdict and reviewer_final ledger event carries engine_id and reviewer_model so the decision is auditable. The dashboard "Spec verdict" row shows which model/engine produced the verdict (e.g. "by claude-opus-4-8 on claude-code") so a human can see the review was independent.
Honesty note (NFR-003): when
reviewer_modelequals the implementer's model (or is unset), this is self-review — reliable for spec drift / missing requirements, but not a security or correctness guarantee. A distinctreviewer_modelnarrows the gap between self-review and independent evaluation; it does not guarantee correctness. TreatAPPROVEas "matches the spec," not "bug-free."
Engine Routing Policy — Declarative Role→Engine Mapping (Tier 0)
engine_routing_policy is a per-project (and per-feature) JSON field that maps each agent role to an engine profile id. It is the governed, inspectable way to configure which engine runs each part of a feature lifecycle — replacing ad-hoc per-role fields for cross-role routing.
Roles (closed set, cannot be extended):
| Role | When it runs |
|---|---|
author | Implements the feature (the main coding agent). |
reviewer | Spec-conformance review after implementation. |
fixer | Applies targeted fixes (e.g. after a failing test gate). |
Policy shape (JSON object on the project or feature):
{ "author": "<engine-id>", "reviewer": "<engine-id>", "fixer": "<engine-id>" }All keys are optional. Unknown engine ids are silently ignored (logged with a note, fall through to next step) — never a runtime error.
Resolution precedence for each role (first match wins):
- Feature per-role static field —
feature.engine(author) /feature.reviewer_engine(reviewer) - Feature
engine_routing_policy— the feature's own policy for that role - Project
engine_routing_policy— the project-level policy for that role - Project per-role static field —
project.engine(author) /project.reviewer_engine(reviewer) DEFAULT_ENGINE_ID(claude-code) — the built-in fallback
Setting the policy:
- Project-level (applies to all features):
PATCH /api/projects/:idwithengine_routing_policy: { "reviewer": "gemini-profile" }. In the dashboard, edit a project and find the "Engine Routing" section. - Feature-level override (overrides project policy for this run): include
engine_routing_policyinPOST /api/features.nullinherits from the project.
Example — author on claude-code (default), reviewer on a declared gemini-profile:
curl -X PATCH https://fa.example.com/api/projects/$PROJECT_ID \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"engine_routing_policy": {"reviewer": "gemini-profile"}}'Routing rationale — every run records routing_rationale on the feature, a compact JSON record explaining which engine was chosen for each role and why:
{ "author": {"engine": "claude-code", "source": "default"},
"reviewer": {"engine": "gemini-profile", "source": "project.routing_policy[reviewer]"},
"fixer": {"engine": "claude-code", "source": "default"} }This is visible in the dashboard feature detail ("Engines: author=claude-code · reviewer=gemini-profile (project policy)") and in GET /api/features/admin/:id/provenance → routing_rationale. A routing_decision run event is also emitted to the ledger for audit export.
Engine Routing — Tier-1: Automatic Classifier-Based Routing
Tier-1 adds an opt-in, automatic layer that classifies each feature's task shape via a short LLM call and picks an engine based on that shape — without requiring per-feature hand-tuning.
Why classifier routing? The headline benefit is not cost savings — it is correctness. Routing the reviewer role to a different engine than the author engine catches uncorrelated errors. A Gemini review of a Claude-authored patch flags what a Claude review is blind to. Tier-0 already lets you hand-declare a different reviewer engine; Tier-1 makes it automatic and shape-aware so it scales.
Tier-0 rules always take precedence over Tier-1. The classifier only fires when no explicit Tier-0 rule matched for a role.
Category enum (closed set, project-agnostic task shapes):
| Category | Meaning |
|---|---|
small-patch | Small, focused change — typo fix, one-liner, docs-only update |
large-integration | Large feature, new subsystem, architectural change, complex multi-file integration |
bugfix | Fixing a defect, crash, or broken functionality |
refactor | Code-quality improvement or restructuring with no behavior change |
other | Does not clearly fit the above (also used as the fail-closed default) |
Policy shape — per role (instead of a direct engine id string, use a classifier config object):
{
"reviewer": {
"classify": true,
"category": {
"large-integration": "gemini-profile",
"small-patch": "claude-code"
}
}
}classify: true— enables the classifier for this role. Without this key, the role uses Tier-0 only.category— maps each category name to a registered engine profile id. Omitted categories fall through to the next resolution step (Tier-0 fields or default).- A Tier-0 string value for the same role (e.g.
"author": "claude-code") takes precedence and the classifier is never called for that role.
Setting Tier-1 routing:
Project-level (all features):
curl -X PATCH https://fa.example.com/api/projects/$PROJECT_ID \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"engine_routing_policy": {
"reviewer": {
"classify": true,
"category": {
"large-integration": "gemini-profile",
"bugfix": "gemini-profile"
}
}
}
}'In the dashboard, edit a project → "Engine Routing" → check "Classify (Tier-1)" for the Reviewer role → add category→engine rules (one per line: large-integration: gemini-profile).
Feature-level override:
curl -X POST https://fa.example.com/api/features \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Rewrite auth module",
"description": "...",
"engine_routing_policy": {
"reviewer": {
"classify": true,
"category": { "large-integration": "gemini-profile" }
}
}
}'How it works:
- FA runs a single short headless LLM call to classify the feature by task shape.
- The classified category + a one-sentence rationale are stored in
routing_rationale(same column as Tier-0 routing decisions). resolveEngineForRolereads the stored category and looks up the engine from thecategorymap.- If no matching category rule exists, falls through to the next resolution step.
Observability: the dashboard feature detail shows the classified category and rationale:
Task category:
large-integration— Touches many components across the codebaseEngines: author=claude-code · reviewer=gemini-profile (classifier:large-integration)
This is also available in GET /api/features/admin/:id/provenance → routing_rationale.category.
Precedence summary (Tier-0 + Tier-1 combined):
- Feature per-role static field (
engine/reviewer_engine) - Feature
engine_routing_policy— direct string engine id (Tier-0) - Project
engine_routing_policy— direct string engine id (Tier-0) - Project per-role static field (
project.engine/project.reviewer_engine) - Tier-1 classifier — fires only when steps 1–4 all missed AND
classify:trueis set for the role DEFAULT_ENGINE_ID(claude-code)
Classifier is opt-in. Without classify: true in a role's policy, the resolver behaves exactly as Tier-0 with no LLM call overhead.
Declaring non-Claude engine profiles (spec 048 + spec 065)
FA is a neutral orchestrator. Any agent can be wired in as a named engine profile — no code changes, no vendor lock-in, no Anthropic credentials forwarded. Two transports are available: acp (the default, JSON-RPC over stdio) and headless (single-shot print mode via stdin/stdout).
Declaring profiles (FA_ENGINE_PROFILES)
Set FA_ENGINE_PROFILES to a JSON array of profile objects before starting FA:
export FA_ENGINE_PROFILES='[
{
"id": "gemini",
"cmd": "gemini-cli",
"args": ["--acp"],
"transport": "acp",
"authEnv": { "GOOGLE_API_KEY": "AIza..." }
},
{
"id": "codex-headless",
"cmd": "codex",
"args": ["exec", "--json"],
"transport": "headless",
"authEnv": { "OPENAI_API_KEY": "sk-proj-..." }
},
{
"id": "my-oss-agent",
"cmd": "my-agent",
"args": ["--print"],
"transport": "headless",
"authEnv": { "MY_AGENT_KEY": "..." }
}
]'| Field | Required | Description |
|---|---|---|
id | yes | Unique engine name; used as the engine field value on projects/features |
cmd | yes | Executable to spawn (path or name on $PATH) |
args | no | Extra arguments passed to cmd |
transport | no | 'acp' (default) or 'headless' — see below |
authEnv | no | Env vars injected into the agent subprocess as its credentials |
Malformed config (invalid JSON, missing id/cmd, wrong field types, unknown transport value) is detected at startup and FA exits with an actionable error message — no silent drops.
Choosing a transport
The transport field selects the drive-path for a declared profile:
| Transport | Drive-path | Governance granularity |
|---|---|---|
acp (default) | JSON-RPC 2.0 over stdio (Agent Client Protocol). FA negotiates a session, sends a prompt, and maps streaming notifications to run events. | Per-tool: FA can intercept session/request_permission mid-run and apply the project's permission_policy (allow_all, deny_all, ask, governed). |
headless | Single-shot print/exec via stdin→stdout. FA writes the prompt to stdin, closes it, and captures stdout as the result text. | Run-level only: approve-before-run (autonomy mode) + sandbox confinement + gate-the-PR-after (spec reviewer). No per-tool hook exists. |
When to use acp:
- The agent natively speaks ACP (JSON-RPC 2.0 over stdio).
- You need mid-run, per-tool governance (
permission_policy: governedorask). - You want streaming run events (text, tool-use, tool-result).
When to use headless:
- The agent speaks a print/exec CLI interface (e.g.
codex exec --json, a local Aider instance pointed at a self-hosted vLLM, an OpenAI-compatible OSS agent). - You are operating a data-perimeter / sovereignty setup where no external vendor API is acceptable for the implementation agent — the headless transport drives any declared command inside the sandbox without coupling to any protocol or vendor.
- Run-level governance (approvals + sandbox + PR review) is sufficient for your threat model.
The governance difference is real and intentional. The headless transport cannot intercept individual tool calls — it has no access to the agent's internal loop. If per-tool approval is required (e.g. you need to approve every bash command before it runs), use the acp transport with permission_policy: ask or governed. Do not rely on headless for per-tool control; it does not provide it.
Default: when
transportis absent, the profile defaults to'acp'— byte-identical to the behavior before this field was introduced.
Auth isolation (non-negotiable)
Each engine's subprocess receives only:
- The sanitized host environment (all parent env vars except Anthropic creds)
- Its declared
authEnvkeys
Anthropic credentials (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN) are explicitly withheld from every non-Claude engine subprocess, on both acp and headless transports. The Claude OAuth ~/.claude mount is also not injected. Each engine authenticates solely via its own declared authEnv.
Discovering registered engines
After startup, declared profiles appear in GET /api/engines alongside the built-in claude-code and acp backends:
curl http://localhost:3100/api/engines
# → { "engines": ["claude-code", "acp", "gemini", "codex-headless"], "default": "claude-code" }Selecting a non-Claude engine for a project
Pass the profile id as the engine field when creating or updating a project (admin-only field):
curl -X PATCH http://localhost:3100/api/projects/$PROJECT_ID \
-H 'Content-Type: application/json' \
-d '{ "engine": "codex-headless" }'All features submitted to that project use the declared engine unless overridden per-feature. GET /api/engines lists valid ids; an unknown id returns 400.
Operator validation
Because vendor agents are external, FA's CI gate tests with in-repo mock agents (no live vendor creds required). Before deploying a vendor profile in production, follow the Human Live-Run Validation Playbook in docs/design/engine-adapters.md to confirm end-to-end behaviour with real credentials: declare the profile → create a test project → submit a minimal feature → verify the PR appears on your repo.
Agent permission controls (ACP)
For runs on the ACP engine, FA can govern what the agent does mid-run. Controlled by permission_policy (per project default; per feature override; null inherits):
| Policy | Behavior |
|---|---|
allow_all | Auto-allow every tool call (headless). Default. |
deny_all | Auto-deny every tool call. |
ask | Suspend on every permission request → route to an approver. Safest, but a real run stalls constantly (demos/high-stakes). |
governed | Auto-allow non-mutating kinds (read, search, think); route everything that changes state (writes, deletes, exec, network, unknown) to the approver. |
governed is the practical governed-autonomy setting. Unknown/unclassifiable kinds are treated conservatively as "ask" — FA never silently allows what it can't classify.
When the agent hits a gate (under ask, or governed for a mutating call): FA registers a pending gate with a random permissionId, notifies the approver (with a resolve link), and waits — with a timeout (FA_PERMISSION_TIMEOUT_MS, default 30 min) that fails safe to deny so a run can't hang forever.
The approval surface (tenant-scoped):
# List what's waiting (permissionId, featureId, toolName, createdAt, expiresAt — raw args never exposed)
curl http://localhost:3100/api/features/permissions/pending -H "Authorization: Bearer fa_YOUR_PROJECT_KEY"
# Allow or deny a gate
curl -X POST http://localhost:3100/api/features/FEATURE_ID/permissions/PERMISSION_ID/resolve \
-H "Authorization: Bearer fa_YOUR_PROJECT_KEY" -H "Content-Type: application/json" \
-d '{"decision": "allow"}'decision is allow/deny; unknown/expired permissionId → 404; you can only resolve your own project's gates. Admins: GET /api/features/admin/permissions/pending. The dashboard mirrors this as an always-visible Pending approvals panel with Allow/Deny buttons.
Governed browser tooling
FA's ACP permission gate composes directly with the MCP tool passthrough (declared in .fa/environment.yml) to give you governed headless browsing: the agent can navigate, read page content, and take screenshots autonomously, while every click, form fill, login, or network-egress action is paused for your approval.
The governance guarantee (the ACP ToolKind table):
| ACP ToolKind | Examples | Verdict |
|---|---|---|
read | navigate to URL, get page text, screenshot | auto-allow |
search | query, grep, discovery scan | auto-allow |
think | internal reasoning step | auto-allow |
execute | click, form submit, run JS | ask approver |
edit | fill form field, mutate DOM | ask approver |
fetch | HTTP request, download file | ask approver |
delete | remove element or file | ask approver |
move | rename or relocate | ask approver |
switch_mode | agent mode change | ask approver |
other | uncategorised by tool author | ask approver |
| (absent) | kind field missing | ask approver (fail-closed) |
FA classifies every MCP tool call purely by the declared kind field — it never inspects the tool name. A Playwright navigate call that declares kind=read is indistinguishable from a local file read; a click that declares kind=execute gates identically to a shell command.
Enablement recipe (three declarative seams, no code changes):
Step 1 — declare the Playwright MCP server in .fa/environment.yml:
mcp:
servers:
playwright:
command: npx
args: ["@playwright/mcp@latest"]
env:
DISPLAY: ":1" # only needed for headed mode; omit for headlessStep 2 — build and select the browser-capable runtime image:
FA ships a curated fa-runtime-browser image (in containers/fa-runtime-browser/) that bundles Chromium + its OS dependencies alongside git and the Claude Code CLI. Build it once on the FA host:
docker build -t fa-runtime-browser:latest containers/fa-runtime-browserThen select it for the project (or per feature) via the existing declarative seam — no code changes:
# Set it as the default image for the project
curl -X PATCH http://localhost:3100/api/projects/PROJECT_ID \
-H "Authorization: Bearer fa_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"runtime_image": "fa-runtime-browser:latest"}'
# Or pass it for a single feature at submit time
curl -X POST http://localhost:3100/api/features \
-H "Authorization: Bearer fa_PROJECT_KEY" -H "Content-Type: application/json" \
-d '{"title": "...", "description": "...", "runtime_image": "fa-runtime-browser:latest"}'Note: do not use auto-detection — select the image explicitly so FA never needs to know what the image contains. The image is purely a substrate; governance is enforced by
engine=acp+permission_policy=governed(Step 3), not by the image itself.
Step 2b — add a headless UI-render assertion to verify_command with verify_gate: block (the wedge):
A feature that passes all unit tests can still render a blank screen. The verify_command runs after the agent's test suite inside the same sandbox and is where you catch that gap. Set verify_gate: block to make this an enforced gate — FA will not open a PR unless the render check passes.
Example verify_command that renders the built app and fails if a key element is absent:
npx playwright test --grep "renders without blank screen"or inline with the @playwright/mcp server already on PATH inside the container:
node -e "
const { chromium } = require('playwright');
(async () => {
const b = await chromium.launch({ headless: true });
const p = await b.newPage();
await p.goto('http://localhost:3000');
const h = await p.textContent('h1');
if (!h || !h.trim()) { console.error('blank render'); process.exit(1); }
await b.close();
})();
"Set this on the project so every feature is gated:
curl -X PATCH http://localhost:3100/api/projects/PROJECT_ID \
-H "Authorization: Bearer fa_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{
"verify_command": "npx playwright test --grep \"renders without blank screen\"",
"verify_gate": "block"
}'This is a zero-code-change CI gate: the image provides Chromium, the verify_command asserts the render, verify_gate: block ensures FA refuses to open a PR if the render fails — turning advisory evidence into an enforced merge-blocking gate.
Step 3 — set engine and permission policy:
# On the project (default for all its features)
curl -X PATCH http://localhost:3100/api/projects/PROJECT_ID \
-H "Authorization: Bearer fa_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"engine": "acp", "permission_policy": "governed"}'
# Or per-feature override at submit time
curl -X POST http://localhost:3100/api/features \
-H "Authorization: Bearer fa_PROJECT_KEY" -H "Content-Type: application/json" \
-d '{"title": "...", "description": "...", "permission_policy": "governed"}'How approvals surface: when the agent requests a mutating browser action (click, login, download, …) FA registers a pending gate and notifies your approver through all configured notification channels. The approver sees the gate in the dashboard Pending approvals panel or via the API (see above) and clicks Allow or Deny. A 30-minute timeout (configurable via FA_PERMISSION_TIMEOUT_MS) fails safe to deny — the run cannot hang forever.
Spec 028 status: governed browsing is delivered end-to-end across two increments. Increment 1 (#85) pinned the governance contract (MCP passthrough + ACP permission gate, specs 020/021). Increment 2a ships the
fa-runtime-browserimage and this recipe, closing the last substrate gap. First-class UI-render-verification field wiring (increment 2b) remains open.
Notifications, Webhooks & Observability
Four ways to stay on top of a run: push notifications (chat/email), webhooks (machine-to-machine), the web dashboard, and the reporting/observability API.
1. Notification Channels
Configured per project (notification_channels list + master notifications_enabled). Each channel's notify array decides which roles it serves: "po" (product owner/approver), "submitter", or "all". FA picks the target role from the new status:
| Status | Notified role(s) |
|---|---|
awaiting_approval | po (includes the cost estimate) |
clarification_needed | po if po_approval mode, else submitter |
in_progress | submitter |
implemented | po + submitter (branch + PR link) |
failed | po + submitter |
merged / wont_merge | outcome notification (PR link on merge) |
Other statuses don't auto-notify; force one with POST /api/features/:id/notify. Notifications are best-effort (a failing channel is logged, never blocks the run).
Channel config shapes:
{ "type": "telegram", "bot_token": "123456:ABC...", "chat_id": "-1001234567890", "notify": ["all"] }
{ "type": "slack", "webhook_url": "https://hooks.slack.com/services/T00/B00/XXXX", "notify": ["po","submitter"] }
{ "type": "discord", "webhook_url": "https://discord.com/api/webhooks/123/abc", "notify": ["all"] }
{ "type": "email", "smtp_host": "smtp.example.com", "smtp_port": 587,
"smtp_user": "notify@example.com", "smtp_pass": "app-password", "from": "Feature Agent <notify@example.com>",
"po_address": "owner@example.com", "submitter_address": "dev@example.com", "notify": ["all"] }
{ "type": "whatsapp", "provider": "twilio", "account_sid": "ACxxxx", "auth_token": "...",
"from_number": "whatsapp:+14155238886", "po_number": "whatsapp:+1555...", "notify": ["po"] }Email: po_address gets po messages, submitter_address gets submitter, all → both; missing smtp_host → skipped; port 465 implies TLS. WhatsApp provider: "meta" uses access_token + phone_number_id instead of Twilio creds.
curl -X PATCH http://localhost:3100/api/projects/PROJECT_ID \
-H "Authorization: Bearer fa_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{ "notifications_enabled": true, "notification_channels": [
{ "type": "telegram", "bot_token": "...", "chat_id": "...", "notify": ["all"] },
{ "type": "email", "smtp_host": "smtp.example.com", "po_address": "owner@example.com", "notify": ["po"] } ] }'2. Webhooks (Callbacks)
FA POSTs a JSON callback on every status change. Target URL: feature.callback_url ?? project.callback_url (per-feature override wins; no URL → no webhook). Up to 3 retries with exponential backoff (1s/2s/4s), 10s timeout; 2xx = success.
Payload (event: "feature.status_changed"): feature_id, project_id, title, old_status, new_status, branch_name, pr_url, timestamp. Headers: X-FeatureAgent-Event, X-FeatureAgent-Signature: sha256=<hex>.
Verify the signature — HMAC-SHA256 of the exact raw body, keyed with the project API key (even for a per-feature URL):
const crypto = require('crypto');
function verify(rawBody, sigHeader, projectApiKey) {
const expected = 'sha256=' + crypto.createHmac('sha256', projectApiKey).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected));
}Compute over the raw bytes received, not a re-serialized copy.
3. The Web Dashboard
Served at /. Log in with an admin or approver key (validated via /api/users/me; used as the Bearer token for all calls). It shows: the feature list + status, the held-for-merge queue (implemented features with an open PR awaiting a human, ! on ones with review comments), the Cost & Budget tab (per-feature/project cost/tokens, estimate-vs-actual, burn-down), the run-event timeline, live logs (incl. during revising), project config editing, and action buttons (approve, answer clarifications, cancel, retry/rerun, won't-merge, create-PR, address-review-comments). A Pending approvals panel handles ACP gates.
Setting spend caps from the dashboard (no curl required):
- Project config form → Wall-clock budget (seconds) and Token budget (tokens) fields set
max_budget_secondsandmax_budget_tokensfor all features in the project. Leave blank to inherit the server default (FA_MAX_BUDGET_SECONDS/FA_MAX_BUDGET_TOKENS). - Submit feature form → Wall-clock budget and Token budget optional override fields (
sf-max-budget-seconds/sf-max-budget-tokens) set a per-feature cap that overrides the project value for that run only. Leave blank to inherit the project default (then server default). - Feature detail view → Token cap row shows the effective cap in effect — the feature-level override if set, otherwise the project cap (labelled "inherited") — with live usage (
N used / cap) color-coded: green → healthy, amber → within 10 % of cap, red → over cap. The row is omitted when no cap is in effect. - Auto-refresh while running: when a feature detail panel is open and the feature is in an active status (
analyzing,queued,in_progress, orrevising), the panel refreshes automatically every ~10 s — advancing the run-event timeline, status badge, and duration in place without closing the panel or stealing scroll. Refresh stops automatically once the feature reaches a terminal or paused status (implemented,merged,failed,cancelled, etc.).
4. Cost & Budget Observability
Each completed run records total_cost_usd, total_input_tokens, total_output_tokens, duration_ms.
- Pre-run estimate (po_approval): at creation, FA estimates cost from the project's history (median of past runs, once ≥
FA_ESTIMATE_MIN_SAMPLES, default 3) →estimated_cost_usd+ min/max/samples, included in the approval notification. Too little history → "Insufficient history to estimate cost." - Budget cap + live burn-down:
max_budget_tokens/max_budget_seconds(per project + per feature; fall back toFA_MAX_BUDGET_*, uncapped if unset).tokens_usedupdates live; crossing the cap hard-stops the run and the workspace is preserved for inspection (same as a failure). Must be> 0when set. Both caps are configurable from the dashboard without curl — see §3 above. Blank = inherit project default, then server default (FA_MAX_BUDGET_TOKENS/FA_MAX_BUDGET_SECONDS). - Cost-summary:
GET /api/features/cost-summary(project) →run_count, totals, per-feature line items. Admin cross-project:GET /api/features/admin/cost-summary(grouped, withproject_name).
5. Project Reporting
5.1 Governance report (inc 1)
GET /api/features/report (project key) → governance report: totals (run_count, cost, tokens), status_counts, merge_rate, duration_ms + cost percentiles (avg/p50/p95), estimate_vs_actual (estimated vs actual total + delta), and per-feature features. Add ?format=csv for a spreadsheet download (columns: id,title,status,total_cost_usd,total_input_tokens,total_output_tokens,duration_ms,estimated_cost_usd). Admin cross-project rollup: GET /api/features/admin/report (one report object per project; CSV prepends a project_id column).
curl "http://localhost:3100/api/features/report?format=csv" \
-H "Authorization: Bearer fa_PROJECT_KEY" -o project-report.csv5.2 Time-series (inc 2)
GET /api/features/report/timeseries (project key) → spend + run trend over time.
Query parameters:
| Param | Values | Default | Description |
|---|---|---|---|
bucket | day, week | day | Bucketing granularity. Unknown values fall back to day. |
format | csv | JSON | Return text/csv instead of JSON. |
Response (JSON): array of { bucket, run_count, total_cost_usd, total_input_tokens, total_output_tokens } sorted ascending by bucket. bucket is a YYYY-MM-DD date string — the day itself for day, or the Monday of that week for week. Sparse: only buckets with at least one feature appear (no zero-filled rows). Features with no recorded cost contribute 0 to cost/token totals but are counted in run_count.
CSV columns: bucket,run_count,total_cost_usd,total_input_tokens,total_output_tokens
# Daily trend (JSON)
curl "http://localhost:3100/api/features/report/timeseries?bucket=day" \
-H "Authorization: Bearer fa_PROJECT_KEY"
# Weekly trend CSV download
curl "http://localhost:3100/api/features/report/timeseries?bucket=week&format=csv" \
-H "Authorization: Bearer fa_PROJECT_KEY" -o timeseries.csvDashboard: The Reports tab shows a day/week toggle with a table of buckets and an inline SVG bar chart for spend. A "Download CSV" button is available. An empty-state message ("No runs yet for this period") is shown when there are no features.
5.3 Portfolio rollup (inc 2, admin-only)
GET /api/features/admin/report/rollup (admin/user key) → fleet-wide aggregation across all projects.
Response (JSON):
{
"project_count": 3,
"run_count": 42,
"total_cost_usd": 12.34,
"total_input_tokens": 500000,
"total_output_tokens": 200000,
"status_counts": { "merged": 30, "implemented": 5, "failed": 2, "pending": 5 },
"merge_rate": 0.857,
"projects": [
{ "project_id": "...", "project_name": "my-app", "run_count": 20, "total_cost_usd": 8.00 }
]
}projects[] is sorted by total_cost_usd descending (highest spender first). merge_rate uses the same denominator as the per-project report: merged / (merged + wont_merge + failed + cancelled + implemented). Zero projects → zeroed totals, empty projects[].
CSV columns (?format=csv): project_id,project_name,run_count,total_cost_usd (per-project breakdown, sorted by cost descending).
# Fleet rollup JSON
curl "http://localhost:3100/api/features/admin/report/rollup" \
-H "Authorization: Bearer fa_ADMIN_OR_USER_KEY"
# Per-project cost breakdown CSV
curl "http://localhost:3100/api/features/admin/report/rollup?format=csv" \
-H "Authorization: Bearer fa_ADMIN_OR_USER_KEY" -o portfolio.csvDashboard: In admin mode, the Reports tab shows a "Fleet Portfolio Rollup" card above the per-project report cards. It shows fleet totals (project count, total runs, total spend, merge rate), a fleet-wide status breakdown, and a per-project cost table with inline SVG bars. A "Download CSV" button exports the per-project breakdown.
6. Run Events & Replay
A durable, append-only run-event ledger survives restarts and is auditable independent of current status. Events are tenant-scoped (each event carries the project_id), ordered by seq, and returned as-is by the events endpoint.
Event types
type | When recorded | Key payload fields |
|---|---|---|
status_change | Every lifecycle transition | from, to, optional reason |
tool_call | Each agent tool invocation during an implementation run | tool (tool name), summary (tool name + ≤200-char single-line input preview), turn (agent turn number) |
subagent_spawn | Each Task tool call (agent spawning a subagent) | description, subagent_type |
tool_call and subagent_spawn events are recorded live as the agent runs. Payload sizes are kept compact — tool_call summaries are truncated to 200 characters and never include full tool-result bodies — so a long run does not bloat the ledger.
API endpoints
GET /api/features/:id/events(project key) → ordered events (seq,type,payload,created_at).GET /api/features/:id/replay(project key) → a projection: statustimeline(each status withenteredAt/durationMs),transitions,recoveries,retries,terminal.404if no events yet.
Admin variants: /api/features/admin/:id/events, /api/features/admin/:id/replay (any project).
7. Run Monitor (dashboard)
The dashboard feature-detail view includes a live run monitor that auto-refreshes the event ledger while a feature is in_progress or revising. It shows:
- Status transitions — each
status_changeevent displayed withfrom → toarrows. - Live tool-call activity — each
tool_callevent displays the tool name, truncated input preview, and turn number. Events appear in real time as the agent works. - Subagent spawns — each
subagent_spawnevent displays with a↳ agentbadge, the subagent type, and the task description, visually distinguished from regular tool calls.
No new polling mechanism is used — the monitor reuses the existing GET /api/features/admin/:id/events fetch that already auto-refreshes every few seconds. Events degrade gracefully: missing payload fields are silently skipped.
8. Audit artifact
The full event ledger — status transitions plus live tool-call/subagent activity — constitutes a durable governance record of what the agent did and in what order. It persists after the run completes and can be read via the API independent of the current feature status.
Inbound Triggers (GitHub, GitLab, Linear, Sentry & Slack)
FA's inbound trigger seam lets external systems push work into FA by posting signed webhooks. Supported providers: GitHub issues, GitLab issues, Linear issues, Sentry issue alerts, and Slack Workflow Builder — a new issue, error alert, or Slack workflow step automatically becomes an FA feature that flows through your project's declared autonomy mode.
Funnel-first (the differentiator): the agent does NOT run immediately. A
po_approvalproject requires a human to approve the resulting feature before any agent spend happens.auto_safeprojects still run the analysis gate. Onlyfull_autoprojects go straight to implementation. This holds for every trigger provider.
Wiring an inbound trigger to a project
Step 1 — Enable the feature server-side. Set FA_INBOUND_TRIGGERS_ENABLED=true in your environment and restart FA. Without this flag all ingest routes return 404.
Step 2 — Find your webhook URL in the dashboard. Open the dashboard, find your project in the project table, and click Triggers. A modal opens showing:
- An enabled/disabled indicator.
- All registered providers with their full absolute webhook URLs — click Copy to copy any URL.
- A reminder: Sign the webhook with this project's API key (set the provider's webhook secret to your project API key).
Alternatively, query the discovery endpoint directly:
curl http://localhost:3100/api/projects/YOUR_PROJECT_ID/triggers \
-H "Authorization: Bearer fa_YOUR_PROJECT_API_KEY"Step 3 — Paste the URL + secret into the provider. The signing secret for every provider is the same: your project API key (fa_...). Go to the provider's webhook settings, paste the URL from step 2, and set the secret/token field to your project API key. See the per-provider sections below for the exact settings fields.
Step 4 — Send a test event. Most providers have a "Test webhook" button. FA will respond 201 (feature created) or 204 (event ignored), confirming the integration is wired correctly.
Endpoint
POST /api/projects/:projectId/triggers/:provider:provider = github, gitlab, linear, sentry, or slack. :projectId is the FA project ID (not a repo name).
No Authorization header is used. Authentication is via a webhook secret (see provider sections below).
Configuration
| Env var | Default | Meaning |
|---|---|---|
FA_INBOUND_TRIGGERS_ENABLED | true | Set to false to disable all inbound trigger endpoints (returns 404) |
Find your project's API key (used as the shared secret for all providers):
curl http://localhost:3100/api/projects/YOUR_PROJECT_ID \
-H "Authorization: Bearer fa_ADMIN_KEY" | jq .api_keyGitHub Issues
Setting Up a GitHub Webhook
- Go to your GitHub repo → Settings → Webhooks → Add webhook.
- Payload URL:
https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/github - Content type:
application/json - Secret: your project's FA API key (
fa_...) — this is the HMAC signing secret. - Which events? Choose "Let me select individual events" → tick Issues only.
- Click Add webhook.
FA verifies X-Hub-Signature-256 (HMAC-SHA256 of the exact request body, keyed with the project API key) using a timing-safe compare. Any request that fails verification returns 401 and no feature is created.
What Happens (GitHub)
| Scenario | Outcome |
|---|---|
issues event, action: opened | Feature created in the project's autonomy funnel |
Any other event or action (labeled, closed, push, etc.) | 204 No Content — silently ignored |
Invalid or missing X-Hub-Signature-256 | 401 — nothing created |
Unknown :projectId | 404 |
Duplicate delivery (same X-GitHub-Delivery header) | 200 {"ignored":true} — exactly one feature (best-effort in-memory dedup) |
Feature Created (GitHub)
- Title: the GitHub issue title
- Description: the issue body, with a provenance line appended:
_Opened via GitHub issue [owner/repo#N](url)_ - Status: follows the project's
autonomy_mode(awaiting_approval/analyzing/queued) - The feature appears in the dashboard and in the normal FA lifecycle
Example (manual curl)
# Compute the HMAC-SHA256 signature (GitHub-compatible)
PAYLOAD='{"action":"opened","issue":{"id":1,"number":42,"title":"Add dark mode","body":"Users want a dark mode toggle.","html_url":"https://github.com/org/repo/issues/42"},"repository":{"full_name":"org/repo"}}'
SECRET="fa_YOUR_PROJECT_API_KEY"
SIG="sha256=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST "http://localhost:3100/api/projects/YOUR_PROJECT_ID/triggers/github" \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: issues" \
-H "X-Hub-Signature-256: $SIG" \
-H "X-GitHub-Delivery: test-delivery-001" \
-d "$PAYLOAD"GitLab Issues
GitLab uses a plaintext Secret Token (not HMAC) sent in the X-Gitlab-Token header. FA verifies it via constant-time compare against the project's FA API key. This is a different verification scheme than GitHub but equally secure — GitLab controls webhook delivery; you control the token.
Setting Up a GitLab Webhook
- Go to your GitLab project → Settings → Webhooks → Add new webhook.
- URL:
https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/gitlab - Secret token: your project's FA API key (
fa_...). - Trigger: tick Issues events only. Leave all other triggers off.
- Click Add webhook.
FA checks X-Gitlab-Token (plaintext, constant-time compare). Any request with a missing or mismatched token returns 401.
What Happens (GitLab)
| Scenario | Outcome |
|---|---|
Issue Hook, object_attributes.action === 'open' | Feature created in the project's autonomy funnel |
Issue Hook with action: update, close, or reopen | 204 No Content — silently ignored |
Any other event (Push Hook, Merge Request Hook, etc.) | 204 No Content — silently ignored |
Invalid or missing X-Gitlab-Token | 401 — nothing created |
Unknown :projectId | 404 |
Duplicate delivery (same X-Gitlab-Event-UUID header) | 200 {"ignored":true} — exactly one feature (best-effort in-memory dedup) |
Feature Created (GitLab)
- Title: the GitLab issue title (
object_attributes.title) - Description: the issue description (
object_attributes.description), with a provenance line appended:_Opened via GitLab issue [group/project#N](url)_ - Status: follows the project's
autonomy_mode(awaiting_approval/analyzing/queued) — a GitLab issue does NOT skip approval; it enters the funnel exactly like any other feature submission. - The feature appears in the dashboard and participates in the normal FA lifecycle (clarification gates, spec-kit pipeline, PR creation, notifications, etc.) with no special handling.
Example (manual curl)
curl -X POST "http://localhost:3100/api/projects/YOUR_PROJECT_ID/triggers/gitlab" \
-H "Content-Type: application/json" \
-H "X-Gitlab-Event: Issue Hook" \
-H "X-Gitlab-Token: fa_YOUR_PROJECT_API_KEY" \
-H "X-Gitlab-Event-UUID: test-uuid-001" \
-d '{"object_kind":"issue","project":{"path_with_namespace":"group/repo"},"object_attributes":{"id":5001,"iid":42,"title":"Add dark mode","description":"Users want a dark mode toggle.","action":"open","url":"https://gitlab.com/group/repo/-/issues/42"}}'Linear Issues
Linear uses HMAC-SHA256 signing: the webhook secret is used to compute a hex digest of the raw request body, which Linear sends in the linear-signature header (a bare hex string — no sha256= prefix, unlike GitHub). FA verifies it using a timing-safe compare against the project's FA API key.
Setting Up a Linear Webhook
- In Linear, go to Settings → API → Webhooks → Create webhook (workspace-level) or Team Settings → Webhooks.
- URL:
https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/linear - Signing secret: your project's FA API key (
fa_...). - Data change events: tick Issues only. Leave all other types off.
- Click Create webhook.
FA verifies linear-signature (HMAC-SHA256 hex digest of the exact request body, keyed with the project API key). Any request with a missing, invalid, or tampered signature returns 401 and no feature is created.
What Happens (Linear)
| Scenario | Outcome |
|---|---|
type: "Issue", action: "create" | Feature created in the project's autonomy funnel |
type: "Issue" with action: "update", "remove", etc. | 204 No Content — silently ignored |
type: "Comment" or any other type | 204 No Content — silently ignored |
Invalid or missing linear-signature | 401 — nothing created |
Unknown :projectId | 404 |
Redelivery of the same issue (same data.id) | 200 {"ignored":true} — exactly one feature (best-effort in-memory dedup on linear:<issueId>) |
Feature Created (Linear)
- Title:
data.titlefrom the webhook payload - Description:
data.description, with a provenance line appended:_Opened via Linear issue [ENG-123](url)_(usingdata.identifieranddata.url) - Status: follows the project's
autonomy_mode(awaiting_approval/analyzing/queued) — apo_approvalproject still requires human approval before any agent spend. Linear does NOT bypass the funnel. - The feature appears in the dashboard and participates in the normal FA lifecycle.
Example (manual curl)
# Compute the HMAC-SHA256 signature (Linear-compatible — bare hex, no prefix)
PAYLOAD='{"type":"Issue","action":"create","data":{"id":"abc-123","identifier":"ENG-42","title":"Add dark mode","description":"Users want a dark mode toggle.","url":"https://linear.app/myteam/issue/ENG-42"}}'
SECRET="fa_YOUR_PROJECT_API_KEY"
SIG="$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST "http://localhost:3100/api/projects/YOUR_PROJECT_ID/triggers/linear" \
-H "Content-Type: application/json" \
-H "linear-signature: $SIG" \
-d "$PAYLOAD"Sentry Issues
Sentry uses HMAC-SHA256 signing: FA computes a hex digest of the raw request body, keyed by the project's FA API key, and compares it (timing-safe) against the Sentry-Hook-Signature header (a bare hex string — the same scheme as Linear). This is the P4 differentiator: Sentry's own alert fires an agent straight at the error; FA's trigger opens a conversation with the requester, governed by the project's declared autonomy mode.
Setting Up a Sentry Webhook
- In Sentry, go to Settings → Integrations → WebHooks (or Project Settings → Integrations → WebHooks).
- Payload URL:
https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/sentry - In the webhook configuration, set the signing secret to your project's FA API key (
fa_...). Sentry will use this to compute theSentry-Hook-SignatureHMAC. - Events: select Issue events only. Leave all other event types off.
- Save.
FA verifies Sentry-Hook-Signature (HMAC-SHA256 hex digest of the exact request body, keyed with the project API key, using a timing-safe compare). Any request with a missing, invalid, or tampered signature returns 401 and no feature is created.
What Happens (Sentry)
| Scenario | Outcome |
|---|---|
Sentry-Hook-Resource: issue, action: "created" | Feature created in the project's autonomy funnel |
Sentry-Hook-Resource: issue with action: "resolved", "assigned", etc. | 204 No Content — silently ignored |
Sentry-Hook-Resource: error or any other resource | 204 No Content — silently ignored |
Invalid or missing Sentry-Hook-Signature | 401 — nothing created |
Missing Sentry-Hook-Resource header | 204 No Content — silently ignored |
| Empty or whitespace-only issue title | 204 No Content — silently ignored |
Unknown :projectId | 404 |
Redelivery of the same issue (same data.issue.id) | 200 {"ignored":true} — exactly one feature (best-effort in-memory dedup on sentry:<issueId>) |
Feature Created (Sentry)
- Title:
data.issue.titlefrom the webhook payload (trimmed) - Description:
data.issue.culprit(ordata.issue.metadata.valueif culprit is absent), followed by a provenance line:_Opened via Sentry issue [MYAPP-1A2](permalink)_(usingdata.issue.shortId+data.issue.permalink; degrades gracefully if either is absent) - Status: follows the project's
autonomy_mode(awaiting_approval/analyzing/queued) — a Sentry alert does NOT skip approval or bypass the funnel. Apo_approvalproject still requires a human to approve before any agent spend. - The feature appears in the dashboard (the provenance line
_Opened via Sentry issue …_in the description identifies its origin) and participates in the normal FA lifecycle — clarification gates, spec-kit pipeline, PR creation, notifications, etc. No dedicated dashboard view is needed.
Example (manual curl)
# Compute the HMAC-SHA256 signature (Sentry-compatible — bare hex, no prefix)
PAYLOAD='{"action":"created","data":{"issue":{"id":12345,"shortId":"MYAPP-1A2","title":"ZeroDivisionError: division by zero","culprit":"app/views.py in divide","permalink":"https://sentry.io/organizations/myorg/issues/12345/","metadata":{"value":"division by zero"}}}}'
SECRET="fa_YOUR_PROJECT_API_KEY"
SIG="$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST "http://localhost:3100/api/projects/YOUR_PROJECT_ID/triggers/sentry" \
-H "Content-Type: application/json" \
-H "Sentry-Hook-Resource: issue" \
-H "Sentry-Hook-Signature: $SIG" \
-d "$PAYLOAD"Slack Workflow Builder
Slack uses a shared token model: the operator configures a Slack Workflow Builder "Send a web request" step that POSTs to FA carrying the project's FA API key. FA accepts the token from either the x-fa-token HTTP header or a token field in the JSON body (header wins when both are present). The comparison is constant-time (crypto.timingSafeEqual).
Why no native Slack app signing? Native Slack request signing requires a per-project signing-secret column — a database schema change that is out of scope for this increment. The shared-token model (identical to GitLab's) provides equivalent security for webhook delivery. Native signing is a future option blocked on the trigger-secret schema increment.
Setting Up a Slack Workflow Builder Step
- In Slack, open Workflow Builder and create or open a workflow (e.g. triggered by a shortcut, form submission, or channel message).
- Add a "Send a web request" step.
- URL:
https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/slack - Method:
POST - Headers: Add
x-fa-tokenwith valuefa_YOUR_PROJECT_API_KEY(the project's FA API key). - Request body (JSON): map workflow variables to FA fields:jsonOnly
{ "title": "{{workflow_variable_for_title}}", "description": "{{workflow_variable_for_description}}", "event_id": "{{unique_step_id_or_message_ts}}", "permalink": "{{permalink_to_slack_message_or_thread}}", "channel": "{{channel_name}}", "user": "{{user_name_or_display_name}}" }titleis required. All other fields are optional. - Click Save.
Alternatively, if you cannot set custom headers, include the token as a token field in the JSON body instead of the x-fa-token header. The header is preferred.
JSON Body Shape
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | The feature title. Empty or whitespace-only → ignored (204). |
description | string | no | Feature description / body text. |
event_id | string | no | Stable identifier for dedup (e.g. a Slack event ID). Repeated deliveries with the same event_id produce exactly one feature. |
permalink | string | no | URL to the originating Slack message or thread. Included in the feature's provenance line. |
channel | string | no | Channel name (e.g. #general). Included in provenance. |
user | string | no | User name or display name (e.g. @alice). Included in provenance. |
token | string | no | Auth token (body fallback — use x-fa-token header instead when possible). |
ts | string | no | Slack message timestamp — used as externalId for dedup when event_id is absent. |
What Happens (Slack)
| Scenario | Outcome |
|---|---|
Valid token + non-empty title | Feature created in the project's autonomy funnel |
Empty or whitespace-only title | 204 No Content — silently ignored |
Missing title field | 204 No Content — silently ignored |
| Unparseable JSON body | 204 No Content — silently ignored |
| Missing or wrong token (header and body) | 401 — nothing created |
Unknown :projectId | 404 |
Redelivery with same event_id | 200 {"ignored":true} — exactly one feature (best-effort in-memory dedup) |
Feature Created (Slack)
- Title:
titlefrom the JSON body (trimmed) - Description:
description(if provided), followed by a blank line and a provenance line:_Opened via Slack [link](permalink) in #channel by @user_(each ofpermalink,channel,useris appended only when present) - ExternalId for dedup:
event_id→ts→''(empty = no dedup) - Status: follows the project's
autonomy_mode(awaiting_approval/analyzing/queued) — a Slack workflow step does NOT skip the funnel. Apo_approvalproject still requires a human to approve before any agent spend. - The feature appears in the dashboard and participates in the normal FA lifecycle — clarification gates, spec-kit pipeline, PR creation, notifications, etc.
Example (manual curl)
curl -X POST "http://localhost:3100/api/projects/YOUR_PROJECT_ID/triggers/slack" \
-H "Content-Type: application/json" \
-H "x-fa-token: fa_YOUR_PROJECT_API_KEY" \
-d '{
"title": "Add dark mode toggle",
"description": "Users are requesting a dark mode. See thread for details.",
"event_id": "Ev0ABCDEF12",
"permalink": "https://myteam.slack.com/archives/C0ABCDEF/p1234567890123456",
"channel": "#product-feedback",
"user": "@alice"
}'Fleet Overview — Cross-Project Triage Dashboard
The Fleet Overview page (/fleet.html) is the portfolio-level control surface for FA admins managing multiple enrolled projects. It answers "what needs my attention right now?" in a single view — without clicking through per-project tabs.
How to reach it
- From the dashboard: click the Fleet button in the top-right header (visible to admins).
- Direct URL:
http://localhost:3100/fleet.html(or your FA host).
Sign in with the same admin API key you use for the main dashboard.
What the page shows
Fleet Summary Header
Four stat cards across the top give an instant snapshot of the entire fleet:
| Card | What it counts |
|---|---|
| Total Projects | All enrolled projects; sub-line shows how many have at least one active feature |
| In Flight | Features currently in queued or in_progress status across all projects |
| Awaiting Human | Features in awaiting_approval plus all implemented features with an open PR |
| Paused | Features blocked on awaiting_credits or awaiting_auth across all projects; shown in amber when non-zero |
| 7-Day Cost | Aggregate agent spend for features created in the last 7 days across all projects |
Fleet Attention Queue
The core of the Fleet Overview: a cross-project, SLA-aware list of every feature that requires a human decision — computed server-side and ordered so the most overdue items appear first.
Human-gated states included:
| State | Required action | Default SLA |
|---|---|---|
awaiting_approval | Approve the feature | 24 h (FLEET_APPROVAL_SLA_HOURS) |
clarification_needed | Answer the agent's questions | 24 h (FLEET_CLARIFICATION_SLA_HOURS) |
implemented (with open PR) | Review and merge | 48 h (FLEET_MERGE_SLA_HOURS) |
Not included: awaiting_credits / awaiting_auth (infra-pause states — these appear in the dedicated Blocked / Paused panel below instead), and any implemented feature without a PR URL (nothing to merge yet).
Ordering: breached items (age > SLA) appear first, then non-breached — both groups sorted oldest-waiting first within the group.
Age badges:
- Red badge (
"3d — SLA 24h ⚠") — the item has breached its SLA threshold. - Neutral badge (
"6h") — the item is still within SLA.
Age accuracy: item age is derived from the durable run-events ledger (the timestamp of the transition into the current status), not from updated_at (which is bumped by PR-merge polling). Each item reports its age_basis field ("state_entry" or "created_at" fallback) so the number is transparent.
The queue is fetched from the new GET /api/features/admin/attention endpoint and auto-refreshes every 30 seconds.
Blocked / Paused Features
A dedicated cross-project panel that surfaces every feature in an infra-pause state — awaiting_credits (credit limit reached) or awaiting_auth (OAuth session expired) — so a stall anywhere in the portfolio is never silent.
Why this matters: the agent pauses and preserves the workspace when either condition is hit. Without this panel, a paused feature disappears from every other section of the Fleet Overview and the operator has no cross-project signal that work is stopped.
What each row shows:
| Column | Meaning |
|---|---|
| Project | Project name (links to the project in the main dashboard) |
| Feature | Feature title |
| Pause Reason | Status badge — awaiting credits (credit limit) or awaiting auth (OAuth expired) |
| Age (paused) | How long ago the feature last changed state (a proxy for time-in-pause) |
| Action | Logs link — opens the live log viewer for this feature so you can see the exact pause event |
Sorted oldest-first — the longest-stalled features appear at the top.
Explicit empty state: when no features are paused, the panel shows No features are paused — count: 0. This makes "nothing is blocked" distinguishable from "the panel failed to load."
Resolving a pause:
awaiting_credits— add credits to the Anthropic account; FA resumes automatically on the next poll tick.awaiting_auth— runclaude /loginon the FA host to refresh the OAuth session; FA resumes automatically once credentials are valid.
The panel is data-only — it reads features already fetched by the existing GET /api/features/admin/all call; no extra network request is made. It refreshes on the same 30-second cadence as the rest of the page.
Project Health Strip
One row per enrolled project showing at a glance:
| Column | Meaning |
|---|---|
| Project | Project name |
| Mode | Autonomy mode (full_auto / auto_safe / po_approval) |
| Spec Kit | Spec-kit enrollment status (enabled, enrolling, awaiting merge, disabled) |
| In Progress | Count of features currently running |
| Queued | Count of features waiting to run |
| Awaiting Merge | Count of implemented features with an open PR (highlighted when non-zero) |
| 7-Day Cost | Agent spend for features created in the last 7 days for this project |
| Drill down link → main dashboard (filtered to this project) |
Engine Routing Independence
The Engine Routing Independence panel is the fleet-level governance signal for FA's marquee cross-engine review claim: that a reviewer engine different from the author engine catches uncorrelated errors — errors "structurally impossible for any single-vendor tool." This panel makes that claim provable and auditable across the entire portfolio.
What it shows:
A fleet rollup bar across the top of the panel counts every feature by classification:
| Classification | Meaning |
|---|---|
| Independent | The reviewer engine is present and differs from the author engine — the gold standard for catching correlated errors. |
| Correlated | The reviewer engine is the same as the author engine, or no distinct reviewer engine ran. A project left with author=claude, reviewer=claude is silently correlated. This is the signal you want to catch. |
| Unknown | No routing data exists — the feature pre-dates spec 093's routing recording, or the agent never ran (e.g. pending, cancelled). |
Below the rollup, a per-feature table shows the author engine, reviewer engine, fixer engine, and classification for every feature across all projects. By default the table is filtered to correlated + unknown rows — the "problem" features — so the operator can see exactly where independent review is missing. Toggle "Show only correlated & unknown" off to see the full fleet.
Reading the results:
- A fleet where every reviewed feature shows independent means the routing policy is working as designed — different engines are providing uncorrelated review coverage.
- Any correlated row is a project that is configured with the same engine for both author and reviewer roles (or has no reviewer policy set). To fix: edit the project's Engine Routing Policy and assign a different engine profile to the
reviewerrole. - Unknown rows are expected for older or never-run features. They are not a problem unless the count grows and newly-run features remain unknown (which would indicate a routing recording regression).
Cross-reference: to configure or change a project's engine routing policy, see Engine Routing Policy.
Operator workflow
- Open
/fleet.htmlat the start of a work session. - Action Queue first: work top-to-bottom — approve pending features, address PR review comments, then review and merge open PRs.
- Blocked / Paused second: check the Paused count in the summary header. If non-zero, open the Blocked / Paused panel and resolve each stall — add credits for
awaiting creditsitems; re-authenticate (claude /loginon the FA host) forawaiting authitems. A non-zero paused count means agent work has silently stopped somewhere in the portfolio. - Health Strip third: spot projects with a large Awaiting Merge count (they have shipped work that hasn't been merged yet) or zero In Progress + Queued (idle projects that may need new features submitted).
- Engine Routing Independence: confirm the fleet is getting genuine cross-engine review. Any correlated row calls for a routing policy update.
- The page auto-refreshes every 30 seconds; click Refresh for an immediate update.
Fleet attention alerts
FA can push breach notifications to a project's configured channels so the operator does not have to watch the dashboard. When a human-gated feature breaches its SLA, FA sends a message to every notification channel configured on that project.
Enable: set FLEET_ATTENTION_ALERTS_ENABLED=true in your .env (off by default). No database migration is needed.
Opt in per project: a project receives alerts only if it has notification channels configured (notification_channels + notifications_enabled). Use the existing channel configuration on the project (Telegram, Discord, Slack, WhatsApp, or Email). Projects without channels are silently skipped.
Sweep interval: FA checks for new breaches every FLEET_ATTENTION_SWEEP_INTERVAL_MS milliseconds (default 900 000 ms = 15 min). Adjust to taste; shorter intervals mean faster alert delivery, longer intervals reduce noise.
Deduplication: each breach is alerted exactly once per breach window. If an item resolves (leaves the attention queue) and later breaches again, a fresh alert is sent. FA uses an in-memory dedup set — it resets on process restart, so a breach may re-alert after a restart.
Alert message format:
Needs a human: "<title>" has awaited <action> for Nh (SLA Nh) [— <pr_url>]Where <action> is approval, clarification, or merge; the PR URL is appended for merge-action items.
Tenant safety: each project's alert is sent only to that project's own channels, using that project's own notification configuration — features are never cross-project.
Active Runs — Live Pulse of Every Running Feature
The Active Runs panel is a persistent section at the top of the main dashboard (above the stat cards and feature list) that answers "what is running right now?" across all projects in a single glance — without clicking into individual features.
It is visible to admins only and auto-refreshes on the dashboard's existing 10-second cadence.
What counts as "active"
A feature is shown in the panel when its status is one of:
| Status | Meaning |
|---|---|
analyzing | The agent is reviewing the request (auto_safe / po_approval flow) |
queued | Accepted and waiting for an implementation slot |
in_progress | An agent is actively writing code |
reviewing | The spec-conformance reviewer is running (comparing diff vs. spec) |
revising | An agent is addressing PR review comments |
Features in any other status (e.g. implemented, merged, failed, cancelled) are not shown.
Per-row fields
Each active feature is rendered as one row:
| Column | Source | Notes |
|---|---|---|
| Project | Project name from the enrolled projects list | Generic — any enrolled project |
| Feature | Feature title; click to open the feature detail modal | |
| Status | Status badge (same colour-coding as the main features list) | |
| Current step | Latest run event from GET /api/features/admin/:id/events, rendered via the same renderEventPayload helper used in the Run Monitor | Shows tool name + input summary for tool_call events, ↳ agent badge for subagent_spawn, from → to for status_change. — if no events recorded yet. |
| Elapsed | Client-computed from updated_at (or created_at) — no schema column | Format: 30s, 4m 12s, 2h 7m |
| Logs | Link to /logs.html?id=<id> for live log streaming |
The panel fetches events for at most 15 active features per refresh cycle (bounded fan-out). If more than 15 features are active simultaneously, the first 15 are shown with full detail and a "+K more active" note is appended.
Empty state
When no features are active, the panel displays:
No active runs right now.
This explicit empty state is intentional — it makes the absence of activity distinguishable from a frozen or loading page.
Observability triangle
Three views cover different observability needs:
| View | Scope | Question it answers |
|---|---|---|
| Active Runs panel (this section) | All projects, live | "What is running right now?" (pulse) |
Active Runs drill-down (/runs.html, §9) | All projects, live | "What is every agent doing right now, grouped by project?" (deep drill-down) |
| Run Monitor (feature-detail, §5.7) | Single feature, live event stream | "What is this specific feature doing, step by step?" (drill-down) |
Fleet Overview (/fleet.html, §7) | All projects, SLA-aware | "What needs human attention and how overdue is it?" (triage) |
Active Runs Drill-Down — Portfolio-Wide Live Run Activity
The Active Runs page (/runs.html) is the deep drill-down companion to the Active Runs pulse panel. Where the pulse panel shows one row per running feature across all projects, the drill-down page shows every tool call and subagent spawn, grouped by project, in a single live-refreshing view — the "governance you can see" surface for fleet operators and regulated buyers.
- Direct URL:
http://localhost:3100/runs.html(or your FA host). - Nav link: Available from the main dashboard header ("Runs" button, next to Fleet).
- Auth: Same admin API key as the main dashboard (stored in browser via
localStorage). - Refresh: Single
setIntervalat 10 seconds — no SSE/WebSocket.
Active set
A feature is shown when its status is one of:
| Status | Meaning |
|---|---|
analyzing | Agent reviewing the request |
queued | Waiting for an implementation slot |
in_progress | Agent actively writing code |
revising | Agent addressing PR review comments |
Terminal states (merged, wont_merge, failed, cancelled) and awaiting-human states (implemented, awaiting_approval, clarification_needed) are excluded.
Portfolio → project → feature grouping
Active features are grouped by project, with projects sorted alphabetically by name. Each project section shows:
- Project name and the count of its active runs.
- One run card per active feature inside the project, showing:
- Feature title, status badge, elapsed time (client-side from
updated_atorcreated_at— no extra DB column). - Detail link (opens the main dashboard filtered to that project) and Logs link (
/logs.html?id=<feature-id>). - A live activity feed of the most-recent ≤40 events from
GET /api/features/admin/:id/events.
- Feature title, status badge, elapsed time (client-side from
Per-run live activity feed
Each run card contains a compact, chronological feed of run events:
| Event type | Display |
|---|---|
tool_call | Tool name + input summary + turn number — in monospace, muted |
subagent_spawn | ↳ agent badge (amber) + subagent type + description — visually indented/distinct so spawns read as a shallow tree |
status_change | Lightweight dimmed divider: from → to |
| Other types | Silently skipped — degrades gracefully on missing/unknown payload fields |
Fan-out cap and overflow
To bound API fan-out, only the first 15 active features (globally, in the order returned) receive live event fetches. If more than 15 features are active simultaneously:
- The first 15 show the full activity feed.
- The remainder show status and elapsed time with a note that their live events were not fetched.
- A footer note reads: +K more active runs — live events not fetched (cap of 15).
Empty state
When no features are active, the page displays:
No active runs right now.
This explicit empty state distinguishes "nothing running" from a broken or loading page.
Observability views compared
| View | Scope | Refresh | Question it answers |
|---|---|---|---|
| Active Runs panel (main dashboard, §8) | All projects | Dashboard cadence | "What is running right now?" (pulse) |
Active Runs drill-down (/runs.html, this section) | All projects, grouped by project | 10s | "What is every agent doing, tool-by-tool?" (fleet drill-down) |
| Run Monitor (feature detail, §5.7) | Single feature | Dashboard cadence | "What is this feature doing step by step?" (single-feature drill-down) |
Fleet Overview (/fleet.html, §7) | All projects | 30s | "What needs human attention and how overdue?" (SLA triage) |
Dashboard Stat Cards — What Each Count Means
The row of stat cards at the top of the main dashboard gives an instant fleet-wide snapshot. Here is what each card counts:
| Card | What it counts | Cumulative or transient? |
|---|---|---|
| Projects | Enrolled projects (admins only) | Current snapshot |
| Total Features | All features ever submitted to FA (across all statuses) | Cumulative — never decreases |
| In Progress | Features actively being implemented by an agent right now | Transient — goes to zero when idle |
| Implemented (awaiting merge) | Features whose code is done and a PR is open, but the PR has not yet been merged | Transient waypoint — goes to zero once merged |
| Queued | Features accepted and waiting for an implementation slot | Transient |
| Need Clarification | Features paused because the agent raised questions that need a human answer | Transient — human-gated |
Key insight: "Implemented (awaiting merge)" is a transient waypoint, not a lifetime success tally. It counts features that are done but still sitting in review. Once you merge the PR, the feature transitions to merged and this count drops. A non-zero value here is normal if you have PRs in flight; a large or growing value that isn't decreasing is a signal to check your review queue — the Fleet Overview (/fleet.html) surfaces overdue merge SLA breaches.
Possibly-Stuck Drift Panel — Silent-State Divergence Made Visible
The Possibly-stuck drift panel is a warning section that appears automatically on the main dashboard (above the tab bar, between the stat cards and the features list) when FA detects features in a machine-driven state for longer than expected — a sign of silent state drift.
It is only shown when there is at least one stuck feature. When everything is moving normally, it is not rendered at all.
What "stuck" means
FA classifies a feature as possibly stuck when it has been in one of these states for longer than FLEET_STUCK_SLA_HOURS (default: 6 hours):
| Status | Why it can get stuck |
|---|---|
in_progress | An FA outage or agent crash left the feature stuck in an active implementation slot |
revising | Same — an agent was addressing PR review comments and FA went down |
implemented (no PR URL) | The agent finished implementing but the automatic PR creation failed (e.g. expired GitHub token), leaving the feature with code pushed but no PR opened |
Note:
implementedfeatures that already have apr_urlare not shown here — they are tracked by the Fleet Attention Queue (merge SLA) on/fleet.html. The drift panel only surfaces cases that no other dashboard surface catches.
What the panel shows
Each row contains:
| Column | Description |
|---|---|
| Project | The enrolled project name |
| Feature | Feature title; click to open the feature detail modal |
| Status | Status badge (in progress, revising, implemented) |
| Stuck for | How long the feature has been in this state (format: 6h 12m, 1h 30s, etc.) |
The badge count next to the panel title shows how many features are stuck and the configured threshold.
What to do
in_progress/revisingstuck: Check the feature's live logs (/logs.html?id=<id>) and the FA server logs. If the agent process died, the feature may need a manual state reset or retry. FA recovers stuckin_progressfeatures on restart — but only if the server was restarted after the stuck run began.implementedwith no PR: Open the feature detail and use the "Create PR" button to manually open the draft PR (the branch is already pushed — this is a pure GitHub API call).
Configuration
Set FLEET_STUCK_SLA_HOURS in the FA server's environment to adjust the threshold. The default is 6 hours. Setting it to 0 or a negative value falls back to 6.
API
The drift data is available at:
GET /api/features/admin/drift
Authorization: Bearer <admin-or-approver-key>Response:
{
"items": [
{
"project_id": "...",
"project_name": "My Project",
"feature_id": "...",
"title": "My feature",
"status": "in_progress",
"state_entered_at": "2026-07-14T06:00:00.000Z",
"age_seconds": 32400,
"threshold_hours": 6
}
],
"threshold_hours": 6
}Approvers see only their assigned projects' stuck features. The panel auto-refreshes on the dashboard's existing 10-second cadence — no additional polling timer is added.
Run Provenance — Audit Trail in Every PR
Every FA delivery run automatically produces a provenance artifact — a machine-generated Markdown file committed into the feature branch at .fa/provenance/<feature-id>.md. Each feature gets its own file, so concurrent PRs never collide on the same path. This covers:
- Standard implement runs — the normal
queued → in_progress → implementedflow - Spec-kit implement runs — features processed via the spec-kit pipeline (
/speckit.implement) - Revise runs — features revised via
/reviseto address PR review comments; the per-feature provenance file is overwritten in place to reflect the latest run (the committed file is the audit trail; no new PR is opened)
A compact summary is also appended to the PR body (the "Run Provenance" section) for standard and spec-kit runs, so reviewers see key governance facts without opening the file. For revise runs the summary is not re-injected into the existing PR body — the updated committed file is the governance record.
What it is
.fa/provenance/<feature-id>.md is a committed, human-readable audit trail that answers: how was this diff produced? It is generated from facts FA already holds and enforces — no guesswork, no extrapolation.
What it contains
| Section | Fields |
|---|---|
| Feature | ID, title, timestamp |
| Execution Environment | Isolation level (container / host), runtime image, network policy |
| Data Mounts (Read-Only) | Each declared data_dirs source and destination, confirming they were mounted read-only |
| Declared Commands and Results | setup_command, test_command, verify_command — the declared command text and exit code |
| Approval | Who approved the feature (approved_by, approved_at) — present only when autonomy_mode=po_approval |
| Clarifying Q&A | Questions the agent raised during analysis and the human-provided answers |
| Run Metrics | Duration, agent turns, total cost (USD), input tokens, output tokens |
| Privileged Actions | Placeholder — will list mid-run permission requests + approvers once specs 020/021 land |
Always on, no configuration needed
Provenance generation is on by default for every FA delivery run — standard implement, spec-kit implement, and revise. There is no opt-out flag or per-project setting in this version — the artifact is simply committed as part of every feature branch. A per-project opt-out is a planned follow-up increment.
Credential safety
The provenance artifact never contains secret material. It will not emit environment variable values, API keys, tokens, or OAuth credentials. It reports only non-secret enforcement facts: what isolation level was used, which data directories were mounted (paths only, not their contents), which commands were declared and their exit codes, and run cost metrics. This guarantee is enforced by the provenance module's design and verified by automated tests.
Reading the artifact
Open .fa/provenance/<feature-id>.md in the feature branch to see the full audit trail. The PR body "Run Provenance" table is a compact excerpt.
Dashboard panel (interactive)
The FA dashboard surfaces the key governance facts interactively. Click any feature to open its detail view and scroll to the Governance & Provenance section:
- A compact table showing autonomy mode, approval (who + when), runtime image, permission policy, declared commands + gate policies, budget caps, and run metrics.
- A direct link to the full
.fa/provenance/<feature-id>.mdblob on the branch (when the branch is pushed to GitHub/GitLab/Bitbucket). - Clarifying Q&A — any questions the agent raised during analysis and the human-provided answers, so auditors can see what context the agent was given.
- Empty state — "No provenance recorded for this run" when
provenance_summaryis absent (spec-kit pipeline, revision runs, or features predating spec 056).
Two export buttons are shown at the bottom of the panel:
- Export JSON — downloads the full structured governance record (see endpoint below) as
<feature-id>-provenance.json. - Export .md summary — downloads the compact PR summary text as
<feature-id>-provenance.md.
Export API
Two read-only endpoints expose the governance record programmatically. Neither endpoint ever includes secret-bearing fields (api_key, environment variable values, OAuth credentials).
Project-scoped (tenant-isolated):
GET /api/features/:id/provenance
Authorization: Bearer <project-api-key>Admin (any feature):
GET /api/features/admin/:id/provenance
Authorization: Bearer <admin-or-user-api-key>Both return the same JSON shape:
{
"feature_id": "abc12345-...",
"title": "Add user authentication",
"status": "implemented",
"autonomy_mode": "po_approval",
"approved_by": "po@example.com",
"approved_at": "2026-07-14T10:00:00.000Z",
"runtime_image": "fa-runtime:latest",
"permission_policy": null,
"test_command": "npm test",
"test_gate": null,
"verify_command": null,
"verify_gate": null,
"max_budget_tokens": null,
"max_budget_seconds": null,
"duration_ms": 45200,
"total_cost_usd": 0.1234,
"total_input_tokens": 123456,
"total_output_tokens": 12345,
"clarifications": [
{ "question": "Which database?", "answer": "PostgreSQL" }
],
"pr_url": "https://github.com/org/repo/pull/42",
"branch_name": "feature/add-user-auth-abc12345",
"provenance_doc_path": ".fa/provenance/abc12345-....md",
"provenance_summary": "### Run Provenance\n..."
}provenance_summary is the compact Markdown block appended to the PR body. It is null for spec-kit pipeline runs, revision runs, and features predating spec 056.
Returns 404 for unknown or cross-tenant feature IDs.
Example
# Run Provenance
> Machine-generated audit trail — committed by Feature Agent for every standard
> implementation run. Reports only facts FA enforced; never contains secret material.
## Feature
- **ID:** `abc12345-...`
- **Title:** Add user authentication
- **Generated at:** 2026-07-14T12:34:56.789Z
## Execution Environment
| Property | Value |
|---|---|
| Isolation | container (Docker) |
| Runtime image | `fa-runtime:latest` |
| Network policy | bridge |
## Data Mounts (Read-Only)
_No data directories were declared for this run._
## Declared Commands and Results
| Stage | Command | Exit code |
|---|---|---|
| Tests | `npm test` | ✓ 0 |
## Approval
- **Approved by:** po@example.com
- **Approved at:** 2026-07-14T10:00:00.000Z
## Clarifying Q&A
_No clarifying questions were raised for this run._
## Run Metrics
| Metric | Value |
|---|---|
| Duration | 45.2 s |
| Agent turns | 12 |
| Total cost | $0.1234 |
| Input tokens | 123,456 |
| Output tokens | 12,345 |
## Privileged Actions
> **Placeholder — forthcoming.** Once the mid-run permission gate (specs 020/021) lands,
> this section will list every privileged action the agent requested during the run
> and the identity that allowed it.Fleet Fan-out — Submit One Spec Across N Projects
The fleet fan-out primitive lets a fleet operator submit one feature (title + description + shared optional fields) to N enrolled projects in a single API call — one spec → N features → N sandboxed agent runs → N draft PRs. Without fan-out, an operator must submit N times by hand: one POST /api/features/admin per project.
This is the primary ROI multiplier for the multi-project/portfolio operator: time-saved-per-repo × N.
Who it's for
Fleet operators — anyone managing multiple enrolled projects who wants to push a common cross-cutting change (a shared dependency upgrade, a security patch, a policy change) across every repo in the fleet with one submission. Works with arbitrary enrolled projects; FA never needs to know what the projects do or what the spec describes.
The endpoint
POST /api/features/admin/fan-out (admin-authenticated)
Request body
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Short name for the feature (seeds every branch slug) |
description | string | yes | The feature request, shared verbatim across every project |
project_ids | string[] | yes | IDs of the enrolled projects to target. Deduped. Capped at MAX_FANOUT_PROJECTS (default 50) |
base_branch | string | no | Branch to clone/branch off instead of the project's default_branch, applied to every project |
spec_path | string | no | Per-feature spec/doc location override |
spec_content | string | no | Verbatim spec text written byte-identical at spec_path |
callback_url | string | no | Per-feature webhook URL for every created feature's status changes |
use_spec_kit | boolean | no | Opt into the spec-kit pipeline for each project (silently coerced to false when the project's spec-kit status is not enabled) |
code_discipline | off|lite|full | no | YAGNI discipline level for every created feature |
engine | string | no | Engine id override for every created feature. Unknown id → 400 before any features are created |
max_budget_tokens | number | no | Per-feature token cap |
max_budget_seconds | number | no | Per-feature wall-clock cap in seconds |
Validation happens once up front — if
titleordescriptionis missing, or a budget/engine field is invalid, the call returns400and no features are created.
Response
201 — at least one feature was created:
{
"batch_id": "e3b7c1a2-9f4d-4a1e-8b3c-0d2e5f6a7b8c",
"created": [
{ "project_id": "proj-aaa", "feature_id": "feat-111", "status": "queued" },
{ "project_id": "proj-bbb", "feature_id": "feat-222", "status": "awaiting_approval" }
],
"errors": [
{ "project_id": "proj-ccc", "error": "Project not found" }
]
}batch_id is a UUID that uniquely identifies this fan-out as a campaign — all N created features share this id, making the batch durable and trackable. See Campaign Grouping for the rollup API and dashboard view.
400 — no features were created (all project_ids invalid, project_ids empty, validation failed).
Per-project autonomy is respected
Fan-out is a submission convenience, not an approval bypass. Each created feature's initial status is determined by its own project's autonomy_mode — the same rule as a single-project submission:
Project's autonomy_mode | Feature enters |
|---|---|
full_auto | queued (immediate implementation) |
auto_safe | analyzing (agent reviews, may ask questions) |
po_approval | awaiting_approval (human must approve before anything runs) |
A po_approval project in the fan-out batch does not get its features auto-approved — an admin or linked approver still needs to approve them.
Partial success
Fan-out processes each project independently. If some project_ids are invalid (not found), the call still creates features for the valid ones and reports the errors in errors[]. Only when every project is invalid (or project_ids is empty) does the call return 400.
Batch cap
The call is limited to MAX_FANOUT_PROJECTS target projects (default 50, overridable via the MAX_FANOUT_PROJECTS env var). Exceeding the cap returns 400 before any features are created. Duplicate project_ids are deduped — each project gets at most one feature per call.
Example
Submit a "Upgrade ESLint to v9" spec across three repos:
curl -X POST http://localhost:3100/api/features/admin/fan-out \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Upgrade ESLint to v9",
"description": "Upgrade eslint to ^9.0.0. Update the config from .eslintrc to eslint.config.mjs (flat config). Fix any lint errors introduced by the upgrade. All existing tests must still pass.",
"project_ids": ["proj-aaa", "proj-bbb", "proj-ccc"],
"code_discipline": "lite",
"max_budget_seconds": 1800
}'Response (201):
{
"batch_id": "e3b7c1a2-9f4d-4a1e-8b3c-0d2e5f6a7b8c",
"created": [
{ "project_id": "proj-aaa", "feature_id": "feat-111", "status": "queued" },
{ "project_id": "proj-bbb", "feature_id": "feat-222", "status": "awaiting_approval" }
],
"errors": [
{ "project_id": "proj-ccc", "error": "Project not found" }
]
}proj-aaa is full_auto → queued; proj-bbb is po_approval → awaiting_approval; proj-ccc was not found. The batch_id groups all created features into a campaign — use the campaign rollup endpoints to track progress.
Dashboard
The admin dashboard includes a Fleet Fan-out button next to + Submit Feature in the Features tab. It opens a form with:
- A scrollable checklist of all enrolled projects (showing name + autonomy mode)
- Title and description fields
- Optional shared fields: base branch, callback URL, wall-clock budget, token budget
Submit the form to POST to POST /api/features/admin/fan-out. The result panel shows which projects succeeded (with their initial status) and which failed (with the error). New features appear in the main features list immediately.
What fan-out does NOT do
- No approval bypass. Each project's
po_approvalflow still requires human sign-off. - No per-project differences. All created features get the same
title,description, and shared options. If you need different descriptions per project, usePOST /api/features/adminfor each.
Campaign Grouping & Batch Rollup
Every fan-out call stamps one shared batch_id (a UUID) on all N created features, forming a durable campaign. The batch_id is:
- returned in the
POST /api/features/admin/fan-outresponse - stored on each member feature's
batch_idcolumn (readable via the feature detail endpoint) - the key for the rollup and drill-down endpoints below
Single submissions (POST /api/features or POST /api/features/admin) are not campaigns — their batch_id is null and they never appear in campaign rollups.
Campaign rollup endpoints
Both endpoints require admin authentication.
GET /api/features/admin/batches
Returns an array of all campaigns, newest-first. Each entry:
{
"batch_id": "e3b7c1a2-9f4d-4a1e-8b3c-0d2e5f6a7b8c",
"title": "Upgrade ESLint to v9",
"created_at": "2026-07-15T12:00:00.000Z",
"project_count": 3,
"feature_count": 3,
"status_breakdown": { "queued": 1, "implemented": 1, "merged": 1 },
"total_cost_usd": 1.2345,
"total_input_tokens": 45000,
"total_output_tokens": 22000,
"pr_count": 2,
"merged_count": 1
}| Field | Description |
|---|---|
batch_id | Shared UUID for this fan-out |
title | Title of the fan-out feature (all members share it) |
created_at | Earliest member creation timestamp |
project_count | Distinct projects in this campaign |
feature_count | Total member features |
status_breakdown | Count per status across all members |
total_cost_usd | Sum of total_cost_usd across completed members |
total_input_tokens / total_output_tokens | Aggregated token usage |
pr_count | Members with a pr_url |
merged_count | Members with status = 'merged' |
GET /api/features/admin/batches/:batchId
Returns the same rollup fields plus a members array with one entry per feature:
{
"batch_id": "...",
"title": "...",
"feature_count": 3,
"status_breakdown": { ... },
"total_cost_usd": 1.2345,
"members": [
{
"id": "feat-111",
"project_id": "proj-aaa",
"project_name": "my-api",
"title": "Upgrade ESLint to v9",
"status": "merged",
"total_cost_usd": 0.41,
"pr_url": "https://github.com/org/my-api/pull/42",
"created_at": "2026-07-15T12:00:01.000Z"
}
]
}Returns 404 if the batchId is not known (no features carry that id).
Dashboard — Campaigns tab
The admin dashboard includes a Campaigns tab (visible to admins only) showing:
- A table of all campaigns with: title, feature count, project count, progress (
done/total · running), open PRs, merged count, total cost - Click any campaign row to drill into the member features: project name, feature title, status badge, cost, PR link
- A Back button to return to the campaign list
The Campaigns tab is refreshed on the same 10-second loadAll cadence as the rest of the dashboard — no separate timer.
Answering the fleet operator's core question
"I bumped 12 repos this morning — how many implemented, how many PRs open, how many merged, what did it cost?"
# Check progress across all 12 repos from one fan-out call
curl -H "Authorization: Bearer $ADMIN_KEY" \
http://localhost:3100/api/features/admin/batches/<batch_id>Or open the dashboard Campaigns tab, click the campaign row, and see the per-project breakdown with live status badges and PR links.
Builder — Draft a Spec from Plain English
The Builder is an on-ramp that turns a plain-English description into a structured, editable feature spec before it enters the delivery funnel. A non-technical user describes what they want; the AI produces a bounded spec (title, description, acceptance criteria, scope boundaries) the team reviews and edits before submitting.
The draft is NOT submitted automatically. You always review and edit before clicking "Submit as feature". The submitted feature runs the full governed funnel — analyze → clarify → approve → implement → PR.
Dashboard
In the admin dashboard, click the Builder tab to access the panel:
- Select a project — choose which enrolled project this feature is for.
- Describe what you want — plain English, no technical constraints. Focus on the goal and who benefits.
- (Optional) Click "Ask clarifying questions" — the AI makes a text-only model call to identify ambiguities in your description and returns up to 5 targeted questions. Answer them inline in the panel. If the description is already specific enough, the AI returns no questions and displays "Looks specific enough to draft — no clarifying questions." Skipping this step is fine; the answers are automatically folded into the description before the draft is generated.
- Click Draft spec — the AI makes a bounded, text-only model call (no repo clone, no tools) and returns a structured spec. If you answered clarifying questions, those answers are included as a "Clarifying Q&A" block in the description so the draft is sharper.
- Review and edit the draft — the spec form shows the title, description, acceptance criteria, and scope boundaries, all editable.
- Refine conversationally — a refinement panel appears below the spec form. Type a follow-up instruction ("also handle email errors", "tighten scope to authentication only", "add a criterion for the logout flow") and click Refine. The AI updates the draft in place. You can iterate as many times as needed — each turn's instruction and the resulting title are logged in a conversation history above the input. The server is stateless: your browser holds the transcript and sends it with each refinement call.
- Review the estimate — after each draft or refine, the panel automatically fetches and displays a live cost + scope estimate: typical run cost (median, range, sample count) from the project's own history, or a clear "Not enough run history yet" message; and a coarse scope band (small / medium / large) derived from the spec's acceptance criteria count, scope boundary count, and description length. This is an estimate — actual cost varies. No model call is made; the estimate is instant.
- Opt in to committing the spec (default ON) — the "Commit the spec as a reviewable file in the PR" checkbox (checked by default) lets you attach the structured draft as a byte-identical Markdown file committed on the feature branch. The spec path defaults to
docs/features/<title-slug>.mdand is editable. When enabled, the spec ships inside the PR — reviewers can read and gate against the actual spec artifact, not just the description. Uncheck it to submit without the spec file (back-compat with the original submit flow). - Click Submit as feature — calls
POST /api/builder/admin/submit. The feature enters the normal governed funnel (analyze → clarify → approve → implement → PR). The description always carries the operative brief (title + folded criteria + boundaries). When commit-spec is enabled,spec_contentandspec_pathare also persisted, and the agent writes the spec file byte-identical tospec_pathon the branch.
API endpoints
POST /api/builder/clarify — AI-driven pre-draft questions
Before drafting, ask the AI to identify what it needs to know to write a sharp spec. Send the raw plain-English ask; get back targeted questions (0–5) whose answers will improve the draft. When the ask is already specific enough, returns an empty array — no manufactured questions.
POST /api/builder/clarify
Authorization: Bearer <project-api-key>
Content-Type: application/json
{
"description": "Improve the dashboard."
}Response (200) — ambiguous ask:
{
"questions": [
"Which specific metrics or sections should be improved?",
"Is this about visual layout, data freshness, or loading performance?",
"Who is the primary audience — end users, admins, or both?"
]
}Response (200) — well-specified ask:
{
"questions": []
}Validation: description must be a non-empty string, max 8 KB (reuses draft validation) → 400 on invalid. Missing/invalid project API key → 401. Engine error → 502.
No side effects: creates no feature row; stateless (no DB write, no repo clone).
Using the answers: the client appends the Q&A to the description before calling /draft:
<original description>
Clarifying Q&A:
Q: Which specific metrics or sections should be improved?
A: The cost chart and the per-feature status breakdown.
Q: Who is the primary audience — end users, admins, or both?
A: Admins only.Then call /draft with this enriched description — /draft is unchanged.
Admin variant: POST /api/builder/admin/clarify — same body + project_id, uses admin key.
POST /api/builder/draft — initial draft
Project-key callers can draft directly without the dashboard:
POST /api/builder/draft
Authorization: Bearer <project-api-key>
Content-Type: application/json
{
"description": "Allow users to reset their password via email link."
}Response (200):
{
"title": "Add password reset via email",
"description": "Allow users who have forgotten their password to request a reset link sent to their registered email address, then set a new password via the link.",
"acceptance_criteria": [
"A 'Forgot password?' link is visible on the login page",
"Submitting the form sends a reset email within 30 seconds",
"The reset link expires after 1 hour",
"Setting a new password via the link succeeds and the user can log in"
],
"scope_boundaries": [
"Do NOT: add phone/SMS verification",
"Do NOT: change the existing login flow",
"Do NOT: add rate limiting beyond what already exists"
]
}Validation: description must be a non-empty string, max 8 KB. Missing/invalid project API key → 401. Model output unparseable → 502 with an actionable detail message.
No side effects: creates no feature row, makes no git commit.
POST /api/builder/refine — conversational refinement
After you have a draft, refine it iteratively with natural-language follow-up instructions. The server is stateless — you pass the current structured draft and the running conversation history on each call; the server returns an updated draft.
POST /api/builder/refine
Authorization: Bearer <project-api-key>
Content-Type: application/json
{
"current_draft": {
"title": "Add password reset via email",
"description": "...",
"acceptance_criteria": ["..."],
"scope_boundaries": ["..."]
},
"instruction": "also require the new password to meet the site's existing strength rules",
"messages": [
{ "role": "user", "content": "earlier refinement instruction" },
{ "role": "assistant", "content": "{...previous draft as JSON...}" }
]
}messages is optional on the first refinement call and grows with each round-trip (the client appends each exchange). current_draft is always the definitive current state — the model refines it, it does not start over.
Response (200): same DraftSpecResult shape as /draft — updated title, description, acceptance_criteria, scope_boundaries.
Validation:
instructionmust be a non-empty string, max 8 KB → 400.current_draftmust be a valid spec shape (non-empty title and description, at least one item each in acceptance_criteria and scope_boundaries) → 400.messagesmay contain at most 20 turns (to bound cost) → 400 when exceeded.- Missing/invalid project API key → 401.
- Model output unparseable or engine failure → 502 with
detail.
No side effects: creates no feature row, makes no git commit.
Admin variants: POST /api/builder/admin/clarify, POST /api/builder/admin/draft, and POST /api/builder/admin/refine accept an additional project_id body field and use an admin key instead.
POST /api/builder/submit — submit a drafted spec as a feature
After reviewing and editing the draft, submit it as a feature via the dedicated builder submit endpoint. Unlike posting directly to /api/features, this endpoint accepts the structured draft and assembles the description + optional spec artifact server-side.
POST /api/builder/submit
Authorization: Bearer <project-api-key>
Content-Type: application/json
{
"current_draft": {
"title": "Add password reset via email",
"description": "Allow users who have forgotten their password to request a reset link...",
"acceptance_criteria": [
"A 'Forgot password?' link is visible on the login page",
"Submitting the form sends a reset email within 30 seconds"
],
"scope_boundaries": [
"Do NOT: add phone/SMS verification",
"Do NOT: change the existing login flow"
]
},
"commit_spec": true,
"spec_path": "docs/features/add-password-reset-via-email.md",
"base_branch": "feature/auth-step-1-abc123"
}Fields:
| Field | Type | Required | Description |
|---|---|---|---|
current_draft | object | yes | The final structured draft — must be a valid spec shape (non-empty title and description, at least one item each in acceptance_criteria and scope_boundaries). |
commit_spec | boolean | no | When true, assembles a Markdown spec document from the draft and writes it byte-identical to spec_path on the feature branch. Default: not set (no spec file). |
spec_path | string | no | Where to commit the spec file, e.g. docs/features/my-feature.md. Only used when commit_spec is true. Defaults to docs/features/<title-slug>.md. |
base_branch | string | no | Branch to fork from; the feature's PR will target this branch (stacked PR flow). |
Response (201): the created feature row — same shape as POST /api/features. The spec_path and spec_content fields on the row confirm what will be committed.
Validation:
current_draftmust be a valid spec shape → 400.- Missing/invalid project API key → 401.
What it does:
- Assembles the feature
descriptionby folding acceptance criteria and scope boundaries into the description text (the "operative brief" — unchanged from the original submit flow). - If
commit_specistrue, callsbuildSpecMarkdown(current_draft)(pure, deterministic — title as H1, description, Acceptance Criteria bullets, Scope Boundaries bullets) and setsspec_content+spec_pathon the feature row. - Creates the feature through the standard
createFeaturepath — the feature enters the project's normal initial status (full_auto → queued; auto_safe → analyzing; po_approval → awaiting_approval). - The agent later writes
spec_contentbyte-identical tospec_pathon the branch — no regeneration, no alteration.
No double-dipping: the operative description is always preserved — commit_spec is additive, not a replacement.
Back-compat: submitting without commit_spec (or commit_spec: false) behaves exactly like the original /api/features submit — no spec file, no spec_path, no spec_content.
Admin variant: POST /api/builder/admin/submit accepts an additional project_id body field and uses an admin key instead.
POST /api/builder/estimate — live cost + scope estimate
Before clicking Submit as feature, the dashboard automatically fetches a live estimate for the current draft. You can also call the endpoint directly:
POST /api/builder/estimate
Authorization: Bearer <project-api-key>
Content-Type: application/json
{
"current_draft": {
"title": "Add password reset via email",
"description": "...",
"acceptance_criteria": ["..."],
"scope_boundaries": ["..."]
}
}Response (200):
{
"cost": {
"basis": "history",
"sampleSize": 7,
"medianUsd": 0.0412,
"minUsd": 0.0089,
"maxUsd": 0.1203
},
"scope": {
"band": "small",
"criteriaCount": 4,
"boundaryCount": 3,
"descriptionLength": 182
}
}When the project has fewer than FA_ESTIMATE_MIN_SAMPLES (default 3) completed runs with recorded costs, the cost basis is "insufficient":
{
"cost": { "basis": "insufficient", "sampleSize": 1 },
"scope": { "band": "medium", "criteriaCount": 5, "boundaryCount": 2, "descriptionLength": 340 }
}Cost field: derived purely from the project's own completed-run history — the same empirical estimate shown at po_approval submission. No fabricated model price table. Honest per-project data only.
Scope field: a deterministic coarse band (small / medium / large) computed from the draft's acceptance criteria count, scope boundary count, and description length. No model call — instant and reproducible. Banding: large when ≥6 criteria or ≥600-char description or ≥5 scope boundaries; small when ≤3 criteria and ≤300-char description; medium otherwise.
In the dashboard: the estimate appears automatically after a draft or refine completes, above the Submit button. It shows the cost range ("median $X, range $min–$max across N past runs") or a clear insufficient-history message, plus the scope band and counts. Framed as an estimate — actual cost varies by run complexity.
No side effects: creates no feature row, no DB write, no git operation. Pure DB read (cost history) + deterministic function.
Validation: current_draft must be a valid spec shape (non-empty title and description, at least one item each in acceptance_criteria and scope_boundaries) → 400. Missing/invalid project API key → 401.
Admin variant: POST /api/builder/admin/estimate requires an admin key and a project_id in the body; missing project_id → 400, unknown → 404.
Key properties
| Property | Detail |
|---|---|
| Pure text call | No repo clone, no tools, no sandbox — a single bounded model call per draft or refine. |
| Tenant-scoped | Project resolved from the API key, not the body — a caller can only draft/refine/estimate for their own project. |
| Credential-safe | Reuses the host-side Claude runner (same api/oauth auth as the existing analyze flow). No new credential surface. |
| Stateless refinement | The server stores nothing between refine calls — the client holds the transcript and passes it each time. No new DB table, no session storage. |
| No funnel bypass | The draft is just a starting point. Submitted features run every gate (analyze, clarify, approve). |
| Editable before submit | Every field in the draft is editable — title, description, criteria, and boundaries — at any point before submitting. |
| Bounded cost | Refinement caps the conversation at 20 turns and the instruction at 8 KB. |
| Estimate at the front door | Cost + scope transparency before submission — the governance wedge made visible before a feature enters the funnel. |
| Durable spec artifact | When "Commit the spec" is enabled (default), the structured draft is assembled into a clean Markdown file and written byte-identical to spec_path on the feature branch — ships inside the PR, reviewable and gateable. The spec-conformance Reviewer (spec 002) can gate against it. |
| Operative description preserved | The committed spec is additive — the feature description always carries the folded operative brief regardless of commit_spec. |
Starting a new project from the Builder (Connect an existing repo)
The Builder's "+ New Project" button lets an operator enroll a brand-new repository without leaving the dashboard. This is the CONNECT path: you point FA at a repo you have already created (empty is fine) — FA enrolls it instantly and you can immediately draft + submit the first feature.
CONNECT only — auto-creating a repo (CREATE) is a follow-up increment. The "New Project" on-ramp deliberately reuses FA's existing operator credential (
GITHUB_TOKEN) and admin auth — no new credential surface is introduced. Per-tenant VCS credential storage and repo auto-creation are deferred to spec 011 and the parked CREATE increment.
How to use it (dashboard)
- In the Builder tab, click + New Project (next to the project selector).
- Enter a project name and the repository URL of the repo you've already created (e.g.
https://github.com/acme/my-new-service). Empty repos are fine. - Optionally set a default branch (defaults to
main) and an autonomy mode (defaults topo_approval— governed). - Click Enroll project — FA calls
POST /api/builder/new-project, validates the URL, and creates the project using the standard enrollment path. On success, the panel auto-selects the new project in the Builder selector. - The "New Project" panel closes after 4 seconds and you're ready to Draft spec → Submit the first feature via the existing Builder flow.
The enrolled project is governed by its autonomy mode: with po_approval (the default), the first feature goes to awaiting_approval and requires a PO sign-off before the agent runs — the same gate that protects any other project.
API
POST /api/builder/new-project
Authorization: Bearer <admin-key>
Content-Type: application/json
{
"name": "My new service",
"repo_url": "https://github.com/acme/my-new-service",
"default_branch": "main",
"autonomy_mode": "po_approval"
}Fields:
name(required) — project display name.repo_url(required) — HTTPS or SSH git URL of an existing repository. Must include an owner and repo name. Malformed or structurally invalid URLs are rejected 400. A URL for a repo already enrolled by another project is rejected 409.default_branch(optional) — defaults tomain.autonomy_mode(optional) —po_approval(default),auto_safe, orfull_auto. Unknown values are silently coerced topo_approval.
Response (201):
{
"project_id": "...",
"api_key": "fa_...",
"name": "My new service",
"repo_url": "https://github.com/acme/my-new-service"
}Save the api_key — it is returned only once (same as POST /api/projects).
Errors:
400— missing/emptyname, malformedrepo_url, or URL with no owner/repo path.401— missing or invalid admin credential.409— a project is already enrolled for thatrepo_url.
No schema change. The endpoint reuses createProject() internally — there are no new columns and no migration.
Building from a Baseline Branch (Stacked PRs)
The Builder supports incremental, stacked development: pick an existing feature branch as a baseline, and the AI drafts the next increment aware of what is already on that branch. When you submit, the new feature branches off the baseline and its PR targets the baseline — a true git stack (not a GitHub repo fork).
"Fork" in this context means a git branch fork — the new feature branch is created off the baseline branch, not the default branch. GitHub automatically retargets the PR to the default branch when the parent merges.
How to use it (dashboard)
Select a project in the Builder panel.
A "Build on top of a branch (baseline)" selector loads automatically — it lists the project's open PRs/branches from your VCS provider (GitHub, GitLab, or Bitbucket). Leave it at "None" to draft off the default branch (existing behavior).
Pick a baseline if you want to build on top of an in-progress feature branch. The selector shows the branch name and PR number.
Describe the next increment — the AI is told about the baseline: its diff stat vs. the default branch and its spec/documentation if present. The draft focuses on what is new in your increment, not what the baseline already does.
Review, refine, and estimate as usual.
Click Submit as feature — the feature is submitted with
base_branchset to the selected baseline. FA forks a new feature branch off the baseline and opens a stacked draft PR targeting the baseline. After submission, the success message shows:Forked from
feature/step-1-abc— stacked on PR #5 (GitHub auto-retargets to default when the parent merges).
How to use it (API)
Pass base_branch in the draft and submit calls:
POST /api/builder/draft
Authorization: Bearer <project-api-key>
Content-Type: application/json
{
"description": "Add the second step to the onboarding wizard",
"base_branch": "feature/onboarding-step-1-abc123"
}List open branches to populate a picker:
GET /api/builder/branches
Authorization: Bearer <project-api-key>Response:
{
"branches": [
{ "name": "feature/onboarding-step-1-abc123", "prUrl": "https://github.com/o/r/pull/42", "prNumber": 42, "prTitle": "Add onboarding step 1" }
]
}Then submit the feature with the chosen baseline via the builder submit endpoint (recommended — handles spec assembly):
POST /api/builder/submit
Authorization: Bearer <project-api-key>
Content-Type: application/json
{
"current_draft": {
"title": "Add onboarding step 2",
"description": "...",
"acceptance_criteria": ["..."],
"scope_boundaries": ["..."]
},
"base_branch": "feature/onboarding-step-1-abc123",
"commit_spec": true
}The agent will clone off feature/onboarding-step-1-abc123, implement the feature on a new branch (feature/add-onboarding-step-2-<id>), and open a draft PR targeting feature/onboarding-step-1-abc123. If commit_spec: true, the spec file is also committed on that branch.
Admin variant: use GET /api/builder/admin/branches?project_id=<id>, POST /api/builder/admin/draft with project_id in the body, and POST /api/builder/admin/submit for the final submission.
Stack lifecycle
| Event | What happens |
|---|---|
| Parent PR merges | GitHub automatically retargets the child PR to the default branch. No FA action needed. |
| Parent branch moves (rebase) | The child's PR may need manual rebase. FA v1 does not auto-restack chains; update the child branch manually and force-push. |
| No baseline selected | Behavior is unchanged from the original Builder — branches off the default branch, PR targets the default branch. |
| Baseline with no PR | If the baseline branch has no open PR, prNumber and prUrl are null in the branch list. You can still use it as a baseline; no "stacked on PR #N" indicator is shown. |
v1 scope: one-level stacks only. Multi-hop chain management (auto-rebase, restack on merge) is a future follow-up.
Audit Log Export & SIEM Integration
FA records every agent event — status transitions, tool calls, subagent spawns — in its run_events ledger. The audit export API makes that ledger machine-ingestible with a SHA-256 hash chain so a downstream consumer (SIEM, compliance system, or periodic anchor) can prove the export was not altered after generation.
Full details, curl examples, and the chain verification algorithm are in docs/OPERATIONS.md — "Audit log export & integrity verification".
Key points for users:
- Endpoints:
GET /api/audit/export(download JSONL or CSV) andGET /api/audit/manifest(chain head only, for periodic anchoring). - Auth: a project API key exports only that project's events. An admin key exports across all projects and can apply
project_id/feature_id/since/untilfilters. - Secret redaction: payload fields matching
api_key,token,secret,password,authorization,credential,refreshToken,accessToken,GITHUB_TOKEN, orANTHROPIC_API_KEY(case-insensitive, recursively) are replaced with[REDACTED]before the export leaves FA. No credentials are exported. - Integrity: each record carries a
record_hashchained from the previous record. The final hash appears in theX-Audit-Chain-Headresponse header and in the manifest'schain_headfield. CallverifyAuditChain(records, manifest)(exported fromsrc/services/audit-export.ts) or re-implement the algorithm in any language to confirm the export is intact. - SIEM ingest: point your SIEM at the
/api/audit/export?format=jsonlendpoint. The NDJSON format (one JSON object per line) is natively supported by most log-ingestion pipelines. Filter bysince/untilfor incremental pulls.
Viewing & verifying the audit log in the dashboard
The admin dashboard includes an Audit tab that makes the tamper-evident audit trail human-readable without requiring a JSONL download or CLI tools.
Opening the Audit tab:
- Open the FA dashboard in your browser.
- Click the Audit tab in the top navigation bar.
Filters: Use the filter controls at the top of the panel to narrow the view:
- Project ID — leave blank to see all projects (admin key), or enter a specific project ID. A project API key is automatically scoped to its own project and cannot view others.
- Feature ID — narrow to a single feature's events.
- Since / Until — ISO-8601 timestamps to scope a time window (e.g.
2024-06-01T00:00:00Z).
Click Apply to refresh the table.
Tamper-evidence badge: Immediately below the filters, a badge shows the integrity status of the currently filtered record set:
- ✅ Chain verified — the SHA-256 hash chain over all matching records is intact. No record has been altered or deleted since FA wrote it.
- ⚠️ Chain broken at record N — the hash at position N does not match the expected value, indicating tampering or corruption at or before that record.
The chain is always verified over the full filtered set, not just the visible page. Pagination does not weaken the verification.
Record table columns:
| Column | Meaning |
|---|---|
| Created At | Timestamp the event was recorded |
| Feature ID | The feature this event belongs to |
| Project ID | The owning project |
| Seq | Monotonically increasing per-feature sequence number |
| Type | Event type (e.g. status_transition, tool_call) |
| Payload | Event data (click to expand); secrets already redacted |
| Hash (short) | First 12 characters of the SHA-256 record hash |
Exporting for SIEM: The ↓ JSONL and ↓ CSV links in the panel header download the current filter's full record set in machine-readable format. These call the existing GET /api/audit/export endpoint — the same format your SIEM ingests for incremental pulls.
Mobile Dashboard — Phone & Tablet Access
The FA dashboard (public/index.html) and live log viewer (public/logs.html) are fully responsive and usable one-handed on a phone. No pinch-zoom is required for any governance action.
Supported flows on mobile
| Flow | Where | Works at 375px? |
|---|---|---|
| Submit a feature | Features tab → Submit Feature button | ✓ Full-width form, touch-friendly inputs |
| Triage / approve | Features tab → feature row → Approve button | ✓ Detail modal fits phone width |
| Cancel a run | Feature detail modal → Cancel run | ✓ |
| Answer clarifications | Feature detail → clarification-answer form | ✓ |
| PO approval | Feature detail → Approve button (po_approval mode) | ✓ ≥44px tap target |
| Merge a PR | Held for Human Merge panel | ✓ Stacks on narrow screens |
| View live logs | logs.html | ✓ Monospace log readable at 11px |
| Fleet overview | /fleet.html | Viewport meta present; no additional responsive breakpoints added in this release |
Layout behaviour at phone width (375×812)
- Stat cards collapse to a single column via CSS
auto-fit minmax(180px, 1fr). - Feature table scrolls horizontally inside its card — no columns are clipped.
- Nav tabs scroll horizontally (no-scrollbar strip); each tab is ≥44px tall.
- Header status badges scroll horizontally as a single row.
- Detail panel stacks label above value so nothing wraps awkwardly.
- Modal action buttons (Approve / Cancel / Close) stack full-width for easy thumb reach.
- Forms collapse two-column rows to single column; inputs use
font-size: 16pxto suppress iOS Safari's auto-zoom behaviour.
Desktop layout is unchanged
All breakpoints are max-width-gated (≤768px tablet, ≤480px phone). Wide-viewport users see no visual change.