Skip to content

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

  1. Core Concepts & Getting Started
  2. Submitting & Managing Features
  3. Configuring a Project (the Environment Manifest)
  4. Spec-Kit, Review, Pull Requests & Agent Permissions
  5. Notifications, Webhooks & Observability
  6. Inbound Triggers (GitHub, GitLab, Linear, Sentry & Slack)
  7. Fleet Overview — Cross-Project Triage Dashboard
  8. Active Runs — Live Pulse of Every Running Feature
  9. Active Runs Drill-Down — Portfolio-Wide Live Run Activity
  10. Dashboard Stat Cards — What Each Count Means
  11. Possibly-Stuck Drift Panel — Silent-State Divergence Made Visible
  12. Run Provenance — Audit Trail in Every PR
  13. Fleet Fan-out — Submit One Spec Across N Projects
  14. Campaign Grouping & Batch Rollup
  15. Builder — Draft a Spec from Plain English
  16. Audit Log Export & SIEM Integration
  17. 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:

  1. Enroll a project — register a Git repository with FA (its URL, default branch, and how much autonomy you want to grant).
  2. Submit a feature request — a title and a description of what you want built.
  3. 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

EntityWhat it is
ProjectAn 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.
FeatureA 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.
UserA 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).
ClarificationA 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:

ModeNew feature starts inBehaviorUse when
full_autoqueuedNo 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_safeanalyzingThe 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_approvalawaiting_approvalThe 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)──▶ resumes

Status by status:

StatusMeaningTypically moves to
pendingJust submitted, not yet routed by the engine.awaiting_approval, analyzing, or queued per autonomy mode
awaiting_approvalWaiting for a human to approve (po_approval only).analyzing on approval
analyzingThe agent is reading the request to decide if it's actionable or needs clarification.clarification_needed or queued
clarification_neededThe agent has open questions for you. Answer them to continue.back to queued/analysis once answered
queuedReady to build, waiting for an implementation slot.in_progress
in_progressThe agent is actively writing code in the sandbox.implemented, failed, awaiting_credits, or awaiting_auth
implementedDraft 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_progressThe agent is actively writing code in the sandbox.implemented, failed, or awaiting_credits
implementedDraft 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
reviewingThe 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)
revisingThe agent is addressing PR review comments on the same branch/PR (via /revise).back to implemented
mergedTerminal. Your PR was merged; FA detected it by polling GitHub.
wont_mergeTerminal. PR was closed without merging (auto-detected) or you explicitly decided not to ship it.
failedThe agent could not complete the run.re-queued via /retry
cancelledYou aborted the run. Editable and re-runnable.re-queued via /retry
awaiting_creditsPaused because the Claude credit limit was reached; the workspace is preserved.resumes automatically when credits return
awaiting_authPaused 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:

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:

  1. Links your Google identity to your FA user account (recorded in the identities table).
  2. Issues a signed session cookie that keeps you logged in for 7 days.
  3. 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:

  1. Verifies your ID token signature, issuer, audience, expiry, nonce, and email verification status.
  2. Links or updates your OIDC identity in the identities table.
  3. Issues a signed session cookie valid for 7 days.
  4. 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:

  1. Records a local identity entry (same as Google does with a google entry).
  2. Issues a signed session cookie that keeps you logged in for 7 days.
  3. 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:

  1. On the login page, click "Set / forgot password?" below the email/password form.
  2. Enter your email address and click Send Reset Link.
  3. Check your email for a link from Feature Agent (valid 24 hours, single-use).
  4. Click the link. The login page will show a "Set New Password" form.
  5. Enter and confirm your new password (minimum 8 characters), then click Set Password.
  6. 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-request always 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>.

CredentialFormatWho holds itGrants
Project API keyfa_ + 64 hex charsThe enrolled project / its automationSubmitting and viewing that project's own features, and self-configuring its environment (/api/project). Scoped to a single project.
User API keyfa_ + 64 hex charsAn admin or approver userAdmin users: full access. Approver users: review/approve features on their linked projects (/api/users/me, /api/users/me/features, approvals).
Legacy admin keyvalue of ADMIN_API_KEY env varThe operatorAdmin-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/approveadmin or approver (an approver must be linked to that project).
  • /api/project (self-config) — the project API key (identifies the project; no :id needed).
  • /api/users (manage users)admin. /api/users/me and /api/users/me/featuresany user key.

Note on dev mode: when FA is bound to loopback only and no ADMIN_API_KEY is set, admin endpoints are open for local development. As soon as the server is exposed (non-loopback bind, FA_EXPOSED=true, or NODE_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 secret api_key stripped).
  • PATCH /api/project — update only the self-configurable fields below. Any other field is rejected with 403 (not silently ignored), and the response lists both the forbidden_fields you tried and the allowed_fields you 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:

bash
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:

json
{
  "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.

bash
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:

json
{
  "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:

bash
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).

bash
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):

bash
# 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):

bash
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:

bash
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

FieldTypeWhat it does / when to use
titlestringRequired. Short name for the feature. Also seeds the branch slug (feature/<slug>-<id>).
descriptionstringRequired. The actual request. The more concrete, the fewer clarifications the agent needs. Attach artifacts (below) to pin down anything visual or data-shaped.
prioritylow | medium | high | criticalAdvisory priority label.
submitter_emailstringWhere "your feature is done" notifications go (if the project routes to submitter).
submitter_contactobjectNon-email contact for notifications: { "telegram_chat_id": "...", "whatsapp": "..." }.
use_spec_kitbooleanOpt 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_branchstringBranch 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_pathstringWhere 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_contentstringVerbatim 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_urlstringPer-feature webhook URL for this feature's status changes, overriding the project's callback_url. Signed with your project key either way.
runtime_imagestringContainer image override for this run. Inherits the project image, then the server default. Use when this feature needs a different toolchain.
network_policystringDocker 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_commandstringShell 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_gatewarn | blockOverrides 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_modeapi | oauthHow the sandboxed agent authenticates inside the container. Defaults to the server's configured mode.
code_disciplineoff | lite | fullInjects a YAGNI / minimal-code discipline block to curb over-engineering. Overrides the project default; omit (or null) to inherit.
revieweroff | spec_conformanceOpt-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_modelstringModel for the reviewer agent (e.g. claude-opus-4-8). Blank = inherit project or implementer model (self-review). Overrides the project default.
reviewer_enginestringEngine id for the reviewer agent. Blank = inherit project or implementer engine. Overrides the project default.
permission_policyallow_all | deny_all | ask | governedGoverns mid-run permission requests (see §4). Overrides the project default.
enginestringEngine id override for this run. Must be one of the server's known engines, or 400. Inherits the project engine / default.
engine_routing_policyobject | nullPer-feature declarative role→engine routing policy (e.g. {"reviewer":"gemini-profile"}). Overrides the project policy; null inherits. See Engine Routing Policy.
max_budget_tokensnumber | nullHard-stop token cap for the run; must be > 0 when set. Invalid → 400.
max_budget_secondsnumber | nullHard-stop wall-clock cap (seconds) for the run. Works under both auth modes. Invalid → 400.

Validation: missing title/description400; a bad engine, max_budget_tokens, or max_budget_seconds400 with a message naming the problem.

bash
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 the files field. Only before implementation begins (status pending/awaiting_approval/analyzing/clarification_needed/queued; else 409). 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/:idclarifications array; each has id, question, answer, status, source). Answer each:

POST /api/features/:id/clarifications/:clarificationId/answer (project key)

bash
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):

bash
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_email was 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_by fields — 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.

ActionEndpoint (project key)Requires statusWhat it does
RetryPOST /:id/retryfailed or cancelledRe-queues for a fresh run. Bumps retry_count (on failed). Edit the feature first (via PATCH) to change the request before retrying.
RerunPOST /:id/rerunfailed, 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.
CancelPOST /:id/cancelanalyzing, queued, clarification_needed, in_progress, awaiting_credits, revisingAborts an in-flight run (SIGTERM if live → 202 {cancelling:true}). Cancels to cancelledexcept a revising feature reverts to implemented (PR intact).
Won't-mergePOST /:id/wont-mergeany non-terminalMarks wont_merge (terminal). Optional {reason} recorded. Fires a notification.
Create PRPOST /:id/create-primplemented, has branch_name, no pr_urlRecovery: 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.
RevisePOST /:id/reviseimplemented, has pr_url + branch_nameAddresses 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.
NotifyPOST /:id/notifyanyForce-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), the estimated_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 (404 if 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

RouteAuthScopeWhat you can set
PATCH /api/projects/:idAdmin keyAny project fieldEverything, including governance/security fields
PATCH /api/projectThe project's own API keyThis 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.

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

FieldWhat it doesExampleWhen
runtime_imageContainer 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_policyDocker 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_dirsRead-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.
servicesEphemeral deps (Postgres, etc.) started alongside the agent. name (network alias), image, env, ready, expose (env injected into the agent container).see exampleA suite needing a real DB/cache/broker.
envEnv vars injected into the agent container.{"MARKET_DATA_OFFLINE":"1"}Flags, offline switches.
setup_commandRuns 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_policyproject network_policyFA_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 — none breaks engine egress. Both api and oauth auth modes require the agent to reach Anthropic over the network to authenticate. Setting network_policy: "none" cuts all egress and will cause every run to fail. Only use none if you have an engine/setup that genuinely needs zero egress and handles authentication differently. Useful values are bridge (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):

json
{ "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?).

FieldWhat it doesExampleWhen
preflight_commandRuns 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_commandThe test gate. Unset → npm test fallback (but see test_gate default below).".venv/bin/python -m pytest -q"Every project with tests.
test_gateOn 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_commandFeature-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_gateOn 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

FieldWhat it doesNotes
max_budget_tokensHard-stop token cap per run; > 0 when set. Unset → FA_MAX_BUDGET_TOKENS (uncapped if unset).0/negative → 400.
max_budget_secondsHard-stop wall-clock cap per run (seconds). Works under both auth modes.Guards a runaway/hung run.
agent_max_turnsPer-project turn budget override (AGENT_MAX_TURNS).Raise for large features.

Behavior

FieldValuesWhat it doesSelf-configurable?
code_disciplineoff(default)|lite|fullYAGNI decision ladder in the prompt; full adds a self-review delete pass. Never weakens correctness/tests/security.Yes
revieweroff(default)|spec_conformanceSpec-conformance reviewer: posts a real VCS review comparing the diff against the spec. A feature-level reviewer field overrides this default.Yes
reviewer_modelstring|nullModel 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_enginestring|nullEngine id for the reviewer agent. Null = same engine as the implementer. Must match a configured engine profile or claude-code.Yes
permission_policyallow_all(default)|deny_all|ask|governedMid-run permission handling (see §4).Yes
auth_modeapi|oauthIn-container agent auth (default FA_AGENT_AUTH).Admin only
engineclaude-code(default)|acp|declared-profile-idAgent 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_prbooleanAuto-open a draft PR on implemented.Admin only
allowed_toolsstring[]Restrict the agent's tool set.Admin only
allowed_originsstring[]CORS origins for this project.Admin only
protectedbooleanA protected project can't be deleted (DELETE403).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).

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

yaml
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/sse

FA 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 env key (project-scoped, no new global credential).
  • --strict-mcp-config prevents any host-global MCP servers from leaking into the agent run.
  • Governed browsing (spec 028): when engine=acp and permission_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) + optional args (array of strings) + optional env (object of string values)
  • SSE (URL-based): url (string, required) + optional env
  • Each server entry must have either command or url; a missing or malformed mcp field throws ManifestValidationError (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.

yaml
# 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: anthropic

Shape (strictly validated — unknown keys throw ManifestValidationError):

FieldTypeDescription
provideranthropic | bedrock | vertex | proxyRequired. Which endpoint family to use.
base_urlstringURL override (ANTHROPIC_BASE_URL for proxy; ANTHROPIC_BEDROCK_BASE_URL for bedrock).
regionstringAWS region (AWS_REGION) for bedrock; GCP region (CLOUD_ML_REGION) for vertex.
project_idstringGCP project (ANTHROPIC_VERTEX_PROJECT_ID) for vertex.
retentionzero | defaultDeclared retention posture; recorded in the provenance artifact, not enforced by FA.

What FA resolves per provider:

ProviderEnv vars set
bedrockCLAUDE_CODE_USE_BEDROCK=1 + optional AWS_REGION, ANTHROPIC_BEDROCK_BASE_URL
vertexCLAUDE_CODE_USE_VERTEX=1 + optional ANTHROPIC_VERTEX_PROJECT_ID, CLOUD_ML_REGION
proxyANTHROPIC_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.

yaml
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 block

How 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 outcomeblock valueEffect
Exit 0eitherGate passes; run continues.
Non-zero exittrue (default)Blocking gate failed — feature transitions to failed; no commit, no push, no PR. Workspace preserved for inspection.
Non-zero exitfalseAdvisory — result recorded as warn; run continues and PR is opened.

Gate shape (strictly validated — malformed entries throw ManifestValidationError):

FieldTypeRequiredDescription
namestringyesNon-empty label used in logs, run-events, and the provenance artifact.
commandstringyesNon-empty shell command FA runs via bash -lc inside the sandbox.
blockbooleannotrue (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.

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

bash
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:

bash
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):

bash
# 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/logs

The 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 hostProviderDraft createdToken required
github.comGitHubDraft PRGITHUB_TOKEN
gitlab.comGitLabDraft MR (title prefixed Draft:)GITLAB_TOKEN
GITLAB_HOST env valueGitLab (self-hosted)Draft MRGITLAB_TOKEN
bitbucket.orgBitbucket CloudPR (title prefixed Draft:)BITBUCKET_TOKEN
anything elseGitHub (default)Draft PRGITHUB_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_TOKEN or GITLAB_TOKEN); merging transitions the feature to merged (terminal).
  • Won't-merge: POST /:id/wont-merge marks wont_merge (terminal); optional {reason}.
  • Recover a missing PR/MR: POST /:id/create-pr (implemented, has branch_name, no pr_url) — surfaces the real API failure as 502. 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:

bash
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:

  1. Transitions the feature to reviewing — visible in the dashboard with a blue badge.
  2. Spawns a fresh, independent agent run that sees only three things: the authoritative spec (verbatim spec_content if present → generated doc at doc_path → the feature description), the full PR/MR diff, and the project constitution. The reviewer sees no implementer reasoning, no prior conversation.
  3. 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.
  4. Posts a real VCS review: GitHub APPROVE or REQUEST_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.
  5. 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, and risk_signals for auditability.
  6. On APPROVE: records a reviewer_final event and returns the feature to implemented. You still merge. FA proposes; humans decide.
  7. 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 to revising. 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 to implemented.
  • 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 2 REQUEST_CHANGES rounds with the deviation still unresolved, FA escalates to a human: it leaves the feature at implemented, leaves the PR intact with the standing REQUEST_CHANGES review, 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 implemented exactly as before.

Dashboard visibility: loop progress is observable through existing surfaces — no new panel needed:

  • The reviewing and revising status badges show live state during each round.
  • The Run Events timeline shows each reviewer_verdict event (per round, with round and unmet_count) and the terminal reviewer_final event (with outcome: approved | escalated and reason).
  • 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).
  • 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 implemented with a standing REQUEST_CHANGES review + 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 categoryExamples
Auth / credentials / permissionsTouches auth checks, credential handling, or permission gates
Weakened safety checksRemoves tests, assertions, invariant guards
Broadened execution surfaceAdds process exec (spawn/eval), network calls, or filesystem writes
Schema / data changesDB migrations, destructive data operations
Secret-shaped literalsAPI keys, tokens, or passwords hardcoded in source
Large-blast-radius deletionsSignificant 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:

  1. spec_content (verbatim — byte-identical to what was submitted)
  2. doc_path (the generated spec committed on the branch)
  3. 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:

FieldWhereDescription
reviewer_modelproject or featureThe Claude model (or other engine's model) to use for the reviewer agent. Blank = implementer model. Example: claude-opus-4-8.
reviewer_engineproject or featureThe 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_model equals 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 distinct reviewer_model narrows the gap between self-review and independent evaluation; it does not guarantee correctness. Treat APPROVE as "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):

RoleWhen it runs
authorImplements the feature (the main coding agent).
reviewerSpec-conformance review after implementation.
fixerApplies targeted fixes (e.g. after a failing test gate).

Policy shape (JSON object on the project or feature):

json
{ "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):

  1. Feature per-role static fieldfeature.engine (author) / feature.reviewer_engine (reviewer)
  2. Feature engine_routing_policy — the feature's own policy for that role
  3. Project engine_routing_policy — the project-level policy for that role
  4. Project per-role static fieldproject.engine (author) / project.reviewer_engine (reviewer)
  5. DEFAULT_ENGINE_ID (claude-code) — the built-in fallback

Setting the policy:

  • Project-level (applies to all features): PATCH /api/projects/:id with engine_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_policy in POST /api/features. null inherits from the project.

Example — author on claude-code (default), reviewer on a declared gemini-profile:

bash
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:

json
{ "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/provenancerouting_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):

CategoryMeaning
small-patchSmall, focused change — typo fix, one-liner, docs-only update
large-integrationLarge feature, new subsystem, architectural change, complex multi-file integration
bugfixFixing a defect, crash, or broken functionality
refactorCode-quality improvement or restructuring with no behavior change
otherDoes 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):

json
{
  "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):

bash
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:

bash
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:

  1. FA runs a single short headless LLM call to classify the feature by task shape.
  2. The classified category + a one-sentence rationale are stored in routing_rationale (same column as Tier-0 routing decisions).
  3. resolveEngineForRole reads the stored category and looks up the engine from the category map.
  4. 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-integrationTouches many components across the codebaseEngines: author=claude-code · reviewer=gemini-profile (classifier:large-integration)

This is also available in GET /api/features/admin/:id/provenancerouting_rationale.category.

Precedence summary (Tier-0 + Tier-1 combined):

  1. Feature per-role static field (engine / reviewer_engine)
  2. Feature engine_routing_policy — direct string engine id (Tier-0)
  3. Project engine_routing_policy — direct string engine id (Tier-0)
  4. Project per-role static field (project.engine / project.reviewer_engine)
  5. Tier-1 classifier — fires only when steps 1–4 all missed AND classify:true is set for the role
  6. 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:

bash
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": "..." }
  }
]'
FieldRequiredDescription
idyesUnique engine name; used as the engine field value on projects/features
cmdyesExecutable to spawn (path or name on $PATH)
argsnoExtra arguments passed to cmd
transportno'acp' (default) or 'headless' — see below
authEnvnoEnv 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:

TransportDrive-pathGovernance 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).
headlessSingle-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: governed or ask).
  • 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 transport is 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 authEnv keys

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:

bash
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):

bash
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):

PolicyBehavior
allow_allAuto-allow every tool call (headless). Default.
deny_allAuto-deny every tool call.
askSuspend on every permission request → route to an approver. Safest, but a real run stalls constantly (demos/high-stakes).
governedAuto-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):

bash
# 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 permissionId404; 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 ToolKindExamplesVerdict
readnavigate to URL, get page text, screenshotauto-allow
searchquery, grep, discovery scanauto-allow
thinkinternal reasoning stepauto-allow
executeclick, form submit, run JSask approver
editfill form field, mutate DOMask approver
fetchHTTP request, download fileask approver
deleteremove element or fileask approver
moverename or relocateask approver
switch_modeagent mode changeask approver
otheruncategorised by tool authorask approver
(absent)kind field missingask 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:

yaml
mcp:
  servers:
    playwright:
      command: npx
      args: ["@playwright/mcp@latest"]
      env:
        DISPLAY: ":1"   # only needed for headed mode; omit for headless

Step 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:

bash
docker build -t fa-runtime-browser:latest containers/fa-runtime-browser

Then select it for the project (or per feature) via the existing declarative seam — no code changes:

bash
# 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:

bash
npx playwright test --grep "renders without blank screen"

or inline with the @playwright/mcp server already on PATH inside the container:

bash
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:

bash
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:

bash
# 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-browser image 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:

StatusNotified role(s)
awaiting_approvalpo (includes the cost estimate)
clarification_neededpo if po_approval mode, else submitter
in_progresssubmitter
implementedpo + submitter (branch + PR link)
failedpo + submitter
merged / wont_mergeoutcome 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:

json
{ "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.

bash
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):

js
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 formWall-clock budget (seconds) and Token budget (tokens) fields set max_budget_seconds and max_budget_tokens for all features in the project. Leave blank to inherit the server default (FA_MAX_BUDGET_SECONDS / FA_MAX_BUDGET_TOKENS).
  • Submit feature formWall-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 viewToken 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, or revising), 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 to FA_MAX_BUDGET_*, uncapped if unset). tokens_used updates live; crossing the cap hard-stops the run and the workspace is preserved for inspection (same as a failure). Must be > 0 when 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, with project_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).

bash
curl "http://localhost:3100/api/features/report?format=csv" \
  -H "Authorization: Bearer fa_PROJECT_KEY" -o project-report.csv

5.2 Time-series (inc 2)

GET /api/features/report/timeseries (project key) → spend + run trend over time.

Query parameters:

ParamValuesDefaultDescription
bucketday, weekdayBucketing granularity. Unknown values fall back to day.
formatcsvJSONReturn 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

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

Dashboard: 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):

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

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

Dashboard: 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

typeWhen recordedKey payload fields
status_changeEvery lifecycle transitionfrom, to, optional reason
tool_callEach agent tool invocation during an implementation runtool (tool name), summary (tool name + ≤200-char single-line input preview), turn (agent turn number)
subagent_spawnEach 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: status timeline (each status with enteredAt/durationMs), transitions, recoveries, retries, terminal. 404 if 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_change event displayed with from → to arrows.
  • Live tool-call activity — each tool_call event displays the tool name, truncated input preview, and turn number. Events appear in real time as the agent works.
  • Subagent spawns — each subagent_spawn event displays with a ↳ agent badge, 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_approval project requires a human to approve the resulting feature before any agent spend happens. auto_safe projects still run the analysis gate. Only full_auto projects 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:

bash
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 varDefaultMeaning
FA_INBOUND_TRIGGERS_ENABLEDtrueSet to false to disable all inbound trigger endpoints (returns 404)

Find your project's API key (used as the shared secret for all providers):

bash
curl http://localhost:3100/api/projects/YOUR_PROJECT_ID \
  -H "Authorization: Bearer fa_ADMIN_KEY" | jq .api_key

GitHub Issues

Setting Up a GitHub Webhook

  1. Go to your GitHub repo → Settings → Webhooks → Add webhook.
  2. Payload URL: https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/github
  3. Content type: application/json
  4. Secret: your project's FA API key (fa_...) — this is the HMAC signing secret.
  5. Which events? Choose "Let me select individual events" → tick Issues only.
  6. 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)

ScenarioOutcome
issues event, action: openedFeature 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-256401 — nothing created
Unknown :projectId404
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)

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

  1. Go to your GitLab project → Settings → Webhooks → Add new webhook.
  2. URL: https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/gitlab
  3. Secret token: your project's FA API key (fa_...).
  4. Trigger: tick Issues events only. Leave all other triggers off.
  5. 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)

ScenarioOutcome
Issue Hook, object_attributes.action === 'open'Feature created in the project's autonomy funnel
Issue Hook with action: update, close, or reopen204 No Content — silently ignored
Any other event (Push Hook, Merge Request Hook, etc.)204 No Content — silently ignored
Invalid or missing X-Gitlab-Token401 — nothing created
Unknown :projectId404
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)

bash
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

  1. In Linear, go to Settings → API → Webhooks → Create webhook (workspace-level) or Team Settings → Webhooks.
  2. URL: https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/linear
  3. Signing secret: your project's FA API key (fa_...).
  4. Data change events: tick Issues only. Leave all other types off.
  5. 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)

ScenarioOutcome
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 type204 No Content — silently ignored
Invalid or missing linear-signature401 — nothing created
Unknown :projectId404
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.title from the webhook payload
  • Description: data.description, with a provenance line appended: _Opened via Linear issue [ENG-123](url)_ (using data.identifier and data.url)
  • Status: follows the project's autonomy_mode (awaiting_approval / analyzing / queued) — a po_approval project 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)

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

  1. In Sentry, go to Settings → Integrations → WebHooks (or Project Settings → Integrations → WebHooks).
  2. Payload URL: https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/sentry
  3. In the webhook configuration, set the signing secret to your project's FA API key (fa_...). Sentry will use this to compute the Sentry-Hook-Signature HMAC.
  4. Events: select Issue events only. Leave all other event types off.
  5. 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)

ScenarioOutcome
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 resource204 No Content — silently ignored
Invalid or missing Sentry-Hook-Signature401 — nothing created
Missing Sentry-Hook-Resource header204 No Content — silently ignored
Empty or whitespace-only issue title204 No Content — silently ignored
Unknown :projectId404
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.title from the webhook payload (trimmed)
  • Description: data.issue.culprit (or data.issue.metadata.value if culprit is absent), followed by a provenance line: _Opened via Sentry issue [MYAPP-1A2](permalink)_ (using data.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. A po_approval project 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)

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

  1. In Slack, open Workflow Builder and create or open a workflow (e.g. triggered by a shortcut, form submission, or channel message).
  2. Add a "Send a web request" step.
  3. URL: https://your-fa-host/api/projects/YOUR_PROJECT_ID/triggers/slack
  4. Method: POST
  5. Headers: Add x-fa-token with value fa_YOUR_PROJECT_API_KEY (the project's FA API key).
  6. Request body (JSON): map workflow variables to FA fields:
    json
    {
      "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}}"
    }
    Only title is required. All other fields are optional.
  7. 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

FieldTypeRequiredDescription
titlestringyesThe feature title. Empty or whitespace-only → ignored (204).
descriptionstringnoFeature description / body text.
event_idstringnoStable identifier for dedup (e.g. a Slack event ID). Repeated deliveries with the same event_id produce exactly one feature.
permalinkstringnoURL to the originating Slack message or thread. Included in the feature's provenance line.
channelstringnoChannel name (e.g. #general). Included in provenance.
userstringnoUser name or display name (e.g. @alice). Included in provenance.
tokenstringnoAuth token (body fallback — use x-fa-token header instead when possible).
tsstringnoSlack message timestamp — used as externalId for dedup when event_id is absent.

What Happens (Slack)

ScenarioOutcome
Valid token + non-empty titleFeature created in the project's autonomy funnel
Empty or whitespace-only title204 No Content — silently ignored
Missing title field204 No Content — silently ignored
Unparseable JSON body204 No Content — silently ignored
Missing or wrong token (header and body)401 — nothing created
Unknown :projectId404
Redelivery with same event_id200 {"ignored":true} — exactly one feature (best-effort in-memory dedup)

Feature Created (Slack)

  • Title: title from 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 of permalink, channel, user is appended only when present)
  • ExternalId for dedup: event_idts'' (empty = no dedup)
  • Status: follows the project's autonomy_mode (awaiting_approval / analyzing / queued) — a Slack workflow step does NOT skip the funnel. A po_approval project 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)

bash
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:

CardWhat it counts
Total ProjectsAll enrolled projects; sub-line shows how many have at least one active feature
In FlightFeatures currently in queued or in_progress status across all projects
Awaiting HumanFeatures in awaiting_approval plus all implemented features with an open PR
PausedFeatures blocked on awaiting_credits or awaiting_auth across all projects; shown in amber when non-zero
7-Day CostAggregate 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:

StateRequired actionDefault SLA
awaiting_approvalApprove the feature24 h (FLEET_APPROVAL_SLA_HOURS)
clarification_neededAnswer the agent's questions24 h (FLEET_CLARIFICATION_SLA_HOURS)
implemented (with open PR)Review and merge48 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:

ColumnMeaning
ProjectProject name (links to the project in the main dashboard)
FeatureFeature title
Pause ReasonStatus 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)
ActionLogs 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 — run claude /login on 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:

ColumnMeaning
ProjectProject name
ModeAutonomy mode (full_auto / auto_safe / po_approval)
Spec KitSpec-kit enrollment status (enabled, enrolling, awaiting merge, disabled)
In ProgressCount of features currently running
QueuedCount of features waiting to run
Awaiting MergeCount of implemented features with an open PR (highlighted when non-zero)
7-Day CostAgent 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:

ClassificationMeaning
IndependentThe reviewer engine is present and differs from the author engine — the gold standard for catching correlated errors.
CorrelatedThe 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.
UnknownNo 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 reviewer role.
  • 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

  1. Open /fleet.html at the start of a work session.
  2. Action Queue first: work top-to-bottom — approve pending features, address PR review comments, then review and merge open PRs.
  3. 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 credits items; re-authenticate (claude /login on the FA host) for awaiting auth items. A non-zero paused count means agent work has silently stopped somewhere in the portfolio.
  4. 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).
  5. Engine Routing Independence: confirm the fleet is getting genuine cross-engine review. Any correlated row calls for a routing policy update.
  6. 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:

StatusMeaning
analyzingThe agent is reviewing the request (auto_safe / po_approval flow)
queuedAccepted and waiting for an implementation slot
in_progressAn agent is actively writing code
reviewingThe spec-conformance reviewer is running (comparing diff vs. spec)
revisingAn 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:

ColumnSourceNotes
ProjectProject name from the enrolled projects listGeneric — any enrolled project
FeatureFeature title; click to open the feature detail modal
StatusStatus badge (same colour-coding as the main features list)
Current stepLatest run event from GET /api/features/admin/:id/events, rendered via the same renderEventPayload helper used in the Run MonitorShows tool name + input summary for tool_call events, ↳ agent badge for subagent_spawn, from → to for status_change. if no events recorded yet.
ElapsedClient-computed from updated_at (or created_at) — no schema columnFormat: 30s, 4m 12s, 2h 7m
LogsLink 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:

ViewScopeQuestion 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 setInterval at 10 seconds — no SSE/WebSocket.

Active set

A feature is shown when its status is one of:

StatusMeaning
analyzingAgent reviewing the request
queuedWaiting for an implementation slot
in_progressAgent actively writing code
revisingAgent 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_at or created_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.

Per-run live activity feed

Each run card contains a compact, chronological feed of run events:

Event typeDisplay
tool_callTool 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_changeLightweight dimmed divider: from → to
Other typesSilently 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

ViewScopeRefreshQuestion it answers
Active Runs panel (main dashboard, §8)All projectsDashboard cadence"What is running right now?" (pulse)
Active Runs drill-down (/runs.html, this section)All projects, grouped by project10s"What is every agent doing, tool-by-tool?" (fleet drill-down)
Run Monitor (feature detail, §5.7)Single featureDashboard cadence"What is this feature doing step by step?" (single-feature drill-down)
Fleet Overview (/fleet.html, §7)All projects30s"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:

CardWhat it countsCumulative or transient?
ProjectsEnrolled projects (admins only)Current snapshot
Total FeaturesAll features ever submitted to FA (across all statuses)Cumulative — never decreases
In ProgressFeatures actively being implemented by an agent right nowTransient — 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 mergedTransient waypoint — goes to zero once merged
QueuedFeatures accepted and waiting for an implementation slotTransient
Need ClarificationFeatures paused because the agent raised questions that need a human answerTransient — 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):

StatusWhy it can get stuck
in_progressAn FA outage or agent crash left the feature stuck in an active implementation slot
revisingSame — 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: implemented features that already have a pr_url are 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:

ColumnDescription
ProjectThe enrolled project name
FeatureFeature title; click to open the feature detail modal
StatusStatus badge (in progress, revising, implemented)
Stuck forHow 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 / revising stuck: 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 stuck in_progress features on restart — but only if the server was restarted after the stuck run began.
  • implemented with 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:

json
{
  "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 → implemented flow
  • Spec-kit implement runs — features processed via the spec-kit pipeline (/speckit.implement)
  • Revise runs — features revised via /revise to 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

SectionFields
FeatureID, title, timestamp
Execution EnvironmentIsolation 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 Resultssetup_command, test_command, verify_command — the declared command text and exit code
ApprovalWho approved the feature (approved_by, approved_at) — present only when autonomy_mode=po_approval
Clarifying Q&AQuestions the agent raised during analysis and the human-provided answers
Run MetricsDuration, agent turns, total cost (USD), input tokens, output tokens
Privileged ActionsPlaceholder — 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>.md blob 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_summary is 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:

json
{
  "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

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

FieldTypeRequiredDescription
titlestringyesShort name for the feature (seeds every branch slug)
descriptionstringyesThe feature request, shared verbatim across every project
project_idsstring[]yesIDs of the enrolled projects to target. Deduped. Capped at MAX_FANOUT_PROJECTS (default 50)
base_branchstringnoBranch to clone/branch off instead of the project's default_branch, applied to every project
spec_pathstringnoPer-feature spec/doc location override
spec_contentstringnoVerbatim spec text written byte-identical at spec_path
callback_urlstringnoPer-feature webhook URL for every created feature's status changes
use_spec_kitbooleannoOpt into the spec-kit pipeline for each project (silently coerced to false when the project's spec-kit status is not enabled)
code_disciplineoff|lite|fullnoYAGNI discipline level for every created feature
enginestringnoEngine id override for every created feature. Unknown id → 400 before any features are created
max_budget_tokensnumbernoPer-feature token cap
max_budget_secondsnumbernoPer-feature wall-clock cap in seconds

Validation happens once up front — if title or description is missing, or a budget/engine field is invalid, the call returns 400 and no features are created.

Response

201 — at least one feature was created:

json
{
  "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_modeFeature enters
full_autoqueued (immediate implementation)
auto_safeanalyzing (agent reviews, may ask questions)
po_approvalawaiting_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:

bash
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):

json
{
  "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_autoqueued; proj-bbb is po_approvalawaiting_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_approval flow 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, use POST /api/features/admin for 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-out response
  • stored on each member feature's batch_id column (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:

json
{
  "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
}
FieldDescription
batch_idShared UUID for this fan-out
titleTitle of the fan-out feature (all members share it)
created_atEarliest member creation timestamp
project_countDistinct projects in this campaign
feature_countTotal member features
status_breakdownCount per status across all members
total_cost_usdSum of total_cost_usd across completed members
total_input_tokens / total_output_tokensAggregated token usage
pr_countMembers with a pr_url
merged_countMembers with status = 'merged'

GET /api/features/admin/batches/:batchId

Returns the same rollup fields plus a members array with one entry per feature:

json
{
  "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?"

bash
# 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:

  1. Select a project — choose which enrolled project this feature is for.
  2. Describe what you want — plain English, no technical constraints. Focus on the goal and who benefits.
  3. (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.
  4. 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.
  5. Review and edit the draft — the spec form shows the title, description, acceptance criteria, and scope boundaries, all editable.
  6. 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.
  7. 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.
  8. 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>.md and 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).
  9. 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_content and spec_path are also persisted, and the agent writes the spec file byte-identical to spec_path on 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:

json
{
  "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:

json
{
  "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):

json
{
  "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:

  • instruction must be a non-empty string, max 8 KB → 400.
  • 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.
  • messages may 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:

FieldTypeRequiredDescription
current_draftobjectyesThe 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_specbooleannoWhen 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_pathstringnoWhere 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_branchstringnoBranch 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_draft must be a valid spec shape → 400.
  • Missing/invalid project API key → 401.

What it does:

  1. Assembles the feature description by folding acceptance criteria and scope boundaries into the description text (the "operative brief" — unchanged from the original submit flow).
  2. If commit_spec is true, calls buildSpecMarkdown(current_draft) (pure, deterministic — title as H1, description, Acceptance Criteria bullets, Scope Boundaries bullets) and sets spec_content + spec_path on the feature row.
  3. Creates the feature through the standard createFeature path — the feature enters the project's normal initial status (full_auto → queued; auto_safe → analyzing; po_approval → awaiting_approval).
  4. The agent later writes spec_content byte-identical to spec_path on 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):

json
{
  "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":

json
{
  "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

PropertyDetail
Pure text callNo repo clone, no tools, no sandbox — a single bounded model call per draft or refine.
Tenant-scopedProject resolved from the API key, not the body — a caller can only draft/refine/estimate for their own project.
Credential-safeReuses the host-side Claude runner (same api/oauth auth as the existing analyze flow). No new credential surface.
Stateless refinementThe 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 bypassThe draft is just a starting point. Submitted features run every gate (analyze, clarify, approve).
Editable before submitEvery field in the draft is editable — title, description, criteria, and boundaries — at any point before submitting.
Bounded costRefinement caps the conversation at 20 turns and the instruction at 8 KB.
Estimate at the front doorCost + scope transparency before submission — the governance wedge made visible before a feature enters the funnel.
Durable spec artifactWhen "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 preservedThe 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)

  1. In the Builder tab, click + New Project (next to the project selector).
  2. 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.
  3. Optionally set a default branch (defaults to main) and an autonomy mode (defaults to po_approval — governed).
  4. 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.
  5. 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 to main.
  • autonomy_mode (optional) — po_approval (default), auto_safe, or full_auto. Unknown values are silently coerced to po_approval.

Response (201):

json
{
  "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/empty name, malformed repo_url, or URL with no owner/repo path.
  • 401 — missing or invalid admin credential.
  • 409 — a project is already enrolled for that repo_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)

  1. Select a project in the Builder panel.

  2. 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).

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

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

  5. Review, refine, and estimate as usual.

  6. Click Submit as feature — the feature is submitted with base_branch set 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:

json
{
  "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

EventWhat happens
Parent PR mergesGitHub 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 selectedBehavior is unchanged from the original Builder — branches off the default branch, PR targets the default branch.
Baseline with no PRIf 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) and GET /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 / until filters.
  • Secret redaction: payload fields matching api_key, token, secret, password, authorization, credential, refreshToken, accessToken, GITHUB_TOKEN, or ANTHROPIC_API_KEY (case-insensitive, recursively) are replaced with [REDACTED] before the export leaves FA. No credentials are exported.
  • Integrity: each record carries a record_hash chained from the previous record. The final hash appears in the X-Audit-Chain-Head response header and in the manifest's chain_head field. Call verifyAuditChain(records, manifest) (exported from src/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=jsonl endpoint. The NDJSON format (one JSON object per line) is natively supported by most log-ingestion pipelines. Filter by since/until for 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:

  1. Open the FA dashboard in your browser.
  2. 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:

ColumnMeaning
Created AtTimestamp the event was recorded
Feature IDThe feature this event belongs to
Project IDThe owning project
SeqMonotonically increasing per-feature sequence number
TypeEvent type (e.g. status_transition, tool_call)
PayloadEvent 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

FlowWhereWorks at 375px?
Submit a featureFeatures tab → Submit Feature button✓ Full-width form, touch-friendly inputs
Triage / approveFeatures tab → feature row → Approve button✓ Detail modal fits phone width
Cancel a runFeature detail modal → Cancel run
Answer clarificationsFeature detail → clarification-answer form
PO approvalFeature detail → Approve button (po_approval mode)✓ ≥44px tap target
Merge a PRHeld for Human Merge panel✓ Stacks on narrow screens
View live logslogs.html✓ Monospace log readable at 11px
Fleet overview/fleet.htmlViewport 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: 16px to 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.

Released under the MIT License.